Merge "No-op refactoring for TrafficStats APIs" into main
diff --git a/TEST_MAPPING b/TEST_MAPPING
index bcf5e8b..94adc5b 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -414,6 +414,15 @@
           "exclude-annotation": "androidx.test.filters.RequiresDevice"
         }
       ]
+    },
+    // TODO: upgrade to presubmit. Postsubmit on virtual devices to monitor flakiness only.
+    {
+      "name": "CtsHostsideNetworkTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "options": [
+        {
+          "exclude-annotation": "androidx.test.filters.RequiresDevice"
+        }
+      ]
     }
   ],
   "imports": [
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 8d96066..3b197fc 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -114,7 +114,7 @@
         "current_sdkinfo",
         "netbpfload.33rc",
         "netbpfload.35rc",
-        "ot-daemon.init.34rc",
+        "ot-daemon.34rc",
     ],
     manifest: "manifest.json",
     key: "com.android.tethering.key",
diff --git a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
index 4d1e7ef..e6e99f4 100644
--- a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
@@ -359,6 +359,7 @@
         } catch (IllegalStateException e) {
             // Silent if the rule already exists. Note that the errno EEXIST was rethrown as
             // IllegalStateException. See BpfMap#insertEntry.
+            return false;
         }
         return true;
     }
diff --git a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
index d28a397..026b1c3 100644
--- a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
+++ b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
@@ -140,6 +140,8 @@
 
     /**
      * Adds a tethering IPv4 offload rule to appropriate BPF map.
+     *
+     * @return true iff the map was modified, false if the key already exists or there was an error.
      */
     public abstract boolean tetherOffloadRuleAdd(boolean downstream, @NonNull Tether4Key key,
             @NonNull Tether4Value value);
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/res/values-fa/strings.xml b/Tethering/res/values-fa/strings.xml
index d7f2543..fdfd5c4 100644
--- a/Tethering/res/values-fa/strings.xml
+++ b/Tethering/res/values-fa/strings.xml
@@ -17,7 +17,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="tethered_notification_title" msgid="5350162111436634622">"اشتراک‌گذاری اینترنت یا نقطه اتصال فعال است"</string>
-    <string name="tethered_notification_message" msgid="2338023450330652098">"برای راه‌اندازی، ضربه بزنید."</string>
+    <string name="tethered_notification_message" msgid="2338023450330652098">"برای راه‌اندازی، تک‌ضرب بزنید."</string>
     <string name="disable_tether_notification_title" msgid="3183576627492925522">"اشتراک‌گذاری اینترنت غیرفعال است"</string>
     <string name="disable_tether_notification_message" msgid="6655882039707534929">"برای جزئیات، با سرپرستتان تماس بگیرید"</string>
     <string name="notification_channel_tethering_status" msgid="7030733422705019001">"وضعیت نقطه اتصال و اشتراک‌گذاری اینترنت"</string>
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 d62f18f..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;
                     }
@@ -2082,6 +2087,7 @@
                     chooseUpstreamType(true);
                     mTryCell = false;
                 }
+                mTetheringMetrics.initUpstreamUsageBaseline();
             }
 
             @Override
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/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index 2202106..fc50faf 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -16,6 +16,8 @@
 
 package com.android.networkstack.tethering.metrics;
 
+import static android.app.usage.NetworkStats.Bucket.STATE_ALL;
+import static android.app.usage.NetworkStats.Bucket.TAG_NONE;
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
@@ -24,6 +26,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
 import static android.net.NetworkStats.DEFAULT_NETWORK_YES;
 import static android.net.NetworkStats.METERED_YES;
+import static android.net.NetworkStats.UID_TETHERING;
 import static android.net.NetworkTemplate.MATCH_BLUETOOTH;
 import static android.net.NetworkTemplate.MATCH_ETHERNET;
 import static android.net.NetworkTemplate.MATCH_MOBILE;
@@ -52,13 +55,19 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNTETHER_IFACE_ERROR;
 
 import android.annotation.Nullable;
+import android.app.usage.NetworkStats;
+import android.app.usage.NetworkStatsManager;
 import android.content.Context;
 import android.net.NetworkCapabilities;
 import android.net.NetworkTemplate;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
 import android.stats.connectivity.UpstreamType;
 import android.stats.connectivity.UserType;
+import android.util.ArrayMap;
 import android.util.Log;
 import android.util.SparseArray;
 
@@ -75,6 +84,10 @@
 /**
  * Collection of utilities for tethering metrics.
  *
+ *  <p>This class is thread-safe. All accesses to this class will be either posting to the internal
+ *  handler thread for processing or checking whether the access is from the internal handler
+ *  thread. However, the constructor is an exception, as it is called on another thread.
+ *
  * To see if the logs are properly sent to statsd, execute following commands
  *
  * $ adb shell cmd stats print-logs
@@ -93,11 +106,16 @@
      */
     private static final String TETHER_UPSTREAM_DATA_USAGE_METRICS =
             "tether_upstream_data_usage_metrics";
+    @VisibleForTesting
+    static final DataUsage EMPTY = new DataUsage(0L /* txBytes */, 0L /* rxBytes */);
     private final SparseArray<NetworkTetheringReported.Builder> mBuilderMap = new SparseArray<>();
     private final SparseArray<Long> mDownstreamStartTime = new SparseArray<Long>();
     private final ArrayList<RecordUpstreamEvent> mUpstreamEventList = new ArrayList<>();
+    private final ArrayMap<UpstreamType, DataUsage> mUpstreamUsageBaseline = new ArrayMap<>();
     private final Context mContext;
     private final Dependencies mDependencies;
+    private final NetworkStatsManager mNetworkStatsManager;
+    private final Handler mHandler;
     private UpstreamType mCurrentUpstream = null;
     private Long mCurrentUpStreamStartTime = 0L;
 
@@ -136,6 +154,14 @@
             return SdkLevel.isAtLeastT() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
                     context, TETHER_UPSTREAM_DATA_USAGE_METRICS);
         }
+
+        /**
+         * @see Handler
+         */
+        @NonNull
+        public Handler createHandler(Looper looper) {
+            return new Handler(looper);
+        }
     }
 
     /**
@@ -150,24 +176,49 @@
     TetheringMetrics(Context context, Dependencies dependencies) {
         mContext = context;
         mDependencies = dependencies;
+        mNetworkStatsManager = mContext.getSystemService(NetworkStatsManager.class);
+        final HandlerThread thread = new HandlerThread(TAG);
+        thread.start();
+        mHandler = dependencies.createHandler(thread.getLooper());
     }
 
-    private static class DataUsage {
-        final long mTxBytes;
-        final long mRxBytes;
+    @VisibleForTesting
+    static class DataUsage {
+        public final long txBytes;
+        public final long rxBytes;
 
         DataUsage(long txBytes, long rxBytes) {
-            mTxBytes = txBytes;
-            mRxBytes = rxBytes;
+            this.txBytes = txBytes;
+            this.rxBytes = rxBytes;
         }
 
-        public long getTxBytes() {
-            return mTxBytes;
+        /*** Calculate the data usage delta from give new and old usage */
+        public static DataUsage subtract(DataUsage newUsage, DataUsage oldUsage) {
+            return new DataUsage(
+                    newUsage.txBytes - oldUsage.txBytes,
+                    newUsage.rxBytes - oldUsage.rxBytes);
         }
 
-        public long getRxBytes() {
-            return mRxBytes;
+        @Override
+        public int hashCode() {
+            return (int) (txBytes & 0xFFFFFFFF)
+                    + ((int) (txBytes >> 32) * 3)
+                    + ((int) (rxBytes & 0xFFFFFFFF) * 5)
+                    + ((int) (rxBytes >> 32) * 7);
         }
+
+        @Override
+        public boolean equals(Object other) {
+            if (this == other) {
+                return true;
+            }
+            if (!(other instanceof DataUsage)) {
+                return false;
+            }
+            return txBytes == ((DataUsage) other).txBytes
+                    && rxBytes == ((DataUsage) other).rxBytes;
+        }
+
     }
 
     private static class RecordUpstreamEvent {
@@ -194,6 +245,10 @@
      * @param callerPkg The package name of the caller.
      */
     public void createBuilder(final int downstreamType, final String callerPkg) {
+        mHandler.post(() -> handleCreateBuilder(downstreamType, callerPkg));
+    }
+
+    private void handleCreateBuilder(final int downstreamType, final String callerPkg) {
         NetworkTetheringReported.Builder statsBuilder = NetworkTetheringReported.newBuilder()
                 .setDownstreamType(downstreamTypeToEnum(downstreamType))
                 .setUserType(userTypeToEnum(callerPkg))
@@ -211,6 +266,10 @@
      * @param errCode The error code to set.
      */
     public void updateErrorCode(final int downstreamType, final int errCode) {
+        mHandler.post(() -> handleUpdateErrorCode(downstreamType, errCode));
+    }
+
+    private void handleUpdateErrorCode(final int downstreamType, final int errCode) {
         NetworkTetheringReported.Builder statsBuilder = mBuilderMap.get(downstreamType);
         if (statsBuilder == null) {
             Log.e(TAG, "Given downstreamType does not exist, this is a bug!");
@@ -219,13 +278,26 @@
         statsBuilder.setErrorCode(errorCodeToEnum(errCode));
     }
 
-    private DataUsage calculateDataUsage(@Nullable UpstreamType upstream) {
+    /**
+     * Calculates the data usage difference between the current and previous usage for the
+     * specified upstream type.
+     *
+     * @return A DataUsage object containing the calculated difference in transmitted (tx) and
+     *         received (rx) bytes.
+     */
+    private DataUsage calculateDataUsageDelta(@Nullable UpstreamType upstream) {
         if (upstream != null && mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
                 && isUsageSupportedForUpstreamType(upstream)) {
-            // TODO: Implement data usage calculation for the upstream type.
-            return new DataUsage(0L, 0L);
+            final DataUsage oldUsage = mUpstreamUsageBaseline.getOrDefault(upstream, EMPTY);
+            if (oldUsage.equals(EMPTY)) {
+                Log.d(TAG, "No usage baseline for the upstream=" + upstream);
+                return EMPTY;
+            }
+            // TODO(b/352537247): Fix data usage which might be incorrect if the device uses
+            //  tethering with the same upstream for over 15 days.
+            return DataUsage.subtract(getCurrentDataUsageForUpstreamType(upstream), oldUsage);
         }
-        return new DataUsage(0L, 0L);
+        return EMPTY;
     }
 
     /**
@@ -234,12 +306,16 @@
      * @param ns The UpstreamNetworkState object representing the current upstream network state.
      */
     public void maybeUpdateUpstreamType(@Nullable final UpstreamNetworkState ns) {
+        mHandler.post(() -> handleMaybeUpdateUpstreamType(ns));
+    }
+
+    private void handleMaybeUpdateUpstreamType(@Nullable final UpstreamNetworkState ns) {
         UpstreamType upstream = transportTypeToUpstreamTypeEnum(ns);
         if (upstream.equals(mCurrentUpstream)) return;
 
         final long newTime = mDependencies.timeNow();
         if (mCurrentUpstream != null) {
-            final DataUsage dataUsage = calculateDataUsage(upstream);
+            final DataUsage dataUsage = calculateDataUsageDelta(mCurrentUpstream);
             mUpstreamEventList.add(new RecordUpstreamEvent(mCurrentUpStreamStartTime, newTime,
                     mCurrentUpstream, dataUsage));
         }
@@ -292,14 +368,14 @@
             final long startTime = Math.max(downstreamStartTime, event.mStartTime);
             // Handle completed upstream events.
             addUpstreamEvent(upstreamEventsBuilder, startTime, event.mStopTime,
-                    event.mUpstreamType, event.mDataUsage.mTxBytes, event.mDataUsage.mRxBytes);
+                    event.mUpstreamType, event.mDataUsage.txBytes, event.mDataUsage.rxBytes);
         }
         final long startTime = Math.max(downstreamStartTime, mCurrentUpStreamStartTime);
         final long stopTime = mDependencies.timeNow();
         // Handle the last upstream event.
-        final DataUsage dataUsage = calculateDataUsage(mCurrentUpstream);
+        final DataUsage dataUsage = calculateDataUsageDelta(mCurrentUpstream);
         addUpstreamEvent(upstreamEventsBuilder, startTime, stopTime, mCurrentUpstream,
-                dataUsage.mTxBytes, dataUsage.mRxBytes);
+                dataUsage.txBytes, dataUsage.rxBytes);
         statsBuilder.setUpstreamEvents(upstreamEventsBuilder);
         statsBuilder.setDurationMillis(stopTime - downstreamStartTime);
     }
@@ -315,6 +391,10 @@
      * @param downstreamType the type of downstream event to remove statistics for
      */
     public void sendReport(final int downstreamType) {
+        mHandler.post(() -> handleSendReport(downstreamType));
+    }
+
+    private void handleSendReport(final int downstreamType) {
         final NetworkTetheringReported.Builder statsBuilder = mBuilderMap.get(downstreamType);
         if (statsBuilder == null) {
             Log.e(TAG, "Given downstreamType does not exist, this is a bug!");
@@ -335,8 +415,7 @@
      *
      * @param reported a NetworkTetheringReported object containing statistics to write
      */
-    @VisibleForTesting
-    public void write(@NonNull final NetworkTetheringReported reported) {
+    private void write(@NonNull final NetworkTetheringReported reported) {
         final byte[] upstreamEvents = reported.getUpstreamEvents().toByteArray();
         mDependencies.write(reported);
         if (DBG) {
@@ -358,12 +437,67 @@
     }
 
     /**
+     * Initialize the upstream data usage baseline when tethering is turned on.
+     */
+    public void initUpstreamUsageBaseline() {
+        mHandler.post(() -> handleInitUpstreamUsageBaseline());
+    }
+
+    private void handleInitUpstreamUsageBaseline() {
+        if (!(mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
+                && mUpstreamUsageBaseline.isEmpty())) {
+            return;
+        }
+
+        for (UpstreamType type : UpstreamType.values()) {
+            if (!isUsageSupportedForUpstreamType(type)) continue;
+            mUpstreamUsageBaseline.put(type, getCurrentDataUsageForUpstreamType(type));
+        }
+    }
+
+    @VisibleForTesting
+    @NonNull
+    DataUsage getDataUsageFromUpstreamType(@NonNull UpstreamType type) {
+        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
+            throw new IllegalStateException(
+                    "Not running on Handler thread: " + Thread.currentThread().getName());
+        }
+        return mUpstreamUsageBaseline.getOrDefault(type, EMPTY);
+    }
+
+
+    /**
+     * Get the current usage for given upstream type.
+     */
+    @NonNull
+    private DataUsage getCurrentDataUsageForUpstreamType(@NonNull UpstreamType type) {
+        final NetworkStats stats = mNetworkStatsManager.queryDetailsForUidTagState(
+                buildNetworkTemplateForUpstreamType(type), Long.MIN_VALUE, Long.MAX_VALUE,
+                UID_TETHERING, TAG_NONE, STATE_ALL);
+
+        final NetworkStats.Bucket bucket = new NetworkStats.Bucket();
+        Long totalTxBytes = 0L;
+        Long totalRxBytes = 0L;
+        while (stats.hasNextBucket()) {
+            stats.getNextBucket(bucket);
+            totalTxBytes += bucket.getTxBytes();
+            totalRxBytes += bucket.getRxBytes();
+        }
+        return new DataUsage(totalTxBytes, totalRxBytes);
+    }
+
+    /**
      * Cleans up the variables related to upstream events when tethering is turned off.
      */
     public void cleanup() {
+        mHandler.post(() -> handleCleanup());
+    }
+
+    private void handleCleanup() {
         mUpstreamEventList.clear();
         mCurrentUpstream = null;
         mCurrentUpStreamStartTime = 0L;
+        mUpstreamUsageBaseline.clear();
     }
 
     private DownstreamType downstreamTypeToEnum(final int ifaceType) {
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/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 1eb6255..423b9b8 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -568,6 +568,12 @@
         return nif.getMTU();
     }
 
+    protected int getIndexByName(String ifaceName) throws SocketException {
+        NetworkInterface nif = NetworkInterface.getByName(ifaceName);
+        assertNotNull("Can't get NetworkInterface object for " + ifaceName, nif);
+        return nif.getIndex();
+    }
+
     protected TapPacketReader makePacketReader(final TestNetworkInterface iface) throws Exception {
         FileDescriptor fd = iface.getFileDescriptor().getFileDescriptor();
         return makePacketReader(fd, getMTU(iface));
@@ -968,6 +974,11 @@
         return Struct.parse(Ipv6Header.class, ByteBuffer.wrap(expectedPacket)).srcIp;
     }
 
+    protected String getUpstreamInterfaceName() {
+        if (mUpstreamTracker == null) return null;
+        return mUpstreamTracker.getTestIface().getInterfaceName();
+    }
+
     protected <T> List<T> toList(T... array) {
         return Arrays.asList(array);
     }
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 049f5f0..32b2f3e 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -1066,24 +1066,34 @@
         runUdp4Test();
     }
 
-    private ClatEgress4Value getClatEgress4Value() throws Exception {
+    private ClatEgress4Value getClatEgress4Value(int clatIfaceIndex) throws Exception {
         // Command: dumpsys connectivity clatEgress4RawBpfMap
         final String[] args = new String[] {DUMPSYS_CLAT_RAWMAP_EGRESS4_ARG};
         final HashMap<ClatEgress4Key, ClatEgress4Value> egress4Map = pollRawMapFromDump(
                 ClatEgress4Key.class, ClatEgress4Value.class, Context.CONNECTIVITY_SERVICE, args);
         assertNotNull(egress4Map);
-        assertEquals(1, egress4Map.size());
-        return egress4Map.entrySet().iterator().next().getValue();
+        for (Map.Entry<ClatEgress4Key, ClatEgress4Value> entry : egress4Map.entrySet()) {
+            ClatEgress4Key key = entry.getKey();
+            if (key.iif == clatIfaceIndex) {
+                return entry.getValue();
+            }
+        }
+        return null;
     }
 
-    private ClatIngress6Value getClatIngress6Value() throws Exception {
+    private ClatIngress6Value getClatIngress6Value(int ifaceIndex) throws Exception {
         // Command: dumpsys connectivity clatIngress6RawBpfMap
         final String[] args = new String[] {DUMPSYS_CLAT_RAWMAP_INGRESS6_ARG};
         final HashMap<ClatIngress6Key, ClatIngress6Value> ingress6Map = pollRawMapFromDump(
                 ClatIngress6Key.class, ClatIngress6Value.class, Context.CONNECTIVITY_SERVICE, args);
         assertNotNull(ingress6Map);
-        assertEquals(1, ingress6Map.size());
-        return ingress6Map.entrySet().iterator().next().getValue();
+        for (Map.Entry<ClatIngress6Key, ClatIngress6Value> entry : ingress6Map.entrySet()) {
+            ClatIngress6Key key = entry.getKey();
+            if (key.iif == ifaceIndex) {
+                return entry.getValue();
+            }
+        }
+        return null;
     }
 
     /**
@@ -1115,8 +1125,13 @@
         final Inet6Address clatIp6 = getClatIpv6Address(tester, tethered);
 
         // Get current values before sending packets.
-        final ClatEgress4Value oldEgress4 = getClatEgress4Value();
-        final ClatIngress6Value oldIngress6 = getClatIngress6Value();
+        final String ifaceName = getUpstreamInterfaceName();
+        final int ifaceIndex = getIndexByName(ifaceName);
+        final int clatIfaceIndex = getIndexByName("v4-" + ifaceName);
+        final ClatEgress4Value oldEgress4 = getClatEgress4Value(clatIfaceIndex);
+        final ClatIngress6Value oldIngress6 = getClatIngress6Value(ifaceIndex);
+        assertNotNull(oldEgress4);
+        assertNotNull(oldIngress6);
 
         // Send an IPv4 UDP packet in original direction.
         // IPv4 packet -- CLAT translation --> IPv6 packet
@@ -1145,8 +1160,10 @@
                 ByteBuffer.wrap(payload), l2mtu);
 
         // After sending test packets, get stats again to verify their differences.
-        final ClatEgress4Value newEgress4 = getClatEgress4Value();
-        final ClatIngress6Value newIngress6 = getClatIngress6Value();
+        final ClatEgress4Value newEgress4 = getClatEgress4Value(clatIfaceIndex);
+        final ClatIngress6Value newIngress6 = getClatIngress6Value(ifaceIndex);
+        assertNotNull(newEgress4);
+        assertNotNull(newIngress6);
 
         assertEquals(RX_UDP_PACKET_COUNT + fragPktCnt, newIngress6.packets - oldIngress6.packets);
         assertEquals(RX_UDP_PACKET_COUNT * RX_UDP_PACKET_SIZE + fragRxBytes,
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/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
index fbc2893..34689bc 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
@@ -16,12 +16,19 @@
 
 package com.android.networkstack.tethering.metrics;
 
+import static android.app.usage.NetworkStats.Bucket.STATE_ALL;
+import static android.app.usage.NetworkStats.Bucket.TAG_NONE;
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
 import static android.net.NetworkCapabilities.TRANSPORT_LOWPAN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
+import static android.net.NetworkStats.DEFAULT_NETWORK_YES;
+import static android.net.NetworkStats.METERED_NO;
+import static android.net.NetworkStats.ROAMING_NO;
+import static android.net.NetworkStats.SET_DEFAULT;
+import static android.net.NetworkStats.UID_TETHERING;
 import static android.net.NetworkTemplate.MATCH_BLUETOOTH;
 import static android.net.NetworkTemplate.MATCH_ETHERNET;
 import static android.net.NetworkTemplate.MATCH_MOBILE;
@@ -49,29 +56,47 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_TYPE;
 import static android.net.TetheringManager.TETHER_ERROR_UNSUPPORTED;
 import static android.net.TetheringManager.TETHER_ERROR_UNTETHER_IFACE_ERROR;
+import static android.stats.connectivity.UpstreamType.UT_BLUETOOTH;
+import static android.stats.connectivity.UpstreamType.UT_CELLULAR;
+import static android.stats.connectivity.UpstreamType.UT_ETHERNET;
+import static android.stats.connectivity.UpstreamType.UT_WIFI;
+
+import static com.android.networkstack.tethering.metrics.TetheringMetrics.EMPTY;
+import static com.android.testutils.NetworkStatsUtilsKt.makePublicStatsFromAndroidNetStats;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.verify;
 
+import android.app.usage.NetworkStatsManager;
 import android.content.Context;
 import android.net.NetworkCapabilities;
+import android.net.NetworkStats;
 import android.net.NetworkTemplate;
 import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
 import android.stats.connectivity.UpstreamType;
 import android.stats.connectivity.UserType;
+import android.util.ArrayMap;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.networkstack.tethering.UpstreamNetworkState;
+import com.android.networkstack.tethering.metrics.TetheringMetrics.DataUsage;
 import com.android.networkstack.tethering.metrics.TetheringMetrics.Dependencies;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.HandlerUtils;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -90,14 +115,19 @@
     private static final String GMS_PKG = "com.google.android.gms";
     private static final long TEST_START_TIME = 1670395936033L;
     private static final long SECOND_IN_MILLIS = 1_000L;
+    private static final long DEFAULT_TIMEOUT = 2000L;
     private static final int MATCH_NONE = -1;
 
     @Mock private Context mContext;
     @Mock private Dependencies mDeps;
+    @Mock private NetworkStatsManager mNetworkStatsManager;
 
     private TetheringMetrics mTetheringMetrics;
     private final NetworkTetheringReported.Builder mStatsBuilder =
             NetworkTetheringReported.newBuilder();
+    private final ArrayMap<UpstreamType, DataUsage> mMockUpstreamUsageBaseline = new ArrayMap<>();
+    private HandlerThread mThread;
+    private Handler mHandler;
 
     private long mElapsedRealtime;
 
@@ -124,10 +154,35 @@
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         doReturn(TEST_START_TIME).when(mDeps).timeNow();
+        doReturn(mNetworkStatsManager).when(mContext).getSystemService(NetworkStatsManager.class);
+        mThread = new HandlerThread("TetheringMetricsTest");
+        mThread.start();
+        mHandler = new Handler(mThread.getLooper());
+        doReturn(mHandler).when(mDeps).createHandler(any());
+        // Set up the usage for upstream types.
+        mMockUpstreamUsageBaseline.put(UT_CELLULAR, new DataUsage(100L, 200L));
+        mMockUpstreamUsageBaseline.put(UT_WIFI, new DataUsage(400L, 800L));
+        mMockUpstreamUsageBaseline.put(UT_BLUETOOTH, new DataUsage(50L, 80L));
+        mMockUpstreamUsageBaseline.put(UT_ETHERNET, new DataUsage(0L, 0L));
+        doAnswer(inv -> {
+            final NetworkTemplate template = (NetworkTemplate) inv.getArguments()[0];
+            final DataUsage dataUsage = mMockUpstreamUsageBaseline.getOrDefault(
+                    matchRuleToUpstreamType(template.getMatchRule()), new DataUsage(0L, 0L));
+            return makeNetworkStatsWithTxRxBytes(dataUsage);
+        }).when(mNetworkStatsManager).queryDetailsForUidTagState(any(), eq(Long.MIN_VALUE),
+                eq(Long.MAX_VALUE), eq(UID_TETHERING), eq(TAG_NONE), eq(STATE_ALL));
         mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mElapsedRealtime = 0L;
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mThread != null) {
+            mThread.quitSafely();
+            mThread.join();
+        }
+    }
+
     private void verifyReport(final DownstreamType downstream, final ErrorCode error,
             final UserType user, final UpstreamEvents.Builder upstreamEvents, final long duration)
             throws Exception {
@@ -142,9 +197,15 @@
         verify(mDeps).write(expectedReport);
     }
 
+    private void runAndWaitForIdle(Runnable r) {
+        r.run();
+        HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
+    }
+
     private void updateErrorAndSendReport(final int downstream, final int error) {
         mTetheringMetrics.updateErrorCode(downstream, error);
         mTetheringMetrics.sendReport(downstream);
+        HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
     }
 
     private static NetworkCapabilities buildUpstreamCapabilities(final int[] transports) {
@@ -176,7 +237,7 @@
     private void runDownstreamTypesTest(final int type, final DownstreamType expectedResult)
             throws Exception {
         mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
-        mTetheringMetrics.createBuilder(type, TEST_CALLER_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(type, TEST_CALLER_PKG));
         final long duration = 2 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
         UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
@@ -202,14 +263,15 @@
     private void runErrorCodesTest(final int errorCode, final ErrorCode expectedResult)
             throws Exception {
         mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG);
-        mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI));
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG));
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
         final long duration = 2 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
         updateErrorAndSendReport(TETHERING_WIFI, errorCode);
 
         UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
-        addUpstreamEvent(upstreamEvents, UpstreamType.UT_WIFI, duration, 0L, 0L);
+        addUpstreamEvent(upstreamEvents, UT_WIFI, duration, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, expectedResult, UserType.USER_UNKNOWN,
                     upstreamEvents, getElapsedRealtime());
         clearElapsedRealtime();
@@ -243,7 +305,7 @@
     private void runUserTypesTest(final String callerPkg, final UserType expectedResult)
             throws Exception {
         mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, callerPkg);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, callerPkg));
         final long duration = 1 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
@@ -267,8 +329,8 @@
     private void runUpstreamTypesTest(final UpstreamNetworkState ns,
             final UpstreamType expectedResult) throws Exception {
         mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG);
-        mTetheringMetrics.maybeUpdateUpstreamType(ns);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG));
+        runAndWaitForIdle(() -> mTetheringMetrics.maybeUpdateUpstreamType(ns));
         final long duration = 2 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
@@ -283,10 +345,10 @@
     @Test
     public void testUpstreamTypes() throws Exception {
         runUpstreamTypesTest(null , UpstreamType.UT_NO_NETWORK);
-        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_CELLULAR), UpstreamType.UT_CELLULAR);
-        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_WIFI), UpstreamType.UT_WIFI);
-        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_BLUETOOTH), UpstreamType.UT_BLUETOOTH);
-        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_ETHERNET), UpstreamType.UT_ETHERNET);
+        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_CELLULAR), UT_CELLULAR);
+        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_WIFI), UT_WIFI);
+        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_BLUETOOTH), UT_BLUETOOTH);
+        runUpstreamTypesTest(buildUpstreamState(TRANSPORT_ETHERNET), UT_ETHERNET);
         runUpstreamTypesTest(buildUpstreamState(TRANSPORT_WIFI_AWARE), UpstreamType.UT_WIFI_AWARE);
         runUpstreamTypesTest(buildUpstreamState(TRANSPORT_LOWPAN), UpstreamType.UT_LOWPAN);
         runUpstreamTypesTest(buildUpstreamState(TRANSPORT_CELLULAR, TRANSPORT_WIFI,
@@ -295,13 +357,13 @@
 
     @Test
     public void testMultiBuildersCreatedBeforeSendReport() throws Exception {
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG));
         final long wifiTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(1 * SECOND_IN_MILLIS);
-        mTetheringMetrics.createBuilder(TETHERING_USB, SYSTEMUI_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_USB, SYSTEMUI_PKG));
         final long usbTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(2 * SECOND_IN_MILLIS);
-        mTetheringMetrics.createBuilder(TETHERING_BLUETOOTH, GMS_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_BLUETOOTH, GMS_PKG));
         final long bluetoothTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(3 * SECOND_IN_MILLIS);
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_DHCPSERVER_ERROR);
@@ -335,19 +397,20 @@
 
     @Test
     public void testUpstreamsWithMultipleDownstreams() throws Exception {
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG));
         final long wifiTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(1 * SECOND_IN_MILLIS);
-        mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI));
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
         final long wifiUpstreamStartTime = currentTimeMillis();
         incrementCurrentTime(5 * SECOND_IN_MILLIS);
-        mTetheringMetrics.createBuilder(TETHERING_USB, SYSTEMUI_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_USB, SYSTEMUI_PKG));
         final long usbTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(5 * SECOND_IN_MILLIS);
         updateErrorAndSendReport(TETHERING_USB, TETHER_ERROR_NO_ERROR);
 
         UpstreamEvents.Builder usbTetheringUpstreamEvents = UpstreamEvents.newBuilder();
-        addUpstreamEvent(usbTetheringUpstreamEvents, UpstreamType.UT_WIFI,
+        addUpstreamEvent(usbTetheringUpstreamEvents, UT_WIFI,
                 currentTimeMillis() - usbTetheringStartTime, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_USB, ErrorCode.EC_NO_ERROR,
                 UserType.USER_SYSTEMUI, usbTetheringUpstreamEvents,
@@ -356,7 +419,7 @@
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
 
         UpstreamEvents.Builder wifiTetheringUpstreamEvents = UpstreamEvents.newBuilder();
-        addUpstreamEvent(wifiTetheringUpstreamEvents, UpstreamType.UT_WIFI,
+        addUpstreamEvent(wifiTetheringUpstreamEvents, UT_WIFI,
                 currentTimeMillis() - wifiUpstreamStartTime, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
                 UserType.USER_SETTINGS, wifiTetheringUpstreamEvents,
@@ -365,24 +428,27 @@
 
     @Test
     public void testSwitchingMultiUpstreams() throws Exception {
-        mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG);
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG));
         final long wifiTetheringStartTime = currentTimeMillis();
         incrementCurrentTime(1 * SECOND_IN_MILLIS);
-        mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI));
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
         final long wifiDuration = 5 * SECOND_IN_MILLIS;
         incrementCurrentTime(wifiDuration);
-        mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_BLUETOOTH));
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_BLUETOOTH)));
         final long bluetoothDuration = 15 * SECOND_IN_MILLIS;
         incrementCurrentTime(bluetoothDuration);
-        mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_CELLULAR));
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_CELLULAR)));
         final long celltoothDuration = 20 * SECOND_IN_MILLIS;
         incrementCurrentTime(celltoothDuration);
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
 
         UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
-        addUpstreamEvent(upstreamEvents, UpstreamType.UT_WIFI, wifiDuration, 0L, 0L);
-        addUpstreamEvent(upstreamEvents, UpstreamType.UT_BLUETOOTH, bluetoothDuration, 0L, 0L);
-        addUpstreamEvent(upstreamEvents, UpstreamType.UT_CELLULAR, celltoothDuration, 0L, 0L);
+        addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration, 0L, 0L);
+        addUpstreamEvent(upstreamEvents, UT_BLUETOOTH, bluetoothDuration, 0L, 0L);
+        addUpstreamEvent(upstreamEvents, UT_CELLULAR, celltoothDuration, 0L, 0L);
 
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
                 UserType.USER_SETTINGS, upstreamEvents,
@@ -397,10 +463,10 @@
 
     @Test
     public void testUsageSupportedForUpstreamTypeTest() {
-        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_CELLULAR, true /* isSupported */);
-        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_WIFI, true /* isSupported */);
-        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_BLUETOOTH, true /* isSupported */);
-        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_ETHERNET, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UT_CELLULAR, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UT_WIFI, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UT_BLUETOOTH, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UT_ETHERNET, true /* isSupported */);
         runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_WIFI_AWARE, false /* isSupported */);
         runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_LOWPAN, false /* isSupported */);
         runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_UNKNOWN, false /* isSupported */);
@@ -420,12 +486,138 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testBuildNetworkTemplateForUpstreamType() {
-        runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_CELLULAR, MATCH_MOBILE);
-        runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_WIFI, MATCH_WIFI);
-        runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_BLUETOOTH, MATCH_BLUETOOTH);
-        runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_ETHERNET, MATCH_ETHERNET);
+        runBuildNetworkTemplateForUpstreamType(UT_CELLULAR, MATCH_MOBILE);
+        runBuildNetworkTemplateForUpstreamType(UT_WIFI, MATCH_WIFI);
+        runBuildNetworkTemplateForUpstreamType(UT_BLUETOOTH, MATCH_BLUETOOTH);
+        runBuildNetworkTemplateForUpstreamType(UT_ETHERNET, MATCH_ETHERNET);
         runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_WIFI_AWARE, MATCH_NONE);
         runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_LOWPAN, MATCH_NONE);
         runBuildNetworkTemplateForUpstreamType(UpstreamType.UT_UNKNOWN, MATCH_NONE);
     }
+
+    private void verifyEmptyUsageForAllUpstreamTypes() {
+        mHandler.post(() -> {
+            for (UpstreamType type : UpstreamType.values()) {
+                assertEquals(EMPTY, mTetheringMetrics.getDataUsageFromUpstreamType(type));
+            }
+        });
+        HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
+    }
+
+    @Test
+    public void testInitializeUpstreamDataUsageBeforeT() {
+        // Verify the usage is empty for all upstream types before initialization.
+        verifyEmptyUsageForAllUpstreamTypes();
+
+        // Verify the usage is still empty after initialization if sdk is lower than T.
+        doReturn(false).when(mDeps).isUpstreamDataUsageMetricsEnabled(any());
+        runAndWaitForIdle(() -> mTetheringMetrics.initUpstreamUsageBaseline());
+        verifyEmptyUsageForAllUpstreamTypes();
+    }
+
+    private android.app.usage.NetworkStats makeNetworkStatsWithTxRxBytes(DataUsage dataUsage) {
+        final NetworkStats testAndroidNetStats =
+                new NetworkStats(0L /* elapsedRealtime */, 1 /* initialSize */).addEntry(
+                        new NetworkStats.Entry("test", 10001, SET_DEFAULT, TAG_NONE,
+                                METERED_NO, ROAMING_NO, DEFAULT_NETWORK_YES, dataUsage.rxBytes,
+                                10, dataUsage.txBytes, 10, 10));
+        return makePublicStatsFromAndroidNetStats(testAndroidNetStats);
+    }
+
+    private static UpstreamType matchRuleToUpstreamType(int matchRule) {
+        switch (matchRule) {
+            case MATCH_MOBILE:
+                return UT_CELLULAR;
+            case MATCH_WIFI:
+                return UT_WIFI;
+            case MATCH_BLUETOOTH:
+                return UT_BLUETOOTH;
+            case MATCH_ETHERNET:
+                return UT_ETHERNET;
+            default:
+                return UpstreamType.UT_UNKNOWN;
+        }
+    }
+
+    private void initializeUpstreamUsageBaseline() {
+        doReturn(true).when(mDeps).isUpstreamDataUsageMetricsEnabled(any());
+        runAndWaitForIdle(() -> mTetheringMetrics.initUpstreamUsageBaseline());
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testInitUpstreamUsageBaselineAndCleanup() {
+        // Verify the usage is empty for all upstream types before initialization.
+        verifyEmptyUsageForAllUpstreamTypes();
+
+        // Verify the usage has been initialized
+        initializeUpstreamUsageBaseline();
+
+        mHandler.post(() -> {
+            for (UpstreamType type : UpstreamType.values()) {
+                final DataUsage dataUsage = mTetheringMetrics.getDataUsageFromUpstreamType(type);
+                if (TetheringMetrics.isUsageSupportedForUpstreamType(type)) {
+                    assertEquals(mMockUpstreamUsageBaseline.get(type), dataUsage);
+                } else {
+                    assertEquals(EMPTY, dataUsage);
+                }
+            }
+        });
+        HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
+
+        // Verify the usage is empty after clean up
+        runAndWaitForIdle(() -> mTetheringMetrics.cleanup());
+        verifyEmptyUsageForAllUpstreamTypes();
+    }
+
+    private void updateUpstreamDataUsage(UpstreamType type, long usageDiff) {
+        final DataUsage oldWifiUsage = mMockUpstreamUsageBaseline.get(type);
+        final DataUsage newWifiUsage = new DataUsage(
+                oldWifiUsage.txBytes + usageDiff,
+                oldWifiUsage.rxBytes + usageDiff);
+        mMockUpstreamUsageBaseline.put(type, newWifiUsage);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
+    public void testDataUsageCalculation() throws Exception {
+        initializeUpstreamUsageBaseline();
+        runAndWaitForIdle(() -> mTetheringMetrics.createBuilder(TETHERING_WIFI, SETTINGS_PKG));
+        final long wifiTetheringStartTime = currentTimeMillis();
+        incrementCurrentTime(1 * SECOND_IN_MILLIS);
+
+        // Change the upstream to Wi-Fi and update the data usage
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
+        final long wifiDuration = 5 * SECOND_IN_MILLIS;
+        final long wifiUsageDiff = 100L;
+        incrementCurrentTime(wifiDuration);
+        updateUpstreamDataUsage(UT_WIFI, wifiUsageDiff);
+
+        // Change the upstream to bluetooth and update the data usage
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_BLUETOOTH)));
+        final long bluetoothDuration = 15 * SECOND_IN_MILLIS;
+        final long btUsageDiff = 50L;
+        incrementCurrentTime(bluetoothDuration);
+        updateUpstreamDataUsage(UT_BLUETOOTH, btUsageDiff);
+
+        // Change the upstream to cellular and update the data usage
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_CELLULAR)));
+        final long cellDuration = 20 * SECOND_IN_MILLIS;
+        final long cellUsageDiff = 500L;
+        incrementCurrentTime(cellDuration);
+        updateUpstreamDataUsage(UT_CELLULAR, cellUsageDiff);
+
+        // Stop tethering and verify that the data usage is uploaded.
+        updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
+        UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
+        addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration, wifiUsageDiff, wifiUsageDiff);
+        addUpstreamEvent(upstreamEvents, UT_BLUETOOTH, bluetoothDuration, btUsageDiff, btUsageDiff);
+        addUpstreamEvent(upstreamEvents, UT_CELLULAR, cellDuration, cellUsageDiff, cellUsageDiff);
+        verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
+                UserType.USER_SETTINGS, upstreamEvents,
+                currentTimeMillis() - wifiTetheringStartTime);
+    }
 }
diff --git a/bpf/headers/include/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
index c94f1d8..ac5ffda 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -50,18 +50,21 @@
 // Note: this value (and the following +1u's) are hardcoded in NetBpfLoad.cpp
 #define BPFLOADER_MAINLINE_VERSION 42u
 
-// Android Mainline BpfLoader when running on Android T
+// Android Mainline BpfLoader when running on Android T (sdk=33)
 #define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_VERSION + 1u)
 
-// Android Mainline BpfLoader when running on Android U
+// Android Mainline BpfLoader when running on Android U (sdk=34)
 #define BPFLOADER_MAINLINE_U_VERSION (BPFLOADER_MAINLINE_T_VERSION + 1u)
 
 // Android Mainline BpfLoader when running on Android U QPR3
 #define BPFLOADER_MAINLINE_U_QPR3_VERSION (BPFLOADER_MAINLINE_U_VERSION + 1u)
 
-// Android Mainline BpfLoader when running on Android V
+// Android Mainline BpfLoader when running on Android V (sdk=35)
 #define BPFLOADER_MAINLINE_V_VERSION (BPFLOADER_MAINLINE_U_QPR3_VERSION + 1u)
 
+// Android Mainline BpfLoader when running on Android W (sdk=36)
+#define BPFLOADER_MAINLINE_W_VERSION (BPFLOADER_MAINLINE_V_VERSION + 1u)
+
 /* For mainline module use, you can #define BPFLOADER_{MIN/MAX}_VER
  * before #include "bpf_helpers.h" to change which bpfloaders will
  * process the resulting .o file.
@@ -288,6 +291,12 @@
         bpf_ringbuf_submit_unsafe(v, 0);                                       \
     }
 
+#define DEFINE_BPF_RINGBUF(the_map, ValueType, size_bytes, usr, grp, md)                \
+    DEFINE_BPF_RINGBUF_EXT(the_map, ValueType, size_bytes, usr, grp, md,                \
+                           DEFAULT_BPF_MAP_SELINUX_CONTEXT, DEFAULT_BPF_MAP_PIN_SUBDIR, \
+                           PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,               \
+                           LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+
 /* There exist buggy kernels with pre-T OS, that due to
  * kernel patch "[ALPS05162612] bpf: fix ubsan error"
  * do not support userspace writes into non-zero index of bpf map arrays.
@@ -346,11 +355,17 @@
 #error "Bpf Map UID must be left at default of AID_ROOT for BpfLoader prior to v0.28"
 #endif
 
-#define DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md)     \
-    DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md,         \
-                       DEFAULT_BPF_MAP_SELINUX_CONTEXT, DEFAULT_BPF_MAP_PIN_SUBDIR, PRIVATE, \
-                       BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, LOAD_ON_ENG,                    \
-                       LOAD_ON_USER, LOAD_ON_USERDEBUG)
+// for maps not meant to be accessed from userspace
+#define DEFINE_BPF_MAP_KERNEL_INTERNAL(the_map, TYPE, KeyType, ValueType, num_entries)           \
+    DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, AID_ROOT, AID_ROOT,       \
+                       0000, "fs_bpf_loader", "", PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, \
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+
+#define DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md) \
+    DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md,     \
+                       DEFAULT_BPF_MAP_SELINUX_CONTEXT, DEFAULT_BPF_MAP_PIN_SUBDIR,      \
+                       PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,                    \
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 #define DEFINE_BPF_MAP(the_map, TYPE, KeyType, ValueType, num_entries) \
     DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, \
@@ -383,13 +398,18 @@
 static int (*bpf_probe_read_user_str)(void* dst, int size, const void* unsafe_ptr) = (void*) BPF_FUNC_probe_read_user_str;
 static unsigned long long (*bpf_ktime_get_ns)(void) = (void*) BPF_FUNC_ktime_get_ns;
 static unsigned long long (*bpf_ktime_get_boot_ns)(void) = (void*)BPF_FUNC_ktime_get_boot_ns;
-static int (*bpf_trace_printk)(const char* fmt, int fmt_size, ...) = (void*) BPF_FUNC_trace_printk;
 static unsigned long long (*bpf_get_current_pid_tgid)(void) = (void*) BPF_FUNC_get_current_pid_tgid;
 static unsigned long long (*bpf_get_current_uid_gid)(void) = (void*) BPF_FUNC_get_current_uid_gid;
 static unsigned long long (*bpf_get_smp_processor_id)(void) = (void*) BPF_FUNC_get_smp_processor_id;
 static long (*bpf_get_stackid)(void* ctx, void* map, uint64_t flags) = (void*) BPF_FUNC_get_stackid;
 static long (*bpf_get_current_comm)(void* buf, uint32_t buf_size) = (void*) BPF_FUNC_get_current_comm;
 
+// GPL only:
+static int (*bpf_trace_printk)(const char* fmt, int fmt_size, ...) = (void*) BPF_FUNC_trace_printk;
+#define bpf_printf(s, n...) bpf_trace_printk(s, sizeof(s), ## n)
+// Note: bpf only supports up to 3 arguments, log via: bpf_printf("msg %d %d %d", 1, 2, 3);
+// and read via the blocking: sudo cat /sys/kernel/debug/tracing/trace_pipe
+
 #define DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, max_kv,  \
                             min_loader, max_loader, opt, selinux, pindir, ignore_eng,    \
                             ignore_user, ignore_userdebug)                               \
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 22f12d1..69f1cb5 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -17,7 +17,6 @@
 #define LOG_TAG "NetBpfLoad"
 
 #include <arpa/inet.h>
-#include <cstdlib>
 #include <dirent.h>
 #include <elf.h>
 #include <errno.h>
@@ -26,8 +25,6 @@
 #include <fstream>
 #include <inttypes.h>
 #include <iostream>
-#include <linux/bpf.h>
-#include <linux/elf.h>
 #include <linux/unistd.h>
 #include <log/log.h>
 #include <net/if.h>
@@ -63,7 +60,14 @@
 #include "bpf_map_def.h"
 
 using android::base::EndsWith;
+using android::base::GetIntProperty;
+using android::base::GetProperty;
+using android::base::InitLogging;
+using android::base::KernelLogger;
+using android::base::SetProperty;
+using android::base::Split;
 using android::base::StartsWith;
+using android::base::Tokenize;
 using android::base::unique_fd;
 using std::ifstream;
 using std::ios;
@@ -93,6 +97,8 @@
     net_shared,         // (T+) fs_bpf_net_shared    /sys/fs/bpf/net_shared
     netd_readonly,      // (T+) fs_bpf_netd_readonly /sys/fs/bpf/netd_readonly
     netd_shared,        // (T+) fs_bpf_netd_shared   /sys/fs/bpf/netd_shared
+    loader,             // (U+) fs_bpf_loader        /sys/fs/bpf/loader
+                        // on T due to lack of sepolicy/genfscon rules it behaves simply as 'fs_bpf'
 };
 
 static constexpr domain AllDomains[] = {
@@ -102,6 +108,7 @@
     domain::net_shared,
     domain::netd_readonly,
     domain::netd_shared,
+    domain::loader,
 };
 
 static constexpr bool specified(domain d) {
@@ -115,7 +122,7 @@
 
 // Returns the build type string (from ro.build.type).
 const std::string& getBuildType() {
-    static std::string t = android::base::GetProperty("ro.build.type", "unknown");
+    static std::string t = GetProperty("ro.build.type", "unknown");
     return t;
 }
 
@@ -134,9 +141,6 @@
 
 #define BPF_FS_PATH "/sys/fs/bpf/"
 
-// Size of the BPF log buffer for verifier logging
-#define BPF_LOAD_LOG_SZ 0xfffff
-
 static unsigned int page_size = static_cast<unsigned int>(getpagesize());
 
 constexpr const char* lookupSelinuxContext(const domain d) {
@@ -147,6 +151,7 @@
         case domain::net_shared:    return "fs_bpf_net_shared";
         case domain::netd_readonly: return "fs_bpf_netd_readonly";
         case domain::netd_shared:   return "fs_bpf_netd_shared";
+        case domain::loader:        return "fs_bpf_loader";
     }
 }
 
@@ -170,6 +175,7 @@
         case domain::net_shared:    return "net_shared/";
         case domain::netd_readonly: return "netd_readonly/";
         case domain::netd_shared:   return "netd_shared/";
+        case domain::loader:        return "loader/";
     }
 };
 
@@ -187,7 +193,7 @@
 
 static string pathToObjName(const string& path) {
     // extract everything after the final slash, ie. this is the filename 'foo@1.o' or 'bar.o'
-    string filename = android::base::Split(path, "/").back();
+    string filename = Split(path, "/").back();
     // strip off everything from the final period onwards (strip '.o' suffix), ie. 'foo@1' or 'bar'
     string name = filename.substr(0, filename.find_last_of('.'));
     // strip any potential @1 suffix, this will leave us with just 'foo' or 'bar'
@@ -607,6 +613,9 @@
     if (type == BPF_MAP_TYPE_DEVMAP || type == BPF_MAP_TYPE_DEVMAP_HASH)
         desired_map_flags |= BPF_F_RDONLY_PROG;
 
+    if (type == BPF_MAP_TYPE_LPM_TRIE)
+        desired_map_flags |= BPF_F_NO_PREALLOC;
+
     // The .h file enforces that this is a power of two, and page size will
     // also always be a power of two, so this logic is actually enough to
     // force it to be a multiple of the page size, as required by the kernel.
@@ -782,13 +791,17 @@
               .key_size = md[i].key_size,
               .value_size = md[i].value_size,
               .max_entries = max_entries,
-              .map_flags = md[i].map_flags,
+              .map_flags = md[i].map_flags | (type == BPF_MAP_TYPE_LPM_TRIE ? BPF_F_NO_PREALLOC : 0),
             };
             if (isAtLeastKernelVersion(4, 15, 0))
                 strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
             fd.reset(bpf(BPF_MAP_CREATE, req));
             saved_errno = errno;
-            ALOGD("bpf_create_map name %s, ret: %d", mapNames[i].c_str(), fd.get());
+            if (fd.ok()) {
+              ALOGD("bpf_create_map[%s] -> %d", mapNames[i].c_str(), fd.get());
+            } else {
+              ALOGE("bpf_create_map[%s] -> %d errno:%d", mapNames[i].c_str(), fd.get(), saved_errno);
+            }
         }
 
         if (!fd.ok()) return -saved_errno;
@@ -842,7 +855,8 @@
 
         int mapId = bpfGetFdMapId(fd);
         if (mapId == -1) {
-            ALOGE("bpfGetFdMapId failed, ret: %d [%d]", mapId, errno);
+            if (isAtLeastKernelVersion(4, 14, 0))
+                ALOGE("bpfGetFdMapId failed, ret: %d [%d]", mapId, errno);
         } else {
             ALOGI("map %s id %d", mapPinLoc.c_str(), mapId);
         }
@@ -989,38 +1003,52 @@
                   (!fd.ok() ? std::strerror(errno) : "no error"));
             reuse = true;
         } else {
-            vector<char> log_buf(BPF_LOAD_LOG_SZ, 0);
+            static char log_buf[1 << 20];  // 1 MiB logging buffer
 
             union bpf_attr req = {
               .prog_type = cs[i].type,
-              .kern_version = kvers,
-              .license = ptr_to_u64(license.c_str()),
-              .insns = ptr_to_u64(cs[i].data.data()),
               .insn_cnt = static_cast<__u32>(cs[i].data.size() / sizeof(struct bpf_insn)),
+              .insns = ptr_to_u64(cs[i].data.data()),
+              .license = ptr_to_u64(license.c_str()),
               .log_level = 1,
-              .log_buf = ptr_to_u64(log_buf.data()),
-              .log_size = static_cast<__u32>(log_buf.size()),
+              .log_size = sizeof(log_buf),
+              .log_buf = ptr_to_u64(log_buf),
+              .kern_version = kvers,
               .expected_attach_type = cs[i].attach_type,
             };
             if (isAtLeastKernelVersion(4, 15, 0))
                 strlcpy(req.prog_name, cs[i].name.c_str(), sizeof(req.prog_name));
             fd.reset(bpf(BPF_PROG_LOAD, req));
 
-            ALOGD("BPF_PROG_LOAD call for %s (%s) returned fd: %d (%s)", elfPath,
-                  cs[i].name.c_str(), fd.get(), (!fd.ok() ? std::strerror(errno) : "no error"));
+            // Kernel should have NULL terminated the log buffer, but force it anyway for safety
+            log_buf[sizeof(log_buf) - 1] = 0;
+
+            // Strip out final newline if present
+            int log_chars = strlen(log_buf);
+            if (log_chars && log_buf[log_chars - 1] == '\n') log_buf[--log_chars] = 0;
+
+            bool log_oneline = !strchr(log_buf, '\n');
+
+            ALOGD("BPF_PROG_LOAD call for %s (%s) returned '%s' fd: %d (%s)", elfPath,
+                  cs[i].name.c_str(), log_oneline ? log_buf : "{multiline}",
+                  fd.get(), (!fd.ok() ? std::strerror(errno) : "ok"));
 
             if (!fd.ok()) {
-                vector<string> lines = android::base::Split(log_buf.data(), "\n");
+                // kernel NULL terminates log_buf, so this checks for non-empty string
+                if (log_buf[0]) {
+                    vector<string> lines = Split(log_buf, "\n");
 
-                ALOGW("BPF_PROG_LOAD - BEGIN log_buf contents:");
-                for (const auto& line : lines) ALOGW("%s", line.c_str());
-                ALOGW("BPF_PROG_LOAD - END log_buf contents.");
+                    ALOGW("BPF_PROG_LOAD - BEGIN log_buf contents:");
+                    for (const auto& line : lines) ALOGW("%s", line.c_str());
+                    ALOGW("BPF_PROG_LOAD - END log_buf contents.");
+                }
 
                 if (cs[i].prog_def->optional) {
-                    ALOGW("failed program is marked optional - continuing...");
+                    ALOGW("failed program %s is marked optional - continuing...",
+                          cs[i].name.c_str());
                     continue;
                 }
-                ALOGE("non-optional program failed to load.");
+                ALOGE("non-optional program %s failed to load.", cs[i].name.c_str());
             }
         }
 
@@ -1124,12 +1152,6 @@
     ALOGD("BpfLoader version 0x%05x processing ELF object %s with ver [0x%05x,0x%05x)",
           bpfloader_ver, elfPath, bpfLoaderMinVer, bpfLoaderMaxVer);
 
-    ret = readCodeSections(elfFile, cs);
-    if (ret) {
-        ALOGE("Couldn't read all code sections in %s", elfPath);
-        return ret;
-    }
-
     ret = createMaps(elfPath, elfFile, mapFds, prefix, bpfloader_ver);
     if (ret) {
         ALOGE("Failed to create maps: (ret=%d) in %s", ret, elfPath);
@@ -1139,6 +1161,13 @@
     for (int i = 0; i < (int)mapFds.size(); i++)
         ALOGV("map_fd found at %d is %d in %s", i, mapFds[i].get(), elfPath);
 
+    ret = readCodeSections(elfFile, cs);
+    if (ret == -ENOENT) return 0;  // no programs defined in this .o
+    if (ret) {
+        ALOGE("Couldn't read all code sections in %s", elfPath);
+        return ret;
+    }
+
     applyMapRelo(elfFile, mapFds, cs);
 
     ret = loadCodeSections(elfPath, cs, string(license.data()), prefix, bpfloader_ver);
@@ -1155,33 +1184,35 @@
     abort();  // can only hit this if permissions (likely selinux) are screwed up
 }
 
+#define APEXROOT "/apex/com.android.tethering"
+#define BPFROOT APEXROOT "/etc/bpf"
 
 const Location locations[] = {
         // S+ Tethering mainline module (network_stack): tether offload
         {
-                .dir = "/apex/com.android.tethering/etc/bpf/",
+                .dir = BPFROOT "/",
                 .prefix = "tethering/",
         },
         // T+ Tethering mainline module (shared with netd & system server)
         // netutils_wrapper (for iptables xt_bpf) has access to programs
         {
-                .dir = "/apex/com.android.tethering/etc/bpf/netd_shared/",
+                .dir = BPFROOT "/netd_shared/",
                 .prefix = "netd_shared/",
         },
         // T+ Tethering mainline module (shared with netd & system server)
         // netutils_wrapper has no access, netd has read only access
         {
-                .dir = "/apex/com.android.tethering/etc/bpf/netd_readonly/",
+                .dir = BPFROOT "/netd_readonly/",
                 .prefix = "netd_readonly/",
         },
         // T+ Tethering mainline module (shared with system server)
         {
-                .dir = "/apex/com.android.tethering/etc/bpf/net_shared/",
+                .dir = BPFROOT "/net_shared/",
                 .prefix = "net_shared/",
         },
         // T+ Tethering mainline module (not shared, just network_stack)
         {
-                .dir = "/apex/com.android.tethering/etc/bpf/net_private/",
+                .dir = BPFROOT "/net_private/",
                 .prefix = "net_private/",
         },
 };
@@ -1237,7 +1268,7 @@
 // to include a newline to match 'echo "value" > /proc/sys/...foo' behaviour,
 // which is usually how kernel devs test the actual sysctl interfaces.
 static int writeProcSysFile(const char *filename, const char *value) {
-    base::unique_fd fd(open(filename, O_WRONLY | O_CLOEXEC));
+    unique_fd fd(open(filename, O_WRONLY | O_CLOEXEC));
     if (fd < 0) {
         const int err = errno;
         ALOGE("open('%s', O_WRONLY | O_CLOEXEC) -> %s", filename, strerror(err));
@@ -1314,7 +1345,7 @@
 }
 
 static bool hasGSM() {
-    static string ph = base::GetProperty("gsm.current.phone-type", "");
+    static string ph = GetProperty("gsm.current.phone-type", "");
     static bool gsm = (ph != "");
     static bool logged = false;
     if (!logged) {
@@ -1327,7 +1358,7 @@
 static bool isTV() {
     if (hasGSM()) return false;  // TVs don't do GSM
 
-    static string key = base::GetProperty("ro.oem.key1", "");
+    static string key = GetProperty("ro.oem.key1", "");
     static bool tv = StartsWith(key, "ATV00");
     static bool logged = false;
     if (!logged) {
@@ -1338,10 +1369,10 @@
 }
 
 static bool isWear() {
-    static string wearSdkStr = base::GetProperty("ro.cw_build.wear_sdk.version", "");
-    static int wearSdkInt = base::GetIntProperty("ro.cw_build.wear_sdk.version", 0);
-    static string buildChars = base::GetProperty("ro.build.characteristics", "");
-    static vector<string> v = base::Tokenize(buildChars, ",");
+    static string wearSdkStr = GetProperty("ro.cw_build.wear_sdk.version", "");
+    static int wearSdkInt = GetIntProperty("ro.cw_build.wear_sdk.version", 0);
+    static string buildChars = GetProperty("ro.build.characteristics", "");
+    static vector<string> v = Tokenize(buildChars, ",");
     static bool watch = (std::find(v.begin(), v.end(), "watch") != v.end());
     static bool wear = (wearSdkInt > 0) || watch;
     static bool logged = false;
@@ -1358,7 +1389,7 @@
 
     // Any released device will have codename REL instead of a 'real' codename.
     // For safety: default to 'REL' so we default to unreleased=false on failure.
-    const bool unreleased = (base::GetProperty("ro.build.version.codename", "REL") != "REL");
+    const bool unreleased = (GetProperty("ro.build.version.codename", "REL") != "REL");
 
     // goog/main device_api_level is bumped *way* before aosp/main api level
     // (the latter only gets bumped during the push of goog/main to aosp/main)
@@ -1387,6 +1418,9 @@
     const bool isAtLeastT = (effective_api_level >= __ANDROID_API_T__);
     const bool isAtLeastU = (effective_api_level >= __ANDROID_API_U__);
     const bool isAtLeastV = (effective_api_level >= __ANDROID_API_V__);
+    const bool isAtLeastW = (effective_api_level >  __ANDROID_API_V__);  // TODO: switch to W
+
+    const int first_api_level = GetIntProperty("ro.board.first_api_level", effective_api_level);
 
     // last in U QPR2 beta1
     const bool has_platform_bpfloader_rc = exists("/system/etc/init/bpfloader.rc");
@@ -1399,6 +1433,7 @@
     if (isAtLeastU) ++bpfloader_ver;     // [44] BPFLOADER_MAINLINE_U_VERSION
     if (runningAsRoot) ++bpfloader_ver;  // [45] BPFLOADER_MAINLINE_U_QPR3_VERSION
     if (isAtLeastV) ++bpfloader_ver;     // [46] BPFLOADER_MAINLINE_V_VERSION
+    if (isAtLeastW) ++bpfloader_ver;     // [47] BPFLOADER_MAINLINE_W_VERSION
 
     ALOGI("NetBpfLoad v0.%u (%s) api:%d/%d kver:%07x (%s) uid:%d rc:%d%d",
           bpfloader_ver, argv[0], android_get_device_api_level(), effective_api_level,
@@ -1448,6 +1483,12 @@
         if (!isTV()) return 1;
     }
 
+    // 6.6 is highest version supported by Android V, so this is effectively W+ (sdk=36+)
+    if (isKernel32Bit() && isAtLeastKernelVersion(6, 7, 0)) {
+        ALOGE("Android platform with 32 bit kernel version >= 6.7.0 is unsupported");
+        return 1;
+    }
+
     // Various known ABI layout issues, particularly wrt. bpf and ipsec/xfrm.
     if (isAtLeastV && isKernel32Bit() && isX86()) {
         ALOGE("Android V requires X86 kernel to be 64-bit.");
@@ -1482,33 +1523,54 @@
         }
     }
 
+    /* Android 14/U should only launch on 64-bit kernels
+     *   T launches on 5.10/5.15
+     *   U launches on 5.15/6.1
+     * So >=5.16 implies isKernel64Bit()
+     *
+     * We thus added a test to V VTS which requires 5.16+ devices to use 64-bit kernels.
+     *
+     * Starting with Android V, which is the first to support a post 6.1 Linux Kernel,
+     * we also require 64-bit userspace.
+     *
+     * There are various known issues with 32-bit userspace talking to various
+     * kernel interfaces (especially CAP_NET_ADMIN ones) on a 64-bit kernel.
+     * Some of these have userspace or kernel workarounds/hacks.
+     * Some of them don't...
+     * We're going to be removing the hacks.
+     * (for example "ANDROID: xfrm: remove in_compat_syscall() checks").
+     * Note: this check/enforcement only applies to *system* userspace code,
+     * it does not affect unprivileged apps, the 32-on-64 compatibility
+     * problems are AFAIK limited to various CAP_NET_ADMIN protected interfaces.
+     *
+     * Additionally the 32-bit kernel jit support is poor,
+     * and 32-bit userspace on 64-bit kernel bpf ringbuffer compatibility is broken.
+     */
     if (isUserspace32bit() && isAtLeastKernelVersion(6, 2, 0)) {
-        /* Android 14/U should only launch on 64-bit kernels
-         *   T launches on 5.10/5.15
-         *   U launches on 5.15/6.1
-         * So >=5.16 implies isKernel64Bit()
-         *
-         * We thus added a test to V VTS which requires 5.16+ devices to use 64-bit kernels.
-         *
-         * Starting with Android V, which is the first to support a post 6.1 Linux Kernel,
-         * we also require 64-bit userspace.
-         *
-         * There are various known issues with 32-bit userspace talking to various
-         * kernel interfaces (especially CAP_NET_ADMIN ones) on a 64-bit kernel.
-         * Some of these have userspace or kernel workarounds/hacks.
-         * Some of them don't...
-         * We're going to be removing the hacks.
-         * (for example "ANDROID: xfrm: remove in_compat_syscall() checks").
-         * Note: this check/enforcement only applies to *system* userspace code,
-         * it does not affect unprivileged apps, the 32-on-64 compatibility
-         * problems are AFAIK limited to various CAP_NET_ADMIN protected interfaces.
-         *
-         * Additionally the 32-bit kernel jit support is poor,
-         * and 32-bit userspace on 64-bit kernel bpf ringbuffer compatibility is broken.
-         */
-        ALOGE("64-bit userspace required on 6.2+ kernels.");
-        // Stuff won't work reliably, but exempt TVs & Arm Wear devices
-        if (!isTV() && !(isWear() && isArm())) return 1;
+        // Stuff won't work reliably, but...
+        if (isTV()) {
+            // exempt TVs... they don't really need functional advanced networking
+            ALOGW("[TV] 32-bit userspace unsupported on 6.2+ kernels.");
+        } else if (isWear() && isArm()) {
+            // exempt Arm Wear devices (arm32 ABI is far less problematic than x86-32)
+            ALOGW("[Arm Wear] 32-bit userspace unsupported on 6.2+ kernels.");
+        } else if (first_api_level <= __ANDROID_API_T__ && isArm()) {
+            // also exempt Arm devices upgrading with major kernel rev from T-
+            // might possibly be better for them to run with a newer kernel...
+            ALOGW("[Arm KernelUpRev] 32-bit userspace unsupported on 6.2+ kernels.");
+        } else if (isArm()) {
+            ALOGE("[Arm] 64-bit userspace required on 6.2+ kernels (%d).", first_api_level);
+            return 1;
+        } else { // x86 since RiscV cannot be 32-bit
+            ALOGE("[x86] 64-bit userspace required on 6.2+ kernels.");
+            return 1;
+        }
+    }
+
+    // Note: 6.6 is highest version supported by Android V (sdk=35), so this is for sdk=36+
+    if (isUserspace32bit() && isAtLeastKernelVersion(6, 7, 0)) {
+        ALOGE("64-bit userspace required on 6.7+ kernels.");
+        return 1;
     }
 
     // Ensure we can determine the Android build type.
@@ -1579,7 +1641,7 @@
 
     int key = 1;
     int value = 123;
-    base::unique_fd map(
+    unique_fd map(
             createMap(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 2, 0));
     if (writeToMapEntry(map, &key, &value, BPF_ANY)) {
         ALOGE("Critical kernel bug - failure to write into index 1 of 2 element bpf map array.");
@@ -1611,11 +1673,11 @@
 }  // namespace android
 
 int main(int argc, char** argv, char * const envp[]) {
-    android::base::InitLogging(argv, &android::base::KernelLogger);
+    InitLogging(argv, &KernelLogger);
 
     if (argc == 2 && !strcmp(argv[1], "done")) {
         // we're being re-exec'ed from platform bpfloader to 'finalize' things
-        if (!android::base::SetProperty("bpf.progs_loaded", "1")) {
+        if (!SetProperty("bpf.progs_loaded", "1")) {
             ALOGE("Failed to set bpf.progs_loaded property to 1.");
             return 125;
         }
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
new file mode 100644
index 0000000..8f3f462
--- /dev/null
+++ b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    exec_start bpfloader
+
+service bpfloader /system/bin/netbpfload
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
+    user root
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc
new file mode 100644
index 0000000..066cfc8
--- /dev/null
+++ b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc
@@ -0,0 +1,8 @@
+on load_bpf_programs
+    exec_start bpfloader
+
+service bpfloader /system/bin/false
+    user root
+    oneshot
+    reboot_on_failure reboot,netbpfload-missing
+    updatable
diff --git a/netd/Android.bp b/bpf/netd/Android.bp
similarity index 100%
rename from netd/Android.bp
rename to bpf/netd/Android.bp
diff --git a/netd/BpfBaseTest.cpp b/bpf/netd/BpfBaseTest.cpp
similarity index 100%
rename from netd/BpfBaseTest.cpp
rename to bpf/netd/BpfBaseTest.cpp
diff --git a/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
similarity index 100%
rename from netd/BpfHandler.cpp
rename to bpf/netd/BpfHandler.cpp
diff --git a/netd/BpfHandler.h b/bpf/netd/BpfHandler.h
similarity index 100%
rename from netd/BpfHandler.h
rename to bpf/netd/BpfHandler.h
diff --git a/netd/BpfHandlerTest.cpp b/bpf/netd/BpfHandlerTest.cpp
similarity index 100%
rename from netd/BpfHandlerTest.cpp
rename to bpf/netd/BpfHandlerTest.cpp
diff --git a/netd/NetdUpdatable.cpp b/bpf/netd/NetdUpdatable.cpp
similarity index 100%
rename from netd/NetdUpdatable.cpp
rename to bpf/netd/NetdUpdatable.cpp
diff --git a/netd/include/NetdUpdatablePublic.h b/bpf/netd/include/NetdUpdatablePublic.h
similarity index 100%
rename from netd/include/NetdUpdatablePublic.h
rename to bpf/netd/include/NetdUpdatablePublic.h
diff --git a/netd/libnetd_updatable.map.txt b/bpf/netd/libnetd_updatable.map.txt
similarity index 100%
rename from netd/libnetd_updatable.map.txt
rename to bpf/netd/libnetd_updatable.map.txt
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index f6717c5..dc1f56d 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -47,8 +47,8 @@
         "com.android.tethering",
     ],
     visibility: [
+        "//packages/modules/Connectivity/bpf/netd",
         "//packages/modules/Connectivity/DnsResolver",
-        "//packages/modules/Connectivity/netd",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service/native/libs/libclat",
         "//packages/modules/Connectivity/Tethering",
diff --git a/bpf/progs/bpf_net_helpers.h b/bpf/progs/bpf_net_helpers.h
index a86c3e6..a5664ba 100644
--- a/bpf/progs/bpf_net_helpers.h
+++ b/bpf/progs/bpf_net_helpers.h
@@ -139,6 +139,24 @@
     if (skb->data_end - skb->data < len) bpf_skb_pull_data(skb, len);
 }
 
+// anti-compiler-optimizer no-op: explicitly force full calculation of 'v'
+//
+// The use for this is to force full calculation of a complex arithmetic (likely binary
+// bitops) value, and then check the result only once (thus likely reducing the number
+// of required conditional jump instructions that badly affect bpf verifier runtime)
+//
+// The compiler cannot look into the assembly statement, so it doesn't know it does nothing.
+// Since the statement takes 'v' as both input and output in a register (+r),
+// the compiler must fully calculate the precise value of 'v' before this,
+// and must use the (possibly modified) value of 'v' afterwards (thus cannot
+// do funky optimizations to use partial results from before the asm).
+//
+// As this is not flagged 'volatile' this may still be moved out of a loop,
+// or even entirely optimized out if 'v' is never used afterwards.
+//
+// See: https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html
+#define COMPILER_FORCE_CALCULATION(v) asm ("" : "+r" (v))
+
 struct egress_bool { bool egress; };
 #define INGRESS ((struct egress_bool){ .egress = false })
 #define EGRESS ((struct egress_bool){ .egress = true })
diff --git a/bpf/progs/dscpPolicy.c b/bpf/progs/dscpPolicy.c
index 4bdd3ed..94d717b 100644
--- a/bpf/progs/dscpPolicy.c
+++ b/bpf/progs/dscpPolicy.c
@@ -23,11 +23,19 @@
 #define ECN_MASK 3
 #define UPDATE_TOS(dscp, tos) ((dscp) << 2) | ((tos) & ECN_MASK)
 
-DEFINE_BPF_MAP_GRW(socket_policy_cache_map, HASH, uint64_t, RuleEntry, CACHE_MAP_SIZE, AID_SYSTEM)
+// The cache is never read nor written by userspace and is indexed by socket cookie % CACHE_MAP_SIZE
+#define CACHE_MAP_SIZE 32  // should be a power of two so we can % cheaply
+DEFINE_BPF_MAP_KERNEL_INTERNAL(socket_policy_cache_map, PERCPU_ARRAY, uint32_t, RuleEntry,
+                               CACHE_MAP_SIZE)
 
 DEFINE_BPF_MAP_GRW(ipv4_dscp_policies_map, ARRAY, uint32_t, DscpPolicy, MAX_POLICIES, AID_SYSTEM)
 DEFINE_BPF_MAP_GRW(ipv6_dscp_policies_map, ARRAY, uint32_t, DscpPolicy, MAX_POLICIES, AID_SYSTEM)
 
+static inline __always_inline uint64_t calculate_u64(uint64_t v) {
+    COMPILER_FORCE_CALCULATION(v);
+    return v;
+}
+
 static inline __always_inline void match_policy(struct __sk_buff* skb, const bool ipv4) {
     void* data = (void*)(long)skb->data;
     const void* data_end = (void*)(long)skb->data_end;
@@ -43,6 +51,8 @@
     uint64_t cookie = bpf_get_socket_cookie(skb);
     if (!cookie) return;
 
+    uint32_t cacheid = cookie % CACHE_MAP_SIZE;
+
     __be16 sport = 0;
     uint16_t dport = 0;
     uint8_t protocol = 0;  // TODO: Use are reserved value? Or int (-1) and cast to uint below?
@@ -105,16 +115,33 @@
             return;
     }
 
-    RuleEntry* existing_rule = bpf_socket_policy_cache_map_lookup_elem(&cookie);
+    // this array lookup cannot actually fail
+    RuleEntry* existing_rule = bpf_socket_policy_cache_map_lookup_elem(&cacheid);
 
-    if (existing_rule &&
-        v6_equal(src_ip, existing_rule->src_ip) &&
-        v6_equal(dst_ip, existing_rule->dst_ip) &&
-        skb->ifindex == existing_rule->ifindex &&
-        sport == existing_rule->src_port &&
-        dport == existing_rule->dst_port &&
-        protocol == existing_rule->proto) {
-        if (existing_rule->dscp_val < 0) return;
+    if (!existing_rule) return; // impossible
+
+    uint64_t nomatch = 0;
+    nomatch |= v6_not_equal(src_ip, existing_rule->src_ip);
+    nomatch |= v6_not_equal(dst_ip, existing_rule->dst_ip);
+    nomatch |= (skb->ifindex ^ existing_rule->ifindex);
+    nomatch |= (sport ^ existing_rule->src_port);
+    nomatch |= (dport ^ existing_rule->dst_port);
+    nomatch |= (protocol ^ existing_rule->proto);
+    COMPILER_FORCE_CALCULATION(nomatch);
+
+    /*
+     * After the above funky bitwise arithmetic we have 'nomatch == 0' iff
+     *   src_ip == existing_rule->src_ip &&
+     *   dst_ip == existing_rule->dst_ip &&
+     *   skb->ifindex == existing_rule->ifindex &&
+     *   sport == existing_rule->src_port &&
+     *   dport == existing_rule->dst_port &&
+     *   protocol == existing_rule->proto
+     */
+
+    if (!nomatch) {
+        if (existing_rule->dscp_val < 0) return;  // cached no-op
+
         if (ipv4) {
             uint8_t newTos = UPDATE_TOS(existing_rule->dscp_val, tos);
             bpf_l3_csum_replace(skb, l2_header_size + IP4_OFFSET(check), htons(tos), htons(newTos),
@@ -126,12 +153,12 @@
             bpf_skb_store_bytes(skb, l2_header_size, &new_first_be32, sizeof(__be32),
                 BPF_F_RECOMPUTE_CSUM);
         }
-        return;
+        return;  // cached DSCP mutation
     }
 
-    // Linear scan ipv4_dscp_policies_map since no stored params match skb.
-    int best_score = 0;
-    int8_t new_dscp = -1;
+    // Linear scan ipv?_dscp_policies_map since stored params didn't match skb.
+    uint64_t best_score = 0;
+    int8_t new_dscp = -1;  // meaning no mutation
 
     for (register uint64_t i = 0; i < MAX_POLICIES; i++) {
         // Using a uint64 in for loop prevents infinite loop during BPF load,
@@ -150,38 +177,67 @@
         // easier for the verifier to analyze.
         if (!policy) return;
 
+        // Think of 'nomatch' as a 64-bit boolean: false iff zero, true iff non-zero.
+        // Start off with nomatch being false, ie. we assume things *are* matching.
+        uint64_t nomatch = 0;
+
+        // Due to 'a ^ b' being 0 iff a == b:
+        //   nomatch |= a ^ b
+        // should/can be read as:
+        //   nomatch ||= (a != b)
+        // which you can also think of as:
+        //   match &&= (a == b)
+
         // If policy iface index does not match skb, then skip to next policy.
-        if (policy->ifindex != skb->ifindex) continue;
+        nomatch |= (policy->ifindex ^ skb->ifindex);
 
-        int score = 0;
+        // policy->match_* are normal booleans, and should thus always be 0 or 1,
+        // thus you can think of these as:
+        //   if (policy->match_foo) match &&= (foo == policy->foo);
+        nomatch |= policy->match_proto * (protocol ^ policy->proto);
+        nomatch |= policy->match_src_ip * v6_not_equal(src_ip, policy->src_ip);
+        nomatch |= policy->match_dst_ip * v6_not_equal(dst_ip, policy->dst_ip);
+        nomatch |= policy->match_src_port * (sport ^ policy->src_port);
 
-        if (policy->present_fields & PROTO_MASK_FLAG) {
-            if (protocol != policy->proto) continue;
-            score += 0xFFFF;
-        }
-        if (policy->present_fields & SRC_IP_MASK_FLAG) {
-            if (v6_not_equal(src_ip, policy->src_ip)) continue;
-            score += 0xFFFF;
-        }
-        if (policy->present_fields & DST_IP_MASK_FLAG) {
-            if (v6_not_equal(dst_ip, policy->dst_ip)) continue;
-            score += 0xFFFF;
-        }
-        if (policy->present_fields & SRC_PORT_MASK_FLAG) {
-            if (sport != policy->src_port) continue;
-            score += 0xFFFF;
-        }
-        if (dport < policy->dst_port_start) continue;
-        if (dport > policy->dst_port_end) continue;
-        score += 0xFFFF + policy->dst_port_start - policy->dst_port_end;
+        // Since these values are u16s (<=63 bits), we can rely on u64 subtraction
+        // underflow setting the topmost bit.  Basically, you can think of:
+        //   nomatch |= (a - b) >> 63
+        // as:
+        //   match &&= (a >= b)
+        uint64_t dport64 = dport;  // Note: dst_port_{start_end} range is inclusive of both ends.
+        nomatch |= calculate_u64(dport64 - policy->dst_port_start) >> 63;
+        nomatch |= calculate_u64(policy->dst_port_end - dport64) >> 63;
 
-        if (score > best_score) {
-            best_score = score;
-            new_dscp = policy->dscp_val;
-        }
+        // score is 0x10000 for each matched field (proto, src_ip, dst_ip, src_port)
+        // plus 1..0x10000 for the dst_port range match (smaller for bigger ranges)
+        uint64_t score = 0;
+        score += policy->match_proto;  // reminder: match_* are boolean, thus 0 or 1
+        score += policy->match_src_ip;
+        score += policy->match_dst_ip;
+        score += policy->match_src_port;
+        score += 1;  // for a 1 element dst_port_{start,end} range
+        score <<= 16;  // scale up: ie. *= 0x10000
+        // now reduce score if the dst_port range is more than a single element
+        // we want to prioritize (ie. better score) matches of smaller ranges
+        score -= (policy->dst_port_end - policy->dst_port_start);  // -= 0..0xFFFF
+
+        // Here we need:
+        //   match &&= (score > best_score)
+        // which is the same as
+        //   match &&= (score >= best_score + 1)
+        // > not >= because we want equal score matches to prefer choosing earlier policies
+        nomatch |= calculate_u64(score - best_score - 1) >> 63;
+
+        COMPILER_FORCE_CALCULATION(nomatch);
+        if (nomatch) continue;
+
+        // only reachable if we matched the policy and (score > best_score)
+        best_score = score;
+        new_dscp = policy->dscp_val;
     }
 
-    RuleEntry value = {
+    // Update cache with found policy.
+    *existing_rule = (RuleEntry){
         .src_ip = src_ip,
         .dst_ip = dst_ip,
         .ifindex = skb->ifindex,
@@ -191,9 +247,6 @@
         .dscp_val = new_dscp,
     };
 
-    // Update cache with found policy.
-    bpf_socket_policy_cache_map_update_elem(&cookie, &value, BPF_ANY);
-
     if (new_dscp < 0) return;
 
     // Need to store bytes after updating map or program will not load.
diff --git a/bpf/progs/dscpPolicy.h b/bpf/progs/dscpPolicy.h
index ea84655..413fb0f 100644
--- a/bpf/progs/dscpPolicy.h
+++ b/bpf/progs/dscpPolicy.h
@@ -14,14 +14,8 @@
  * limitations under the License.
  */
 
-#define CACHE_MAP_SIZE 1024
 #define MAX_POLICIES 16
 
-#define SRC_IP_MASK_FLAG     1
-#define DST_IP_MASK_FLAG     2
-#define SRC_PORT_MASK_FLAG   4
-#define PROTO_MASK_FLAG      8
-
 #define STRUCT_SIZE(name, size) _Static_assert(sizeof(name) == (size), "Incorrect struct size.")
 
 // Retrieve the first (ie. high) 64 bits of an IPv6 address (in network order)
@@ -34,9 +28,6 @@
 #define v6_not_equal(a, b) ((v6_hi_be64(a) ^ v6_hi_be64(b)) \
                           | (v6_lo_be64(a) ^ v6_lo_be64(b)))
 
-// Returns 'a == b' as boolean
-#define v6_equal(a, b) (!v6_not_equal((a), (b)))
-
 typedef struct {
     struct in6_addr src_ip;
     struct in6_addr dst_ip;
@@ -46,10 +37,12 @@
     uint16_t dst_port_end;
     uint8_t proto;
     int8_t dscp_val;  // -1 none, or 0..63 DSCP value
-    uint8_t present_fields;
-    uint8_t pad[3];
+    bool match_src_ip;
+    bool match_dst_ip;
+    bool match_src_port;
+    bool match_proto;
 } DscpPolicy;
-STRUCT_SIZE(DscpPolicy, 2 * 16 + 4 + 3 * 2 + 3 * 1 + 3);  // 48
+STRUCT_SIZE(DscpPolicy, 2 * 16 + 4 + 3 * 2 + 6 * 1);  // 48
 
 typedef struct {
     struct in6_addr src_ip;
@@ -61,4 +54,4 @@
     int8_t dscp_val;  // -1 none, or 0..63 DSCP value
     uint8_t pad[2];
 } RuleEntry;
-STRUCT_SIZE(RuleEntry, 2 * 16 + 1 * 4 + 2 * 2 + 2 * 1 + 2);  // 44
+STRUCT_SIZE(RuleEntry, 2 * 16 + 4 + 2 * 2 + 4 * 1);  // 44
diff --git a/bpf/progs/test.c b/bpf/progs/test.c
index bce402e..8585118 100644
--- a/bpf/progs/test.c
+++ b/bpf/progs/test.c
@@ -42,22 +42,13 @@
 // Used only by BpfBitmapTest, not by production code.
 DEFINE_BPF_MAP_GRW(bitmap, ARRAY, int, uint64_t, 2, AID_NETWORK_STACK)
 
-DEFINE_BPF_PROG_KVER("xdp/drop_ipv4_udp_ether", AID_ROOT, AID_NETWORK_STACK,
-                      xdp_test, KVER_5_9)
-(struct xdp_md *ctx) {
-    void *data = (void *)(long)ctx->data;
-    void *data_end = (void *)(long)ctx->data_end;
-
-    struct ethhdr *eth = data;
-    int hsize = sizeof(*eth);
-
-    struct iphdr *ip = data + hsize;
-    hsize += sizeof(struct iphdr);
-
-    if (data + hsize > data_end) return XDP_PASS;
-    if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS;
-    if (ip->protocol == IPPROTO_UDP) return XDP_DROP;
-    return XDP_PASS;
+// we need at least 1 bpf program in the final .o for Android S bpfloader compatibility
+// this program is trivial, and has a 'infinite' minimum kernel version number,
+// so will always be skipped
+DEFINE_BPF_PROG_KVER("skfilter/match", AID_ROOT, AID_ROOT, match, KVER_INF)
+(__unused struct __sk_buff* skb) {
+    return XTBPF_MATCH;
 }
 
 LICENSE("Apache 2.0");
+CRITICAL("Networking xTS tests");
diff --git a/tests/mts/Android.bp b/bpf/tests/mts/Android.bp
similarity index 100%
rename from tests/mts/Android.bp
rename to bpf/tests/mts/Android.bp
diff --git a/tests/mts/OWNERS b/bpf/tests/mts/OWNERS
similarity index 100%
rename from tests/mts/OWNERS
rename to bpf/tests/mts/OWNERS
diff --git a/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
similarity index 96%
rename from tests/mts/bpf_existence_test.cpp
rename to bpf/tests/mts/bpf_existence_test.cpp
index 29f5cd2..f3c6907 100644
--- a/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -80,11 +80,6 @@
     TETHERING "prog_offload_schedcls_tether_upstream6_rawip",
 };
 
-// Provided by *current* mainline module for S+ devices with 5.10+ kernels
-static const set<string> MAINLINE_FOR_S_5_10_PLUS = {
-    TETHERING "prog_test_xdp_drop_ipv4_udp_ether",
-};
-
 // Provided by *current* mainline module for T+ devices
 static const set<string> MAINLINE_FOR_T_PLUS = {
     SHARED "map_block_blocked_ports_map",
@@ -159,7 +154,7 @@
     NETD "prog_netd_setsockopt_prog",
 };
 
-// Provided by *current* mainline module for U+ devices with 5.10+ kernels
+// 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",
 };
@@ -194,7 +189,6 @@
     // S requires Linux Kernel 4.9+ and thus requires eBPF support.
     if (IsAtLeastS()) ASSERT_TRUE(isAtLeastKernelVersion(4, 9, 0));
     DO_EXPECT(IsAtLeastS(), MAINLINE_FOR_S_PLUS);
-    DO_EXPECT(IsAtLeastS() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_S_5_10_PLUS);
 
     // Nothing added or removed in SCv2.
 
diff --git a/common/OWNERS b/common/OWNERS
index e7f5d11..989d286 100644
--- a/common/OWNERS
+++ b/common/OWNERS
@@ -1 +1,2 @@
 per-file thread_flags.aconfig = file:platform/packages/modules/Connectivity:main:/thread/OWNERS
+per-file networksecurity_flags.aconfig = file:platform/packages/modules/Connectivity:main:/networksecurity/OWNERS
\ No newline at end of file
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 45cbb78..4c6d8ba 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -147,3 +147,12 @@
   bug: "335680025"
   is_fixed_read_only: true
 }
+
+flag {
+  name: "tethering_active_sessions_metrics"
+  is_exported: true
+  namespace: "android_core_networking"
+  description: "Flag for collecting tethering active sessions metrics"
+  bug: "354619988"
+  is_fixed_read_only: true
+}
diff --git a/common/networksecurity_flags.aconfig b/common/networksecurity_flags.aconfig
index ef8ffcd..6438ba4 100644
--- a/common/networksecurity_flags.aconfig
+++ b/common/networksecurity_flags.aconfig
@@ -6,4 +6,5 @@
     namespace: "network_security"
     description: "Enable service for certificate transparency log list data"
     bug: "319829948"
+    is_fixed_read_only: true
 }
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 0edb7a8..c11c6c0 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -12,7 +12,17 @@
 flag {
     name: "configuration_enabled"
     is_exported: true
+    is_fixed_read_only: true
     namespace: "thread_network"
     description: "Controls whether the Android Thread configuration is enabled"
     bug: "342519412"
-}
\ No newline at end of file
+}
+
+flag {
+    name: "channel_max_powers_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether the Android Thread setting max power of channel feature is enabled"
+    bug: "346686506"
+}
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/api/system-current.txt b/framework-t/api/system-current.txt
index 2354882..9f26bcf 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -516,6 +516,7 @@
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void registerOperationalDatasetCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.channel_max_powers_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setChannelMaxPowers(@NonNull @Size(min=1) android.util.SparseIntArray, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setEnabled(boolean, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @FlaggedApi("com.android.net.thread.flags.configuration_enabled") @RequiresPermission(android.Manifest.permission.THREAD_NETWORK_PRIVILEGED) public void unregisterConfigurationCallback(@NonNull java.util.function.Consumer<android.net.thread.ThreadConfiguration>);
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
@@ -525,6 +526,7 @@
     field public static final int DEVICE_ROLE_LEADER = 4; // 0x4
     field public static final int DEVICE_ROLE_ROUTER = 3; // 0x3
     field public static final int DEVICE_ROLE_STOPPED = 0; // 0x0
+    field public static final int MAX_POWER_CHANNEL_DISABLED = -2147483648; // 0x80000000
     field public static final int STATE_DISABLED = 0; // 0x0
     field public static final int STATE_DISABLING = 2; // 0x2
     field public static final int STATE_ENABLED = 1; // 0x1
@@ -557,6 +559,7 @@
     field public static final int ERROR_UNAVAILABLE = 4; // 0x4
     field public static final int ERROR_UNKNOWN = 11; // 0xb
     field public static final int ERROR_UNSUPPORTED_CHANNEL = 7; // 0x7
+    field public static final int ERROR_UNSUPPORTED_FEATURE = 13; // 0xd
   }
 
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkManager {
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/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 63a6cd2..1ebc4a3 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -6734,4 +6734,33 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    /**
+     * Get the specified ConnectivityService feature status. This method is for test code to check
+     * whether the feature is enabled or not.
+     * Note that tests can not just read DeviceConfig since ConnectivityService reads flag at
+     * startup. For example, it's possible that the current flag value is "disable"(-1) but the
+     * feature is enabled since the flag value was "enable"(1) when ConnectivityService started up.
+     * If the ConnectivityManager needs to check the ConnectivityService feature status for non-test
+     * purpose, define feature in {@link ConnectivityManagerFeature} and use
+     * {@link #isFeatureEnabled} instead.
+     *
+     * @param featureFlag  target flag for feature
+     * @return {@code true} if the feature is enabled, {@code false} if the feature is disabled.
+     * @throws IllegalArgumentException if the flag is invalid
+     *
+     * @hide
+     */
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_SETTINGS,
+            android.Manifest.permission.NETWORK_STACK,
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK
+    })
+    public boolean isConnectivityServiceFeatureEnabledForTesting(final String featureFlag) {
+        try {
+            return mService.isConnectivityServiceFeatureEnabledForTesting(featureFlag);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index 988cc92..47b3316 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -262,4 +262,6 @@
     IBinder getRoutingCoordinatorService();
 
     long getEnabledConnectivityManagerFeatures();
+
+    boolean isConnectivityServiceFeatureEnabledForTesting(String featureFlag);
 }
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/cts/fastpair/AndroidManifest.xml b/nearby/tests/cts/fastpair/AndroidManifest.xml
index 9e1ec70..472f4f0 100644
--- a/nearby/tests/cts/fastpair/AndroidManifest.xml
+++ b/nearby/tests/cts/fastpair/AndroidManifest.xml
@@ -21,7 +21,6 @@
   <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
   <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
   <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
-  <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
 
   <application>
     <uses-library android:name="android.test.runner"/>
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..f196abb 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -17,17 +17,18 @@
 
 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";
@@ -54,7 +55,7 @@
 
     @Override
     public void onPropertiesChanged(Properties properties) {
-        if (!SdkLevel.isAtLeastV() || !NAMESPACE_TETHERING.equals(properties.getNamespace())) {
+        if (!NAMESPACE_TETHERING.equals(properties.getNamespace())) {
             return;
         }
 
@@ -85,6 +86,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/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/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 450f380..241d5fa 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
@@ -39,7 +39,7 @@
                                          uint32_t poll_ms) {
   // Always schedule another run of ourselves to recursively poll periodically.
   // The task runner is sequential so these can't run on top of each other.
-  runner->PostDelayedTask([=]() { PollAndSchedule(runner, poll_ms); }, poll_ms);
+  runner->PostDelayedTask([=, this]() { PollAndSchedule(runner, poll_ms); }, poll_ms);
 
   if (mMutex.try_lock()) {
     ConsumeAllLocked();
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 5f672e7..8e4ec2f 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -1938,8 +1938,21 @@
                         mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setAvoidAdvertisingEmptyTxtRecords(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS))
-                .setOverrideProvider(flag -> mDeps.isFeatureEnabled(
-                        mContext, FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag))
+                .setOverrideProvider(new MdnsFeatureFlags.FlagOverrideProvider() {
+                    @Override
+                    public boolean isForceEnabledForTest(@NonNull String flag) {
+                        return mDeps.isFeatureEnabled(
+                                mContext,
+                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag);
+                    }
+
+                    @Override
+                    public int getIntValueForTest(@NonNull String flag) {
+                        return mDeps.getDeviceConfigPropertyInt(
+                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag,
+                                -1 /* defaultValue */);
+                    }
+                })
                 .build();
         mMdnsSocketClient =
                 new MdnsMultinetworkSocketClient(handler.getLooper(), mMdnsSocketProvider,
@@ -2006,6 +2019,14 @@
         }
 
         /**
+         * @see DeviceConfigUtils#getDeviceConfigPropertyInt
+         */
+        public int getDeviceConfigPropertyInt(String feature, int defaultValue) {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(
+                    NAMESPACE_TETHERING, feature, defaultValue);
+        }
+
+        /**
          * @see MdnsDiscoveryManager
          */
         public MdnsDiscoveryManager makeMdnsDiscoveryManager(
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 7fa605a..a74bdf7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -16,6 +16,8 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility;
+
 import android.Manifest.permission;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -134,13 +136,20 @@
         this.discoveryExecutor = new DiscoveryExecutor(socketClient.getLooper());
     }
 
-    private static class DiscoveryExecutor implements Executor {
+    /**
+     * A utility class to generate a handler, optionally with a looper, and to run functions on the
+     * newly created handler.
+     */
+    @VisibleForTesting(visibility = Visibility.PRIVATE)
+    static class DiscoveryExecutor implements Executor {
         private final HandlerThread handlerThread;
 
         @GuardedBy("pendingTasks")
         @Nullable private Handler handler;
+        // Store pending tasks and associated delay time. Each Pair represents a pending task
+        // (first) and its delay time (second).
         @GuardedBy("pendingTasks")
-        @NonNull private final ArrayList<Runnable> pendingTasks = new ArrayList<>();
+        @NonNull private final ArrayList<Pair<Runnable, Long>> pendingTasks = new ArrayList<>();
 
         DiscoveryExecutor(@Nullable Looper defaultLooper) {
             if (defaultLooper != null) {
@@ -154,8 +163,8 @@
                     protected void onLooperPrepared() {
                         synchronized (pendingTasks) {
                             handler = new Handler(getLooper());
-                            for (Runnable pendingTask : pendingTasks) {
-                                handler.post(pendingTask);
+                            for (Pair<Runnable, Long> pendingTask : pendingTasks) {
+                                handler.postDelayed(pendingTask.first, pendingTask.second);
                             }
                             pendingTasks.clear();
                         }
@@ -177,16 +186,20 @@
 
         @Override
         public void execute(Runnable function) {
+            executeDelayed(function, 0L /* delayMillis */);
+        }
+
+        public void executeDelayed(Runnable function, long delayMillis) {
             final Handler handler;
             synchronized (pendingTasks) {
                 if (this.handler == null) {
-                    pendingTasks.add(function);
+                    pendingTasks.add(Pair.create(function, delayMillis));
                     return;
                 } else {
                     handler = this.handler;
                 }
             }
-            handler.post(function);
+            handler.postDelayed(function, delayMillis);
         }
 
         void shutDown() {
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 709dc79..b2be6ce 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -111,6 +111,12 @@
          * Indicates whether the flag should be force-enabled for testing purposes.
          */
         boolean isForceEnabledForTest(@NonNull String flag);
+
+
+        /**
+         * Get the int value of the flag for testing purposes.
+         */
+        int getIntValueForTest(@NonNull String flag);
     }
 
     /**
@@ -121,6 +127,18 @@
     }
 
     /**
+     * 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.
+     */
+    private int getIntValueForTest(@NonNull String flag) {
+        if (mOverrideProvider == null) {
+            return -1;
+        }
+        return mOverrideProvider.getIntValueForTest(flag);
+    }
+
+    /**
      * Indicates whether {@link #NSD_UNICAST_REPLY_ENABLED} is enabled, including for testing.
      */
     public boolean isUnicastReplyEnabled() {
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index a92ecb0..0d27d54 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -309,7 +309,7 @@
     static final int DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES = 400;
     /**
      * The delay time between to network stats update intents.
-     * Added to fix intent spams (b/3115462)
+     * Added to fix intent spams (b/343844995)
      */
     @VisibleForTesting(visibility = PRIVATE)
     static final int BROADCAST_NETWORK_STATS_UPDATED_DELAY_MS = 1000;
@@ -484,7 +484,6 @@
     @GuardedBy("mStatsLock")
     private long mLatestNetworkStatsUpdatedBroadcastScheduledTime = Long.MIN_VALUE;
 
-
     private final TrafficStatsRateLimitCache mTrafficStatsTotalCache;
     private final TrafficStatsRateLimitCache mTrafficStatsIfaceCache;
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
@@ -495,7 +494,6 @@
     private final boolean mAlwaysUseTrafficStatsRateLimitCache;
     private final int mTrafficStatsRateLimitCacheExpiryDuration;
     private final int mTrafficStatsRateLimitCacheMaxEntries;
-
     private final boolean mBroadcastNetworkStatsUpdatedRateLimitEnabled;
 
 
@@ -720,15 +718,6 @@
     @VisibleForTesting
     public static class Dependencies {
         /**
-         * Get broadcast network stats updated delay time in ms
-         * @return
-         */
-        @NonNull
-        public long getBroadcastNetworkStatsUpdateDelayMs() {
-            return BROADCAST_NETWORK_STATS_UPDATED_DELAY_MS;
-        }
-
-        /**
          * Get legacy platform stats directory.
          */
         @NonNull
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-ar/strings.xml b/service/ServiceConnectivityResources/res/values-ar/strings.xml
index 92dd9a1..8cefec4 100644
--- a/service/ServiceConnectivityResources/res/values-ar/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-ar/strings.xml
@@ -40,7 +40,7 @@
     <item msgid="5624324321165953608">"Wi-Fi"</item>
     <item msgid="5667906231066981731">"بلوتوث"</item>
     <item msgid="346574747471703768">"إيثرنت"</item>
-    <item msgid="5734728378097476003">"‏شبكة افتراضية خاصة (VPN)"</item>
+    <item msgid="5734728378097476003">"‏شبكة VPN"</item>
   </string-array>
     <string name="network_switch_type_name_unknown" msgid="5116448402191972082">"نوع شبكة غير معروف"</string>
 </resources>
diff --git a/service/ServiceConnectivityResources/res/values-fa/strings.xml b/service/ServiceConnectivityResources/res/values-fa/strings.xml
index 02c60df..09f1255 100644
--- a/service/ServiceConnectivityResources/res/values-fa/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-fa/strings.xml
@@ -23,15 +23,15 @@
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
     <skip />
     <string name="mobile_network_available_no_internet" msgid="1000871587359324217">"اتصال اینترنت وجود ندارد"</string>
-    <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"ممکن است داده <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> تمام شده باشد. برای گزینه‌ها ضربه بزنید."</string>
-    <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"ممکن است داده شما تمام شده باشد. برای گزینه‌ها ضربه بزنید."</string>
+    <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"ممکن است داده <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> تمام شده باشد. برای گزینه‌ها تک‌ضرب بزنید."</string>
+    <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"ممکن است داده شما تمام شده باشد. برای گزینه‌ها تک‌ضرب بزنید."</string>
     <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> به اینترنت دسترسی ندارد"</string>
-    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"برای گزینه‌ها ضربه بزنید"</string>
+    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"برای گزینه‌ها تک‌ضرب بزنید"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"شبکه تلفن همراه به اینترنت دسترسی ندارد"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"شبکه به اینترنت دسترسی ندارد"</string>
     <string name="private_dns_broken_detailed" msgid="2677123850463207823">"‏سرور DNS خصوصی قابل دسترسی نیست"</string>
     <string name="network_partial_connectivity" msgid="5549503845834993258">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> اتصال محدودی دارد"</string>
-    <string name="network_partial_connectivity_detailed" msgid="4732435946300249845">"به‌هرصورت، برای اتصال ضربه بزنید"</string>
+    <string name="network_partial_connectivity_detailed" msgid="4732435946300249845">"به‌هرصورت، برای اتصال تک‌ضرب بزنید"</string>
     <string name="network_switch_metered" msgid="5016937523571166319">"به <xliff:g id="NETWORK_TYPE">%1$s</xliff:g> تغییر کرد"</string>
     <string name="network_switch_metered_detail" msgid="1257300152739542096">"وقتی <xliff:g id="PREVIOUS_NETWORK">%2$s</xliff:g> به اینترنت دسترسی نداشته باشد، دستگاه از <xliff:g id="NEW_NETWORK">%1$s</xliff:g> استفاده می‌کند. ممکن است هزینه‌هایی اعمال شود."</string>
     <string name="network_switch_metered_toast" msgid="70691146054130335">"از <xliff:g id="PREVIOUS_NETWORK">%1$s</xliff:g> به <xliff:g id="NEW_NETWORK">%2$s</xliff:g> تغییر کرد"</string>
diff --git a/service/ServiceConnectivityResources/res/values-ky/strings.xml b/service/ServiceConnectivityResources/res/values-ky/strings.xml
index 08ffd2a..398531a 100644
--- a/service/ServiceConnectivityResources/res/values-ky/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-ky/strings.xml
@@ -26,7 +26,7 @@
     <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"<xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> трафиги түгөнгөн окшойт. Параметрлерди ачуу үчүн таптаңыз."</string>
     <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"Трафик түгөнгөн окшойт. Параметрлерди ачуу үчүн таптаңыз."</string>
     <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> Интернетке туташуусу жок"</string>
-    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Параметрлерди ачуу үчүн таптап коюңуз"</string>
+    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Параметрлерди ачуу үчүн тийип коюңуз"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"Мобилдик Интернет жок"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"Тармактын Интернет жок"</string>
     <string name="private_dns_broken_detailed" msgid="2677123850463207823">"Жеке DNS сервери жеткиликсиз"</string>
diff --git a/service/ServiceConnectivityResources/res/values-mn/strings.xml b/service/ServiceConnectivityResources/res/values-mn/strings.xml
index 2f13ef4..9af035b 100644
--- a/service/ServiceConnectivityResources/res/values-mn/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-mn/strings.xml
@@ -27,7 +27,7 @@
     <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"Таны дата дууссан байж магадгүй. Сонголтыг харахын тулд товшино уу."</string>
     <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g>-д интернэтийн хандалт алга"</string>
     <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Сонголт хийхийн тулд товшино уу"</string>
-    <string name="mobile_no_internet" msgid="4087718456753201450">"Мобайл сүлжээнд интернэт хандалт байхгүй байна"</string>
+    <string name="mobile_no_internet" msgid="4087718456753201450">"Хөдөлгөөнт холбооны сүлжээнд интернэт хандалт байхгүй байна"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"Сүлжээнд интернэт хандалт байхгүй байна"</string>
     <string name="private_dns_broken_detailed" msgid="2677123850463207823">"Хувийн DNS серверт хандах боломжгүй байна"</string>
     <string name="network_partial_connectivity" msgid="5549503845834993258">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> зарим үйлчилгээнд хандах боломжгүй байна"</string>
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 53b1eb2..cb62ae1 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -407,6 +407,7 @@
 import java.util.SortedSet;
 import java.util.StringJoiner;
 import java.util.TreeSet;
+import java.util.concurrent.CopyOnWriteArraySet;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.BiConsumer;
@@ -4439,9 +4440,8 @@
         pw.println();
         pw.println("Multicast routing supported: " +
                 (mMulticastRoutingCoordinatorService != null));
-
-        pw.println();
         pw.println("Background firewall chain enabled: " + mBackgroundFirewallChainEnabled);
+        pw.println("IngressToVpnAddressFiltering: " + mIngressToVpnAddressFiltering);
     }
 
     private void dumpNetworks(IndentingPrintWriter pw) {
@@ -8933,9 +8933,15 @@
     @NonNull
     final NetworkRequestInfo mDefaultRequest;
     // Collection of NetworkRequestInfo's used for default networks.
+    // This set is read and iterated on multiple threads.
+    // Using CopyOnWriteArraySet since number of default network request is small (system default
+    // network request + per-app default network requests) and updated infrequently but read
+    // frequently.
     @VisibleForTesting
     @NonNull
-    final ArraySet<NetworkRequestInfo> mDefaultNetworkRequests = new ArraySet<>();
+    final CopyOnWriteArraySet<NetworkRequestInfo> mDefaultNetworkRequests =
+            new CopyOnWriteArraySet<>();
+
 
     private boolean isPerAppDefaultRequest(@NonNull final NetworkRequestInfo nri) {
         return (mDefaultNetworkRequests.contains(nri) && mDefaultRequest != nri);
@@ -14506,4 +14512,14 @@
         }
         return features;
     }
+
+    @Override
+    public boolean isConnectivityServiceFeatureEnabledForTesting(String featureFlag) {
+        switch (featureFlag) {
+            case INGRESS_TO_VPN_ADDRESS_FILTERING:
+                return mIngressToVpnAddressFiltering;
+            default:
+                throw new IllegalArgumentException("Unknown flag: " + featureFlag);
+        }
+    }
 }
diff --git a/service/src/com/android/server/connectivity/DnsManager.java b/service/src/com/android/server/connectivity/DnsManager.java
index b95e3b1..c940eec 100644
--- a/service/src/com/android/server/connectivity/DnsManager.java
+++ b/service/src/com/android/server/connectivity/DnsManager.java
@@ -29,6 +29,7 @@
 import static android.net.resolv.aidl.IDnsResolverUnsolicitedEventListener.VALIDATION_RESULT_SUCCESS;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
@@ -404,22 +405,11 @@
             mPrivateDnsValidationMap.remove(netId);
         }
 
-        Log.d(TAG, String.format("sendDnsConfigurationForNetwork(%d, %s, %s, %d, %d, %d, %d, "
-                + "%d, %d, %s, %s, %s, %b, %s, %s, %s, %s, %d)", paramsParcel.netId,
-                Arrays.toString(paramsParcel.servers), Arrays.toString(paramsParcel.domains),
-                paramsParcel.sampleValiditySeconds, paramsParcel.successThreshold,
-                paramsParcel.minSamples, paramsParcel.maxSamples, paramsParcel.baseTimeoutMsec,
-                paramsParcel.retryCount, paramsParcel.tlsName,
-                Arrays.toString(paramsParcel.tlsServers),
-                Arrays.toString(paramsParcel.transportTypes), paramsParcel.meteredNetwork,
-                Arrays.toString(paramsParcel.interfaceNames),
-                paramsParcel.dohParams.name, Arrays.toString(paramsParcel.dohParams.ips),
-                paramsParcel.dohParams.dohpath, paramsParcel.dohParams.port));
+        Log.d(TAG, "sendDnsConfigurationForNetwork(" + paramsParcel + ")");
         try {
             mDnsResolver.setResolverConfiguration(paramsParcel);
         } catch (RemoteException | ServiceSpecificException e) {
             Log.e(TAG, "Error setting DNS configuration: " + e);
-            return;
         }
     }
 
@@ -509,9 +499,12 @@
         return out;
     }
 
-    @NonNull
+    @Nullable
     private DohParamsParcel makeDohParamsParcel(@NonNull PrivateDnsConfig cfg,
             @NonNull LinkProperties lp) {
+        if (!cfg.ddrEnabled) {
+            return null;
+        }
         if (cfg.mode == PRIVATE_DNS_MODE_OFF) {
             return new DohParamsParcel.Builder().build();
         }
diff --git a/service/src/com/android/server/connectivity/DscpPolicyValue.java b/service/src/com/android/server/connectivity/DscpPolicyValue.java
index 7b11eda..a9100ac 100644
--- a/service/src/com/android/server/connectivity/DscpPolicyValue.java
+++ b/service/src/com/android/server/connectivity/DscpPolicyValue.java
@@ -55,13 +55,17 @@
     @Field(order = 7, type = Type.S8)
     public final byte dscp;
 
-    @Field(order = 8, type = Type.U8, padding = 3)
-    public final short mask;
+    @Field(order = 8, type = Type.Bool)
+    public final boolean match_src_ip;
 
-    private static final int SRC_IP_MASK = 0x1;
-    private static final int DST_IP_MASK = 0x02;
-    private static final int SRC_PORT_MASK = 0x4;
-    private static final int PROTO_MASK = 0x8;
+    @Field(order = 9, type = Type.Bool)
+    public final boolean match_dst_ip;
+
+    @Field(order = 10, type = Type.Bool)
+    public final boolean match_src_port;
+
+    @Field(order = 11, type = Type.Bool)
+    public final boolean match_proto;
 
     private boolean ipEmpty(final byte[] ip) {
         for (int i = 0; i < ip.length; i++) {
@@ -98,24 +102,6 @@
     private static final byte[] EMPTY_ADDRESS_FIELD =
             InetAddress.parseNumericAddress("::").getAddress();
 
-    private short makeMask(final byte[] src46, final byte[] dst46, final int srcPort,
-            final int dstPortStart, final short proto, final byte dscp) {
-        short mask = 0;
-        if (src46 != EMPTY_ADDRESS_FIELD) {
-            mask |= SRC_IP_MASK;
-        }
-        if (dst46 != EMPTY_ADDRESS_FIELD) {
-            mask |=  DST_IP_MASK;
-        }
-        if (srcPort != -1) {
-            mask |=  SRC_PORT_MASK;
-        }
-        if (proto != -1) {
-            mask |=  PROTO_MASK;
-        }
-        return mask;
-    }
-
     private DscpPolicyValue(final InetAddress src46, final InetAddress dst46, final int ifIndex,
             final int srcPort, final int dstPortStart, final int dstPortEnd, final short proto,
             final byte dscp) {
@@ -131,9 +117,10 @@
         this.proto = proto != -1 ? proto : 0;
 
         this.dscp = dscp;
-        // Use member variables for IP since byte[] is needed and api variables for everything else
-        // so -1 is passed into mask if parameter is not present.
-        this.mask = makeMask(this.src46, this.dst46, srcPort, dstPortStart, proto, dscp);
+        this.match_src_ip = (src46 != null);
+        this.match_dst_ip = (dst46 != null);
+        this.match_src_port = (srcPort != -1);
+        this.match_proto = (proto != -1);
     }
 
     public DscpPolicyValue(final InetAddress src46, final InetAddress dst46, final int ifIndex,
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/DeviceConfigUtils.java b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
index 0426ace..04ce2fa 100644
--- a/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
+++ b/staticlibs/device/com/android/net/module/util/DeviceConfigUtils.java
@@ -22,6 +22,7 @@
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
 import static com.android.net.module.util.FeatureVersions.CONNECTIVITY_MODULE_ID;
+import static com.android.net.module.util.FeatureVersions.DNS_RESOLVER_MODULE_ID;
 import static com.android.net.module.util.FeatureVersions.MODULE_MASK;
 import static com.android.net.module.util.FeatureVersions.NETWORK_STACK_MODULE_ID;
 import static com.android.net.module.util.FeatureVersions.VERSION_MASK;
@@ -68,7 +69,8 @@
     @VisibleForTesting
     public static void resetPackageVersionCacheForTest() {
         sPackageVersion = -1;
-        sModuleVersion = -1;
+        sTetheringModuleVersion = -1;
+        sResolvModuleVersion = -1;
         sNetworkStackModuleVersion = -1;
     }
 
@@ -243,23 +245,23 @@
         }
     }
 
-    // Guess the tethering module name based on the package prefix of the connectivity resources
-    // Take the resource package name, cut it before "connectivity" and append "tethering".
+    // Guess an APEX module name based on the package prefix of the connectivity resources
+    // Take the resource package name, cut it before "connectivity" and append the module name.
     // Then resolve that package version number with packageManager.
-    // If that fails retry by appending "go.tethering" instead
-    private static long resolveTetheringModuleVersion(@NonNull Context context)
+    // If that fails retry by appending "go.<moduleName>" instead.
+    private static long resolveApexModuleVersion(@NonNull Context context, String moduleName)
             throws PackageManager.NameNotFoundException {
         final String pkgPrefix = resolvePkgPrefix(context);
         final PackageManager packageManager = context.getPackageManager();
         try {
-            return packageManager.getPackageInfo(pkgPrefix + "tethering",
+            return packageManager.getPackageInfo(pkgPrefix + moduleName,
                     PackageManager.MATCH_APEX).getLongVersionCode();
         } catch (PackageManager.NameNotFoundException e) {
             Log.d(TAG, "Device is using go modules");
             // fall through
         }
 
-        return packageManager.getPackageInfo(pkgPrefix + "go.tethering",
+        return packageManager.getPackageInfo(pkgPrefix + "go." + moduleName,
                 PackageManager.MATCH_APEX).getLongVersionCode();
     }
 
@@ -274,19 +276,35 @@
         return connResourcesPackage.substring(0, pkgPrefixLen);
     }
 
-    private static volatile long sModuleVersion = -1;
+    private static volatile long sTetheringModuleVersion = -1;
+
     private static long getTetheringModuleVersion(@NonNull Context context) {
-        if (sModuleVersion >= 0) return sModuleVersion;
+        if (sTetheringModuleVersion >= 0) return sTetheringModuleVersion;
 
         try {
-            sModuleVersion = resolveTetheringModuleVersion(context);
+            sTetheringModuleVersion = resolveApexModuleVersion(context, "tethering");
         } catch (PackageManager.NameNotFoundException e) {
             // It's expected to fail tethering module version resolution on the devices with
             // flattened apex
             Log.e(TAG, "Failed to resolve tethering module version: " + e);
             return DEFAULT_PACKAGE_VERSION;
         }
-        return sModuleVersion;
+        return sTetheringModuleVersion;
+    }
+
+    private static volatile long sResolvModuleVersion = -1;
+    private static long getResolvModuleVersion(@NonNull Context context) {
+        if (sResolvModuleVersion >= 0) return sResolvModuleVersion;
+
+        try {
+            sResolvModuleVersion = resolveApexModuleVersion(context, "resolv");
+        } catch (PackageManager.NameNotFoundException e) {
+            // It's expected to fail resolv module version resolution on the devices with
+            // flattened apex
+            Log.e(TAG, "Failed to resolve resolv module version: " + e);
+            return DEFAULT_PACKAGE_VERSION;
+        }
+        return sResolvModuleVersion;
     }
 
     private static volatile long sNetworkStackModuleVersion = -1;
@@ -342,6 +360,8 @@
             moduleVersion = getTetheringModuleVersion(context);
         } else if (moduleId == NETWORK_STACK_MODULE_ID) {
             moduleVersion = getNetworkStackModuleVersion(context);
+        } else if (moduleId == DNS_RESOLVER_MODULE_ID) {
+            moduleVersion = getResolvModuleVersion(context);
         } else {
             throw new IllegalArgumentException("Unknown module " + moduleId);
         }
diff --git a/staticlibs/device/com/android/net/module/util/FeatureVersions.java b/staticlibs/device/com/android/net/module/util/FeatureVersions.java
index d5f8124..d0cf3fd 100644
--- a/staticlibs/device/com/android/net/module/util/FeatureVersions.java
+++ b/staticlibs/device/com/android/net/module/util/FeatureVersions.java
@@ -37,6 +37,7 @@
     public static final long VERSION_MASK = 0x00F_FFFF_FFFFL;
     public static final long CONNECTIVITY_MODULE_ID = 0x01L << MODULE_SHIFT;
     public static final long NETWORK_STACK_MODULE_ID = 0x02L << MODULE_SHIFT;
+    public static final long DNS_RESOLVER_MODULE_ID = 0x03L << MODULE_SHIFT;
     // CLAT_ADDRESS_TRANSLATE is a feature of the network stack, which doesn't throw when system
     // try to add a NAT-T keepalive packet filter with v6 address, introduced in version
     // M-2023-Sept on July 3rd, 2023.
@@ -48,4 +49,11 @@
     // by BPF for the given uid and conditions, introduced in version M-2024-Feb on Nov 6, 2023.
     public static final long FEATURE_IS_UID_NETWORKING_BLOCKED =
             CONNECTIVITY_MODULE_ID + 34_14_00_000L;
+
+    // DDR is a feature implemented across NetworkStack, ConnectivityService and DnsResolver.
+    // The flag that enables this feature is in NetworkStack.
+    public static final long FEATURE_DDR_IN_CONNECTIVITY =
+            CONNECTIVITY_MODULE_ID + 35_11_00_000L;
+    public static final long FEATURE_DDR_IN_DNSRESOLVER =
+            DNS_RESOLVER_MODULE_ID + 35_11_00_000L;
 }
diff --git a/staticlibs/device/com/android/net/module/util/Struct.java b/staticlibs/device/com/android/net/module/util/Struct.java
index ff7a711..69ca678 100644
--- a/staticlibs/device/com/android/net/module/util/Struct.java
+++ b/staticlibs/device/com/android/net/module/util/Struct.java
@@ -105,6 +105,7 @@
  */
 public class Struct {
     public enum Type {
+        Bool,        // bool,           size = 1 byte
         U8,          // unsigned byte,  size = 1 byte
         U16,         // unsigned short, size = 2 bytes
         U32,         // unsigned int,   size = 4 bytes
@@ -169,6 +170,9 @@
 
     private static void checkAnnotationType(final Field annotation, final Class fieldType) {
         switch (annotation.type()) {
+            case Bool:
+                if (fieldType == Boolean.TYPE) return;
+                break;
             case U8:
             case S16:
                 if (fieldType == Short.TYPE) return;
@@ -218,6 +222,7 @@
     private static int getFieldLength(final Field annotation) {
         int length = 0;
         switch (annotation.type()) {
+            case Bool:
             case U8:
             case S8:
                 length = 1;
@@ -357,6 +362,9 @@
         final Object value;
         checkAnnotationType(fieldInfo.annotation, fieldInfo.field.getType());
         switch (fieldInfo.annotation.type()) {
+            case Bool:
+                value = buf.get() != 0;
+                break;
             case U8:
                 value = (short) (buf.get() & 0xFF);
                 break;
@@ -457,6 +465,9 @@
     private static void putFieldValue(final ByteBuffer output, final FieldInfo fieldInfo,
             final Object value) throws BufferUnderflowException {
         switch (fieldInfo.annotation.type()) {
+            case Bool:
+                output.put((byte) (value != null && (boolean) value ? 1 : 0));
+                break;
             case U8:
                 output.put((byte) (((short) value) & 0xFF));
                 break;
@@ -748,6 +759,16 @@
         return sb.toString();
     }
 
+    /** A simple Struct which only contains a bool field. */
+    public static class Bool extends Struct {
+        @Struct.Field(order = 0, type = Struct.Type.Bool)
+        public final boolean val;
+
+        public Bool(final boolean val) {
+            this.val = val;
+        }
+    }
+
     /** A simple Struct which only contains a u8 field. */
     public static class U8 extends Struct {
         @Struct.Field(order = 0, type = Struct.Type.U8)
diff --git a/staticlibs/framework/com/android/net/module/util/LocationPermissionChecker.java b/staticlibs/framework/com/android/net/module/util/LocationPermissionChecker.java
index f6bee69..e4d25cd 100644
--- a/staticlibs/framework/com/android/net/module/util/LocationPermissionChecker.java
+++ b/staticlibs/framework/com/android/net/module/util/LocationPermissionChecker.java
@@ -16,6 +16,8 @@
 
 package com.android.net.module.util;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
 import android.Manifest;
 import android.annotation.IntDef;
 import android.annotation.Nullable;
@@ -112,48 +114,14 @@
      *
      * @return {@link LocationPermissionCheckStatus} the result of the location permission check.
      */
-    public @LocationPermissionCheckStatus int checkLocationPermissionWithDetailInfo(
+    @VisibleForTesting(visibility = PRIVATE)
+    public @LocationPermissionCheckStatus int checkLocationPermissionInternal(
             String pkgName, @Nullable String featureId, int uid, @Nullable String message) {
-        final int result = checkLocationPermissionInternal(pkgName, featureId, uid, message);
-        switch (result) {
-            case ERROR_LOCATION_MODE_OFF:
-                Log.e(TAG, "Location mode is disabled for the device");
-                break;
-            case ERROR_LOCATION_PERMISSION_MISSING:
-                Log.e(TAG, "UID " + uid + " has no location permission");
-                break;
+        try {
+            checkPackage(uid, pkgName);
+        } catch (SecurityException e) {
+            return ERROR_LOCATION_PERMISSION_MISSING;
         }
-        return result;
-    }
-
-    /**
-     * Enforce the caller has location permission.
-     *
-     * This API determines if the location mode enabled for the caller and the caller has
-     * ACCESS_COARSE_LOCATION permission is targetSDK<29, otherwise, has ACCESS_FINE_LOCATION.
-     * SecurityException is thrown if the caller has no permission or the location mode is disabled.
-     *
-     * @param pkgName package name of the application requesting access
-     * @param featureId The feature in the package
-     * @param uid The uid of the package
-     * @param message A message describing why the permission was checked. Only needed if this is
-     *                not inside of a two-way binder call from the data receiver
-     */
-    public void enforceLocationPermission(String pkgName, @Nullable String featureId, int uid,
-            @Nullable String message) throws SecurityException {
-        final int result = checkLocationPermissionInternal(pkgName, featureId, uid, message);
-
-        switch (result) {
-            case ERROR_LOCATION_MODE_OFF:
-                throw new SecurityException("Location mode is disabled for the device");
-            case ERROR_LOCATION_PERMISSION_MISSING:
-                throw new SecurityException("UID " + uid + " has no location permission");
-        }
-    }
-
-    private int checkLocationPermissionInternal(String pkgName, @Nullable String featureId,
-            int uid, @Nullable String message) {
-        checkPackage(uid, pkgName);
 
         // Apps with NETWORK_SETTINGS, NETWORK_SETUP_WIZARD, NETWORK_STACK & MAINLINE_NETWORK_STACK
         // are granted a bypass.
@@ -221,7 +189,7 @@
     /**
      * Retrieves a handle to LocationManager (if not already done) and check if location is enabled.
      */
-    public boolean isLocationModeEnabled() {
+    private boolean isLocationModeEnabled() {
         final LocationManager LocationManager = mContext.getSystemService(LocationManager.class);
         try {
             return LocationManager.isLocationEnabledForUser(UserHandle.of(
@@ -278,7 +246,7 @@
     /**
      * Returns true if the |uid| holds NETWORK_SETTINGS permission.
      */
-    public boolean checkNetworkSettingsPermission(int uid) {
+    private boolean checkNetworkSettingsPermission(int uid) {
         return getUidPermission(android.Manifest.permission.NETWORK_SETTINGS, uid)
                 == PackageManager.PERMISSION_GRANTED;
     }
@@ -286,7 +254,7 @@
     /**
      * Returns true if the |uid| holds NETWORK_SETUP_WIZARD permission.
      */
-    public boolean checkNetworkSetupWizardPermission(int uid) {
+    private boolean checkNetworkSetupWizardPermission(int uid) {
         return getUidPermission(android.Manifest.permission.NETWORK_SETUP_WIZARD, uid)
                 == PackageManager.PERMISSION_GRANTED;
     }
@@ -294,7 +262,7 @@
     /**
      * Returns true if the |uid| holds NETWORK_STACK permission.
      */
-    public boolean checkNetworkStackPermission(int uid) {
+    private boolean checkNetworkStackPermission(int uid) {
         return getUidPermission(android.Manifest.permission.NETWORK_STACK, uid)
                 == PackageManager.PERMISSION_GRANTED;
     }
@@ -302,7 +270,7 @@
     /**
      * Returns true if the |uid| holds MAINLINE_NETWORK_STACK permission.
      */
-    public boolean checkMainlineNetworkStackPermission(int uid) {
+    private boolean checkMainlineNetworkStackPermission(int uid) {
         return getUidPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, uid)
                 == PackageManager.PERMISSION_GRANTED;
     }
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 91f94b5..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",
@@ -70,6 +70,7 @@
     ],
     main: "host/python/run_tests.py",
     libs: [
+        "absl-py",
         "mobly",
         "net-tests-utils-host-python-common",
     ],
@@ -81,10 +82,4 @@
     test_options: {
         unit_test: false,
     },
-    // Needed for applying VirtualEnv.
-    version: {
-        py3: {
-            embedded_launcher: false,
-        },
-    },
 }
diff --git a/staticlibs/tests/unit/host/python/test_config.xml b/staticlibs/tests/unit/host/python/test_config.xml
index d3b200a..fed9d11 100644
--- a/staticlibs/tests/unit/host/python/test_config.xml
+++ b/staticlibs/tests/unit/host/python/test_config.xml
@@ -14,9 +14,6 @@
      limitations under the License.
 -->
 <configuration description="Config for NetworkStaticLibHostPythonTests">
-    <target_preparer class="com.android.tradefed.targetprep.PythonVirtualenvPreparer">
-        <option name="dep-module" value="absl-py" />
-    </target_preparer>
     <test class="com.android.tradefed.testtype.mobly.MoblyBinaryHostTest" >
         <option name="mobly-par-file-name" value="NetworkStaticLibHostPythonTests" />
         <option name="mobly-test-timeout" value="3m" />
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
index 9fb61d9..a5af09b 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/DeviceConfigUtilsTest.java
@@ -24,6 +24,7 @@
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 import static com.android.net.module.util.FeatureVersions.CONNECTIVITY_MODULE_ID;
+import static com.android.net.module.util.FeatureVersions.DNS_RESOLVER_MODULE_ID;
 import static com.android.net.module.util.FeatureVersions.NETWORK_STACK_MODULE_ID;
 
 import static org.junit.Assert.assertEquals;
@@ -77,7 +78,9 @@
     private static final int TEST_DEFAULT_FLAG_VALUE = 0;
     private static final int TEST_MAX_FLAG_VALUE = 1000;
     private static final int TEST_MIN_FLAG_VALUE = 100;
-    private static final long TEST_PACKAGE_VERSION = 290000000;
+    private static final long TEST_PACKAGE_VERSION = 290500000;
+    private static final long TEST_GO_PACKAGE_VERSION = 290000000;  // Not updated
+    private static final long TEST_RESOLV_PACKAGE_VERSION = 290300000;  // Updated, but older.
     private static final String TEST_PACKAGE_NAME = "test.package.name";
     // The APEX name is the name of the APEX module, as in android.content.pm.ModuleInfo, and is
     // used for its mount point in /apex. APEX packages are actually APKs with a different
@@ -85,14 +88,18 @@
     // that manifest, and is reflected in android.content.pm.ApplicationInfo. Contrary to the APEX
     // (module) name, different package names are typically used to identify the organization that
     // built and signed the APEX modules.
-    private static final String TEST_APEX_PACKAGE_NAME = "com.prefix.android.tethering";
-    private static final String TEST_GO_APEX_PACKAGE_NAME = "com.prefix.android.go.tethering";
+    private static final String TEST_TETHERING_PACKAGE_NAME = "com.prefix.android.tethering";
+    private static final String TEST_GO_TETHERING_PACKAGE_NAME = "com.prefix.android.go.tethering";
+    private static final String TEST_RESOLV_PACKAGE_NAME = "com.prefix.android.resolv";
+    private static final String TEST_GO_RESOLV_PACKAGE_NAME = "com.prefix.android.go.resolv";
     private static final String TEST_CONNRES_PACKAGE_NAME =
             "com.prefix.android.connectivity.resources";
     private static final String TEST_NETWORKSTACK_NAME = "com.prefix.android.networkstack";
     private static final String TEST_GO_NETWORKSTACK_NAME = "com.prefix.android.go.networkstack";
     private final PackageInfo mPackageInfo = new PackageInfo();
-    private final PackageInfo mApexPackageInfo = new PackageInfo();
+    private final PackageInfo mGoApexPackageInfo = new PackageInfo();
+    private final PackageInfo mTetheringApexPackageInfo = new PackageInfo();
+    private final PackageInfo mResolvApexPackageInfo = new PackageInfo();
     private MockitoSession mSession;
 
     @Mock private Context mContext;
@@ -105,13 +112,22 @@
         mSession = mockitoSession().spyStatic(DeviceConfig.class).startMocking();
 
         mPackageInfo.setLongVersionCode(TEST_PACKAGE_VERSION);
-        mApexPackageInfo.setLongVersionCode(TEST_PACKAGE_VERSION);
+        mTetheringApexPackageInfo.setLongVersionCode(TEST_PACKAGE_VERSION);
+        mGoApexPackageInfo.setLongVersionCode(TEST_GO_PACKAGE_VERSION);
+        mResolvApexPackageInfo.setLongVersionCode(TEST_RESOLV_PACKAGE_VERSION);
 
         doReturn(mPm).when(mContext).getPackageManager();
         doReturn(TEST_PACKAGE_NAME).when(mContext).getPackageName();
         doThrow(NameNotFoundException.class).when(mPm).getPackageInfo(anyString(), anyInt());
         doReturn(mPackageInfo).when(mPm).getPackageInfo(eq(TEST_PACKAGE_NAME), anyInt());
-        doReturn(mApexPackageInfo).when(mPm).getPackageInfo(eq(TEST_APEX_PACKAGE_NAME), anyInt());
+        doReturn(mTetheringApexPackageInfo).when(mPm).getPackageInfo(
+                eq(TEST_TETHERING_PACKAGE_NAME), anyInt());
+        doReturn(mResolvApexPackageInfo).when(mPm).getPackageInfo(eq(TEST_RESOLV_PACKAGE_NAME),
+                anyInt());
+        doReturn(mGoApexPackageInfo).when(mPm).getPackageInfo(eq(TEST_GO_TETHERING_PACKAGE_NAME),
+                anyInt());
+        doReturn(mGoApexPackageInfo).when(mPm).getPackageInfo(eq(TEST_GO_RESOLV_PACKAGE_NAME),
+                anyInt());
 
         doReturn(mResources).when(mContext).getResources();
 
@@ -342,9 +358,9 @@
     @Test
     public void testFeatureIsEnabledOnGo() throws Exception {
         doThrow(NameNotFoundException.class).when(mPm).getPackageInfo(
-                eq(TEST_APEX_PACKAGE_NAME), anyInt());
-        doReturn(mApexPackageInfo).when(mPm).getPackageInfo(
-                eq(TEST_GO_APEX_PACKAGE_NAME), anyInt());
+                eq(TEST_TETHERING_PACKAGE_NAME), anyInt());
+        doReturn(mTetheringApexPackageInfo).when(mPm).getPackageInfo(
+                eq(TEST_GO_TETHERING_PACKAGE_NAME), anyInt());
         doReturn("0").when(() -> DeviceConfig.getProperty(
                 NAMESPACE_CONNECTIVITY, TEST_EXPERIMENT_FLAG));
         doReturn("0").when(() -> DeviceConfig.getProperty(
@@ -483,6 +499,31 @@
                 mContext, 889900000L + CONNECTIVITY_MODULE_ID));
     }
 
+
+    @Test
+    public void testIsFeatureSupported_resolvFeature() throws Exception {
+        assertTrue(DeviceConfigUtils.isFeatureSupported(
+                mContext, TEST_RESOLV_PACKAGE_VERSION + DNS_RESOLVER_MODULE_ID));
+        // Return false because feature requires a future version.
+        assertFalse(DeviceConfigUtils.isFeatureSupported(
+                mContext, 889900000L + DNS_RESOLVER_MODULE_ID));
+    }
+
+    @Test
+    public void testIsFeatureSupported_goResolvFeature() throws Exception {
+        doThrow(NameNotFoundException.class).when(mPm).getPackageInfo(eq(TEST_RESOLV_PACKAGE_NAME),
+                anyInt());
+        doReturn(mGoApexPackageInfo).when(mPm).getPackageInfo(eq(TEST_GO_RESOLV_PACKAGE_NAME),
+                anyInt());
+        assertFalse(DeviceConfigUtils.isFeatureSupported(
+                mContext, TEST_RESOLV_PACKAGE_VERSION + DNS_RESOLVER_MODULE_ID));
+        assertTrue(DeviceConfigUtils.isFeatureSupported(
+                mContext, TEST_GO_PACKAGE_VERSION + DNS_RESOLVER_MODULE_ID));
+        // Return false because feature requires a future version.
+        assertFalse(DeviceConfigUtils.isFeatureSupported(
+                mContext, 889900000L + DNS_RESOLVER_MODULE_ID));
+    }
+
     @Test
     public void testIsFeatureSupported_illegalModule() throws Exception {
         assertThrows(IllegalArgumentException.class,
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/LocationPermissionCheckerTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/LocationPermissionCheckerTest.java
index 84018a5..d773374 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/LocationPermissionCheckerTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/LocationPermissionCheckerTest.java
@@ -18,17 +18,17 @@
 import static android.Manifest.permission.NETWORK_SETTINGS;
 
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.nullable;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
 
 import android.Manifest;
 import android.app.AppOpsManager;
@@ -46,7 +46,6 @@
 
 import com.android.testutils.DevSdkIgnoreRule;
 
-import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -106,17 +105,18 @@
     }
 
     private void setupMocks() throws Exception {
-        when(mMockPkgMgr.getApplicationInfoAsUser(eq(TEST_PKG_NAME), eq(0), any()))
-                .thenReturn(mMockApplInfo);
-        when(mMockContext.getPackageManager()).thenReturn(mMockPkgMgr);
-        when(mMockAppOps.noteOp(AppOpsManager.OPSTR_WIFI_SCAN, mUid, TEST_PKG_NAME,
-                TEST_FEATURE_ID, null)).thenReturn(mWifiScanAllowApps);
-        when(mMockAppOps.noteOp(eq(AppOpsManager.OPSTR_COARSE_LOCATION), eq(mUid),
-                eq(TEST_PKG_NAME), eq(TEST_FEATURE_ID), nullable(String.class)))
-                .thenReturn(mAllowCoarseLocationApps);
-        when(mMockAppOps.noteOp(eq(AppOpsManager.OPSTR_FINE_LOCATION), eq(mUid),
-                eq(TEST_PKG_NAME), eq(TEST_FEATURE_ID), nullable(String.class)))
-                .thenReturn(mAllowFineLocationApps);
+        doReturn(mMockApplInfo).when(mMockPkgMgr)
+                .getApplicationInfoAsUser(eq(TEST_PKG_NAME), eq(0), any());
+        doReturn(mMockPkgMgr).when(mMockContext).getPackageManager();
+        doReturn(mWifiScanAllowApps).when(mMockAppOps).noteOp(
+                AppOpsManager.OPSTR_WIFI_SCAN, mUid, TEST_PKG_NAME,
+                TEST_FEATURE_ID, null);
+        doReturn(mAllowCoarseLocationApps).when(mMockAppOps).noteOp(
+                eq(AppOpsManager.OPSTR_COARSE_LOCATION), eq(mUid),
+                eq(TEST_PKG_NAME), eq(TEST_FEATURE_ID), nullable(String.class));
+        doReturn(mAllowFineLocationApps).when(mMockAppOps).noteOp(
+                eq(AppOpsManager.OPSTR_FINE_LOCATION), eq(mUid),
+                eq(TEST_PKG_NAME), eq(TEST_FEATURE_ID), nullable(String.class));
         if (mThrowSecurityException) {
             doThrow(new SecurityException("Package " + TEST_PKG_NAME + " doesn't belong"
                     + " to application bound to user " + mUid))
@@ -128,10 +128,10 @@
     }
 
     private <T> void mockSystemService(String name, Class<T> clazz, T service) {
-        when(mMockContext.getSystemService(name)).thenReturn(service);
-        when(mMockContext.getSystemServiceName(clazz)).thenReturn(name);
+        doReturn(service).when(mMockContext).getSystemService(name);
+        doReturn(name).when(mMockContext).getSystemServiceName(clazz);
         // Do not use mockito extended final method mocking
-        when(mMockContext.getSystemService(clazz)).thenCallRealMethod();
+        doCallRealMethod().when(mMockContext).getSystemService(clazz);
     }
 
     private void setupTestCase() throws Exception {
@@ -167,16 +167,17 @@
         Binder.restoreCallingIdentity((((long) mUid) << 32) | Binder.getCallingPid());
         doAnswer(mReturnPermission).when(mMockContext).checkPermission(
                 anyString(), anyInt(), anyInt());
-        when(mMockUserManager.isSameProfileGroup(UserHandle.SYSTEM,
-                UserHandle.getUserHandleForUid(MANAGED_PROFILE_UID)))
-                .thenReturn(true);
-        when(mMockContext.checkPermission(mManifestStringCoarse, -1, mUid))
-                .thenReturn(mCoarseLocationPermission);
-        when(mMockContext.checkPermission(mManifestStringFine, -1, mUid))
-                .thenReturn(mFineLocationPermission);
-        when(mMockContext.checkPermission(NETWORK_SETTINGS, -1, mUid))
-                .thenReturn(mNetworkSettingsPermission);
-        when(mLocationManager.isLocationEnabledForUser(any())).thenReturn(mIsLocationEnabled);
+        doReturn(true).when(mMockUserManager)
+                .isSameProfileGroup(UserHandle.SYSTEM,
+                UserHandle.getUserHandleForUid(MANAGED_PROFILE_UID));
+        doReturn(mCoarseLocationPermission).when(mMockContext)
+                .checkPermission(mManifestStringCoarse, -1, mUid);
+        doReturn(mFineLocationPermission).when(mMockContext)
+                .checkPermission(mManifestStringFine, -1, mUid);
+        doReturn(mNetworkSettingsPermission).when(mMockContext)
+                .checkPermission(NETWORK_SETTINGS, -1, mUid);
+        doReturn(mIsLocationEnabled).when(mLocationManager)
+                .isLocationEnabledForUser(any());
     }
 
     private Answer<Integer> createPermissionAnswer() {
@@ -208,7 +209,7 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.SUCCEEDED, result);
     }
@@ -225,7 +226,7 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.SUCCEEDED, result);
     }
@@ -239,9 +240,9 @@
         mWifiScanAllowApps = AppOpsManager.MODE_ALLOWED;
         setupTestCase();
 
-        assertThrows(SecurityException.class,
-                () -> mChecker.checkLocationPermissionWithDetailInfo(
-                        TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null));
+        final int result = mChecker.checkLocationPermissionInternal(
+                        TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
+        assertEquals(LocationPermissionChecker.ERROR_LOCATION_PERMISSION_MISSING, result);
     }
 
     @Test
@@ -251,7 +252,7 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.ERROR_LOCATION_PERMISSION_MISSING, result);
     }
@@ -267,7 +268,7 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.ERROR_LOCATION_PERMISSION_MISSING, result);
         verify(mMockAppOps, never()).noteOp(anyInt(), anyInt(), anyString());
@@ -284,7 +285,7 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.ERROR_LOCATION_MODE_OFF, result);
     }
@@ -298,18 +299,8 @@
         setupTestCase();
 
         final int result =
-                mChecker.checkLocationPermissionWithDetailInfo(
+                mChecker.checkLocationPermissionInternal(
                         TEST_PKG_NAME, TEST_FEATURE_ID, mUid, null);
         assertEquals(LocationPermissionChecker.SUCCEEDED, result);
     }
-
-
-    private static void assertThrows(Class<? extends Exception> exceptionClass, Runnable r) {
-        try {
-            r.run();
-            Assert.fail("Expected " + exceptionClass + " to be thrown.");
-        } catch (Exception exception) {
-            assertTrue(exceptionClass.isInstance(exception));
-        }
-    }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
index a39b7a3..0c2605f 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/StructTest.java
@@ -32,6 +32,7 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.Struct.Bool;
 import com.android.net.module.util.Struct.Field;
 import com.android.net.module.util.Struct.Type;
 
@@ -133,6 +134,29 @@
         verifyHeaderParsing(msg);
     }
 
+    @Test
+    public void testBoolStruct() {
+        assertEquals(1, Struct.getSize(Bool.class));
+
+        assertEquals(false, Struct.parse(Bool.class, toByteBuffer("00")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("01")).val);
+        // maybe these should throw instead, but currently only 0 is false...
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("02")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("7F")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("80")).val);
+        assertEquals(true,  Struct.parse(Bool.class, toByteBuffer("FF")).val);
+
+        final var f = new Bool(false);
+        final var t = new Bool(true);
+        assertEquals(f.val, false);
+        assertEquals(t.val, true);
+
+        assertArrayEquals(toByteBuffer("00").array(), f.writeToBytes(ByteOrder.BIG_ENDIAN));
+        assertArrayEquals(toByteBuffer("00").array(), f.writeToBytes(ByteOrder.LITTLE_ENDIAN));
+        assertArrayEquals(toByteBuffer("01").array(), t.writeToBytes(ByteOrder.BIG_ENDIAN));
+        assertArrayEquals(toByteBuffer("01").array(), t.writeToBytes(ByteOrder.LITTLE_ENDIAN));
+    }
+
     public static class HeaderMsgWithoutConstructor extends Struct {
         static int sType;
         static int sLength;
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
index 70f20d6..58e6622 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
@@ -65,10 +65,11 @@
 
     @Override
     public void insertEntry(K key, V value) throws ErrnoException,
-            IllegalArgumentException {
-        // The entry is created if and only if it doesn't exist. See BpfMap#insertEntry.
+            IllegalStateException {
+        // The entry is created if and only if it doesn't exist.
+        // And throws exception if it exists. See BpfMap#insertEntry.
         if (mMap.get(key) != null) {
-            throw new IllegalArgumentException(key + " already exist");
+            throw new IllegalStateException(key + " already exist");
         }
         mMap.put(key, value);
     }
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/AndroidTest.xml b/tests/cts/hostside/AndroidTest.xml
index ea6b078..03ea178 100644
--- a/tests/cts/hostside/AndroidTest.xml
+++ b/tests/cts/hostside/AndroidTest.xml
@@ -20,6 +20,9 @@
     <option name="config-descriptor:metadata" key="parameter" value="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="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex" />
+    <option name="config-descriptor:metadata" key="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk" />
+    <option name="config-descriptor:metadata" key="mainline-param" value="com.google.android.tethering.apex" />
 
     <target_preparer class="com.android.compatibility.common.tradefed.targetprep.LocationCheck" />
 
@@ -46,4 +49,21 @@
         <option name="directory-keys" value="/sdcard/CtsHostsideNetworkTests" />
         <option name="collect-on-run-ended-only" value="true" />
     </metrics_collector>
+
+<!-- When this test is run in a Mainline context (e.g. with `mts-tradefed`), only enable it if
+    one of the Mainline modules below is present on the device used for testing. -->
+<object type="module_controller" class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+    <!-- Tethering Module (internal version). -->
+    <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    <!-- Tethering Module (AOSP version). -->
+    <option name="mainline-module-package-name" value="com.android.tethering" />
+    <!-- NetworkStack Module (internal version). Should always be installed with CaptivePortalLogin. -->
+    <option name="mainline-module-package-name" value="com.google.android.networkstack" />
+    <!-- NetworkStack Module (AOSP version). Should always be installed with CaptivePortalLogin. -->
+    <option name="mainline-module-package-name" value="com.android.networkstack" />
+    <!-- Resolver Module (internal version). -->
+    <option name="mainline-module-package-name" value="com.google.android.resolv" />
+    <!-- Resolver Module (AOSP version). -->
+    <option name="mainline-module-package-name" value="com.android.resolv" />
+</object>
 </configuration>
diff --git a/tests/cts/hostside/aidl/Android.bp b/tests/cts/hostside/aidl/Android.bp
index 33761dc..31924f0 100644
--- a/tests/cts/hostside/aidl/Android.bp
+++ b/tests/cts/hostside/aidl/Android.bp
@@ -20,6 +20,7 @@
 java_test_helper_library {
     name: "CtsHostsideNetworkTestsAidl",
     sdk_version: "current",
+    min_sdk_version: "30",
     srcs: [
         "com/android/cts/net/hostside/*.aidl",
         "com/android/cts/net/hostside/*.java",
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 798cf98..2ca9adb 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -33,8 +33,8 @@
         "modules-utils-build",
     ],
     libs: [
-        "android.test.runner",
-        "android.test.base",
+        "android.test.runner.stubs",
+        "android.test.base.stubs",
     ],
     srcs: [
         "src/**/*.java",
@@ -44,7 +44,7 @@
         "general-tests",
         "sts",
     ],
-    min_sdk_version: "31",
+    min_sdk_version: "30",
 }
 
 android_test_helper_app {
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index e186c6b..d7631eb 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -50,7 +50,6 @@
 import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_NONE;
 import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_EXPORTED;
 import static com.android.testutils.Cleanup.testAndCleanup;
-import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 import static com.android.testutils.RecorderCallback.CallbackEntry.BLOCKED_STATUS_INT;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -213,6 +212,8 @@
 
     private static final String AUTOMATIC_ON_OFF_KEEPALIVE_VERSION =
                 "automatic_on_off_keepalive_version";
+    private static final String INGRESS_TO_VPN_ADDRESS_FILTERING =
+            "ingress_to_vpn_address_filtering";
     // Enabled since version 1 means it's always enabled because the version is always above 1
     private static final String AUTOMATIC_ON_OFF_KEEPALIVE_ENABLED = "1";
     private static final long TEST_TCP_POLLING_TIMER_EXPIRED_PERIOD_MS = 60_000L;
@@ -890,7 +891,7 @@
                 entry -> entry.getCaps().hasTransport(TRANSPORT_VPN));
     }
 
-    @Test @IgnoreUpTo(SC_V2) // TODO: Use to Build.VERSION_CODES.SC_V2 when available
+    @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testChangeUnderlyingNetworks() throws Exception {
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
@@ -995,6 +996,13 @@
                             FIREWALL_CHAIN_BACKGROUND));
             otherUidCallback.expectAvailableCallbacks(defaultNetwork, false /* suspended */,
                     true /* validated */, isOtherUidBlocked, TIMEOUT_MS);
+        } else {
+            // R does not have per-UID callback or system default callback APIs, and sends an
+            // additional CAP_CHANGED callback.
+            registerDefaultNetworkCallback(myUidCallback);
+            myUidCallback.expectAvailableCallbacks(defaultNetwork, false /* suspended */,
+                    true /* validated */, false /* blocked */, TIMEOUT_MS);
+            myUidCallback.expect(CallbackEntry.NETWORK_CAPS_UPDATED, defaultNetwork);
         }
 
         FileDescriptor fd = openSocketFdInOtherApp(TEST_HOST, 80, TIMEOUT_MS);
@@ -1136,12 +1144,12 @@
         return null;
     }
 
-    @Test
+    @Test @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)  // Automatic keepalives were added in U.
     public void testAutomaticOnOffKeepaliveModeNoClose() throws Exception {
         doTestAutomaticOnOffKeepaliveMode(false);
     }
 
-    @Test
+    @Test @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)  // Automatic keepalives were added in U.
     public void testAutomaticOnOffKeepaliveModeClose() throws Exception {
         doTestAutomaticOnOffKeepaliveMode(true);
     }
@@ -1707,7 +1715,8 @@
     }
 
     private void maybeExpectVpnTransportInfo(Network network) {
-        assumeTrue(SdkLevel.isAtLeastS());
+        // VpnTransportInfo was only added in S.
+        if (!SdkLevel.isAtLeastS()) return;
         final NetworkCapabilities vpnNc = mCM.getNetworkCapabilities(network);
         assertTrue(vpnNc.hasTransport(TRANSPORT_VPN));
         final TransportInfo ti = vpnNc.getTransportInfo();
@@ -1949,6 +1958,9 @@
      */
     private void doTestDropPacketToVpnAddress(final boolean duplicatedAddress)
             throws Exception {
+        assumeTrue(mCM.isConnectivityServiceFeatureEnabledForTesting(
+                INGRESS_TO_VPN_ADDRESS_FILTERING));
+
         final NetworkRequest request = new NetworkRequest.Builder()
                 .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
                 .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
diff --git a/tests/cts/hostside/app2/Android.bp b/tests/cts/hostside/app2/Android.bp
index 12ea23b..cb55c7b 100644
--- a/tests/cts/hostside/app2/Android.bp
+++ b/tests/cts/hostside/app2/Android.bp
@@ -36,4 +36,5 @@
         "sts",
     ],
     sdk_version: "test_current",
+    min_sdk_version: "30",
 }
diff --git a/tests/cts/hostside/networkslicingtestapp/Android.bp b/tests/cts/hostside/networkslicingtestapp/Android.bp
index c220000..79ad2e2 100644
--- a/tests/cts/hostside/networkslicingtestapp/Android.bp
+++ b/tests/cts/hostside/networkslicingtestapp/Android.bp
@@ -35,6 +35,7 @@
         "general-tests",
         "sts",
     ],
+    min_sdk_version: "30",
 }
 
 android_test_helper_app {
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/src/android/net/cts/DscpPolicyTest.kt b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
index f73134a..041e6cb 100644
--- a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
+++ b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
@@ -298,7 +298,8 @@
     fun sendPacket(
         agent: TestableNetworkAgent,
         sendV6: Boolean,
-        dstPort: Int = 0
+        dstPort: Int = 0,
+        times: Int = 1
     ) {
         val testString = "test string"
         val testPacket = ByteBuffer.wrap(testString.toByteArray(Charsets.UTF_8))
@@ -308,9 +309,11 @@
                 IPPROTO_UDP)
         checkNotNull(agent.network).bindSocket(socket)
 
-        val originalPacket = testPacket.readAsArray()
-        Os.sendto(socket, originalPacket, 0 /* bytesOffset */, originalPacket.size, 0 /* flags */,
+        val origPacket = testPacket.readAsArray()
+        repeat(times) {
+            Os.sendto(socket, origPacket, 0 /* bytesOffset */, origPacket.size, 0 /* flags */,
                 if (sendV6) TEST_TARGET_IPV6_ADDR else TEST_TARGET_IPV4_ADDR, dstPort)
+        }
         Os.close(socket)
     }
 
@@ -400,10 +403,11 @@
         agent: TestableNetworkAgent,
         sendV6: Boolean = false,
         dscpValue: Int = 0,
-        dstPort: Int = 0
+        dstPort: Int = 0,
+        times: Int = 1
     ) {
-        var packetFound = false
-        sendPacket(agent, sendV6, dstPort)
+        var packetFound = 0
+        sendPacket(agent, sendV6, dstPort, times)
         // TODO: grab source port from socket in sendPacket
 
         Log.e(TAG, "find DSCP value:" + dscpValue)
@@ -424,10 +428,23 @@
             if (parsePacketIp(buffer, sendV6) && parsePacketPort(buffer, 0, dstPort)) {
                 Log.e(TAG, "DSCP value found")
                 assertEquals(dscpValue, dscp)
-                packetFound = true
+                packetFound++
             }
         }
-        assertTrue(packetFound)
+        assertTrue(packetFound == times)
+    }
+
+    fun validatePackets(
+        agent: TestableNetworkAgent,
+        sendV6: Boolean = false,
+        dscpValue: Int = 0,
+        dstPort: Int = 0
+    ) {
+        // We send two packets from the same socket to verify
+        // socket caching works correctly.
+        validatePacket(agent, sendV6, dscpValue, dstPort, 2)
+        // Try one more time from a different socket.
+        validatePacket(agent, sendV6, dscpValue, dstPort, 1)
     }
 
     fun doRemovePolicyTest(
@@ -453,10 +470,7 @@
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
         }
-        validatePacket(agent, dscpValue = 1, dstPort = 4444)
-        // Send a second packet to validate that the stored BPF policy
-        // is correct for subsequent packets.
-        validatePacket(agent, dscpValue = 1, dstPort = 4444)
+        validatePackets(agent, dscpValue = 1, dstPort = 4444)
 
         agent.sendRemoveDscpPolicy(1)
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
@@ -475,7 +489,7 @@
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
         }
 
-        validatePacket(agent, dscpValue = 4, dstPort = 5555)
+        validatePackets(agent, dscpValue = 4, dstPort = 5555)
 
         agent.sendRemoveDscpPolicy(1)
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
@@ -494,10 +508,7 @@
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
         }
-        validatePacket(agent, true, dscpValue = 1, dstPort = 4444)
-        // Send a second packet to validate that the stored BPF policy
-        // is correct for subsequent packets.
-        validatePacket(agent, true, dscpValue = 1, dstPort = 4444)
+        validatePackets(agent, true, dscpValue = 1, dstPort = 4444)
 
         agent.sendRemoveDscpPolicy(1)
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
@@ -515,7 +526,7 @@
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
         }
-        validatePacket(agent, true, dscpValue = 4, dstPort = 5555)
+        validatePackets(agent, true, dscpValue = 4, dstPort = 5555)
 
         agent.sendRemoveDscpPolicy(1)
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
@@ -533,7 +544,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 1111)
+            validatePackets(agent, dscpValue = 1, dstPort = 1111)
         }
 
         val policy2 = DscpPolicy.Builder(2, 1).setDestinationPortRange(Range(2222, 2222)).build()
@@ -541,7 +552,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(2, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 2222)
+            validatePackets(agent, dscpValue = 1, dstPort = 2222)
         }
 
         val policy3 = DscpPolicy.Builder(3, 1).setDestinationPortRange(Range(3333, 3333)).build()
@@ -549,16 +560,16 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(3, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 3333)
+            validatePackets(agent, dscpValue = 1, dstPort = 3333)
         }
 
         /* Remove Policies and check CE is no longer set */
         doRemovePolicyTest(agent, callback, 1)
-        validatePacket(agent, dscpValue = 0, dstPort = 1111)
+        validatePackets(agent, dscpValue = 0, dstPort = 1111)
         doRemovePolicyTest(agent, callback, 2)
-        validatePacket(agent, dscpValue = 0, dstPort = 2222)
+        validatePackets(agent, dscpValue = 0, dstPort = 2222)
         doRemovePolicyTest(agent, callback, 3)
-        validatePacket(agent, dscpValue = 0, dstPort = 3333)
+        validatePackets(agent, dscpValue = 0, dstPort = 3333)
     }
 
     @Test
@@ -569,7 +580,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 1111)
+            validatePackets(agent, dscpValue = 1, dstPort = 1111)
         }
         doRemovePolicyTest(agent, callback, 1)
 
@@ -578,7 +589,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(2, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 2222)
+            validatePackets(agent, dscpValue = 1, dstPort = 2222)
         }
         doRemovePolicyTest(agent, callback, 2)
 
@@ -587,7 +598,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(3, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 3333)
+            validatePackets(agent, dscpValue = 1, dstPort = 3333)
         }
         doRemovePolicyTest(agent, callback, 3)
     }
@@ -601,7 +612,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 1111)
+            validatePackets(agent, dscpValue = 1, dstPort = 1111)
         }
 
         val policy2 = DscpPolicy.Builder(2, 1).setDestinationPortRange(Range(2222, 2222)).build()
@@ -609,7 +620,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(2, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 2222)
+            validatePackets(agent, dscpValue = 1, dstPort = 2222)
         }
 
         val policy3 = DscpPolicy.Builder(3, 1).setDestinationPortRange(Range(3333, 3333)).build()
@@ -617,7 +628,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(3, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 3333)
+            validatePackets(agent, dscpValue = 1, dstPort = 3333)
         }
 
         /* Remove Policies and check CE is no longer set */
@@ -643,7 +654,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 1111)
+            validatePackets(agent, dscpValue = 1, dstPort = 1111)
         }
 
         val policy2 = DscpPolicy.Builder(2, 1)
@@ -652,7 +663,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(2, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 2222)
+            validatePackets(agent, dscpValue = 1, dstPort = 2222)
         }
 
         val policy3 = DscpPolicy.Builder(3, 1)
@@ -661,24 +672,24 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(3, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 3333)
+            validatePackets(agent, dscpValue = 1, dstPort = 3333)
         }
 
         agent.sendRemoveAllDscpPolicies()
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_DELETED, it.status)
-            validatePacket(agent, false, dstPort = 1111)
+            validatePackets(agent, false, dstPort = 1111)
         }
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(2, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_DELETED, it.status)
-            validatePacket(agent, false, dstPort = 2222)
+            validatePackets(agent, false, dstPort = 2222)
         }
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(3, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_DELETED, it.status)
-            validatePacket(agent, false, dstPort = 3333)
+            validatePackets(agent, false, dstPort = 3333)
         }
     }
 
@@ -690,7 +701,7 @@
         agent.expectCallback<OnDscpPolicyStatusUpdated>().let {
             assertEquals(1, it.policyId)
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
-            validatePacket(agent, dscpValue = 1, dstPort = 4444)
+            validatePackets(agent, dscpValue = 1, dstPort = 4444)
         }
 
         val policy2 = DscpPolicy.Builder(1, 1).setDestinationPortRange(Range(5555, 5555)).build()
@@ -700,8 +711,8 @@
             assertEquals(DSCP_POLICY_STATUS_SUCCESS, it.status)
 
             // Sending packet with old policy should fail
-            validatePacket(agent, dscpValue = 0, dstPort = 4444)
-            validatePacket(agent, dscpValue = 1, dstPort = 5555)
+            validatePackets(agent, dscpValue = 0, dstPort = 4444)
+            validatePackets(agent, dscpValue = 1, dstPort = 5555)
         }
 
         agent.sendRemoveDscpPolicy(1)
diff --git a/tests/cts/net/src/android/net/cts/IpSecBaseTest.java b/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
index 2a6c638..c480135 100644
--- a/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecBaseTest.java
@@ -23,10 +23,13 @@
 import static android.net.IpSecAlgorithm.AUTH_HMAC_SHA384;
 import static android.net.IpSecAlgorithm.AUTH_HMAC_SHA512;
 import static android.net.IpSecAlgorithm.CRYPT_AES_CBC;
+import static android.net.cts.PacketUtils.ICMP_HDRLEN;
+import static android.net.cts.PacketUtils.IP6_HDRLEN;
 import static android.system.OsConstants.FIONREAD;
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
@@ -152,10 +155,17 @@
         final IpSecTransformState transformState =
                 futureIpSecTransform.get(SOCK_TIMEOUT, TimeUnit.MILLISECONDS);
 
-        assertEquals(txHighestSeqNum, transformState.getTxHighestSequenceNumber());
+        // There might be ICMPv6(Router Solicitation) packets. Thus we can only check the lower
+        // bound of the outgoing traffic.
+        final long icmpV6RsCnt = transformState.getTxHighestSequenceNumber() - txHighestSeqNum;
+        assertTrue(icmpV6RsCnt >= 0);
+
+        final long adjustedPacketCnt = packetCnt + icmpV6RsCnt;
+        final long adjustedByteCnt = byteCnt + icmpV6RsCnt * (IP6_HDRLEN + ICMP_HDRLEN);
+
+        assertEquals(adjustedPacketCnt, transformState.getPacketCount());
+        assertEquals(adjustedByteCnt, transformState.getByteCount());
         assertEquals(rxHighestSeqNum, transformState.getRxHighestSequenceNumber());
-        assertEquals(packetCnt, transformState.getPacketCount());
-        assertEquals(byteCnt, transformState.getByteCount());
         assertArrayEquals(replayBitmap, transformState.getReplayBitmap());
     }
 
diff --git a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
index 22a51d6..890c071 100644
--- a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
@@ -65,6 +65,7 @@
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 
@@ -119,7 +120,7 @@
 
     private static final int TIMEOUT_MS = 500;
 
-    private static final int PACKET_COUNT = 5000;
+    private static final int PACKET_COUNT = 100;
 
     // Static state to reduce setup/teardown
     private static ConnectivityManager sCM;
@@ -1088,6 +1089,27 @@
             UdpEncapsulationSocket encapSocket,
             IpSecTunnelTestRunnable test)
             throws Exception {
+        return buildTunnelNetworkAndRunTests(
+                localInner,
+                remoteInner,
+                localOuter,
+                remoteOuter,
+                spi,
+                encapSocket,
+                test,
+                true /* enableEncrypt */);
+    }
+
+    private int buildTunnelNetworkAndRunTests(
+            InetAddress localInner,
+            InetAddress remoteInner,
+            InetAddress localOuter,
+            InetAddress remoteOuter,
+            int spi,
+            UdpEncapsulationSocket encapSocket,
+            IpSecTunnelTestRunnable test,
+            boolean enableEncrypt)
+            throws Exception {
         int innerPrefixLen = localInner instanceof Inet6Address ? IP6_PREFIX_LEN : IP4_PREFIX_LEN;
         TestNetworkCallback testNetworkCb = null;
         int innerSocketPort;
@@ -1115,8 +1137,12 @@
 
             // Configure Transform parameters
             IpSecTransform.Builder transformBuilder = new IpSecTransform.Builder(sContext);
-            transformBuilder.setEncryption(
-                    new IpSecAlgorithm(IpSecAlgorithm.CRYPT_AES_CBC, CRYPT_KEY));
+
+            if (enableEncrypt) {
+                transformBuilder.setEncryption(
+                        new IpSecAlgorithm(IpSecAlgorithm.CRYPT_AES_CBC, CRYPT_KEY));
+            }
+
             transformBuilder.setAuthentication(
                     new IpSecAlgorithm(
                             IpSecAlgorithm.AUTH_HMAC_SHA256, AUTH_KEY, AUTH_KEY.length * 4));
@@ -1167,8 +1193,8 @@
         return innerSocketPort;
     }
 
-    private int buildTunnelNetworkAndRunTestsSimple(int spi, IpSecTunnelTestRunnable test)
-            throws Exception {
+    private int buildTunnelNetworkAndRunTestsSimple(
+            int spi, IpSecTunnelTestRunnable test, boolean enableEncrypt) throws Exception {
         return buildTunnelNetworkAndRunTests(
                 LOCAL_INNER_6,
                 REMOTE_INNER_6,
@@ -1176,7 +1202,8 @@
                 REMOTE_OUTER_6,
                 spi,
                 null /* encapSocket */,
-                test);
+                test,
+                enableEncrypt);
     }
 
     private static void receiveAndValidatePacket(JavaUdpSocket socket) throws Exception {
@@ -1787,10 +1814,11 @@
                             PACKET_COUNT,
                             PACKET_COUNT,
                             PACKET_COUNT * (long) innerPacketSize,
-                            newReplayBitmap(REPLAY_BITMAP_LEN_BYTE * 8));
+                            newReplayBitmap(PACKET_COUNT));
 
                     return innerSocketPort;
-                });
+                },
+                true /* enableEncrypt */);
     }
 
     @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -1814,17 +1842,22 @@
                     ipsecNetwork.bindSocket(outSocket.mSocket);
                     int innerSocketPort = outSocket.getPort();
 
-                    int expectedPacketSize =
-                            getPacketSize(
-                                    AF_INET6,
-                                    AF_INET6,
-                                    false /* useEncap */,
-                                    false /* transportInTunnelMode */);
+                    int outSeqNum = 1;
+                    int receivedTestDataEspCnt = 0;
 
-                    for (int i = 0; i < PACKET_COUNT; i++) {
+                    while (receivedTestDataEspCnt < PACKET_COUNT) {
                         outSocket.sendTo(TEST_DATA, REMOTE_INNER_6, innerSocketPort);
-                        tunUtils.awaitEspPacketNoPlaintext(
-                                spi, TEST_DATA, false /* useEncap */, expectedPacketSize);
+
+                        byte[] pkt = null;
+
+                        // If it is an ESP that contains the TEST_DATA, move to the next
+                        // loop. Otherwise, the ESP may contain an ICMPv6(Router Solicitation).
+                        // In this case, just increase the expected sequence number and continue
+                        // waiting for the ESP with TEST_DATA
+                        do {
+                            pkt = tunUtils.awaitEspPacket(spi, false /* useEncap */, outSeqNum++);
+                        } while (CollectionUtils.indexOfSubArray(pkt, TEST_DATA) == -1);
+                        receivedTestDataEspCnt++;
                     }
 
                     final int innerPacketSize =
@@ -1838,6 +1871,7 @@
                             newReplayBitmap(0));
 
                     return innerSocketPort;
-                });
+                },
+                false /* enableEncrypt */);
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/PacketUtils.java b/tests/cts/net/src/android/net/cts/PacketUtils.java
index 4d924d1..1588835 100644
--- a/tests/cts/net/src/android/net/cts/PacketUtils.java
+++ b/tests/cts/net/src/android/net/cts/PacketUtils.java
@@ -49,6 +49,7 @@
     static final int TCP_HDRLEN = 20;
     static final int TCP_HDRLEN_WITH_TIMESTAMP_OPT = TCP_HDRLEN + 12;
     static final int ESP_HDRLEN = 8;
+    static final int ICMP_HDRLEN = 8;
     static final int ESP_BLK_SIZE = 4; // ESP has to be 4-byte aligned
     static final int ESP_TRAILER_LEN = 2;
 
diff --git a/tests/cts/net/src/android/net/cts/TunUtils.java b/tests/cts/net/src/android/net/cts/TunUtils.java
index 268d8d2..8bf4998 100644
--- a/tests/cts/net/src/android/net/cts/TunUtils.java
+++ b/tests/cts/net/src/android/net/cts/TunUtils.java
@@ -47,6 +47,8 @@
     protected static final int IP4_PROTO_OFFSET = 9;
     protected static final int IP6_PROTO_OFFSET = 6;
 
+    private static final int SEQ_NUM_MATCH_NOT_REQUIRED = -1;
+
     private static final int DATA_BUFFER_LEN = 4096;
     private static final int TIMEOUT = 2000;
 
@@ -146,16 +148,30 @@
         return espPkt; // We've found the packet we're looking for.
     }
 
+    /** Await the expected ESP packet */
     public byte[] awaitEspPacket(int spi, boolean useEncap) throws Exception {
-        return awaitPacket((pkt) -> isEsp(pkt, spi, useEncap));
+        return awaitEspPacket(spi, useEncap, SEQ_NUM_MATCH_NOT_REQUIRED);
     }
 
-    private static boolean isSpiEqual(byte[] pkt, int espOffset, int spi) {
+    /** Await the expected ESP packet with a matching sequence number */
+    public byte[] awaitEspPacket(int spi, boolean useEncap, int seqNum) throws Exception {
+        return awaitPacket((pkt) -> isEsp(pkt, spi, seqNum, useEncap));
+    }
+
+    private static boolean isMatchingEspPacket(byte[] pkt, int espOffset, int spi, int seqNum) {
         ByteBuffer buffer = ByteBuffer.wrap(pkt);
         buffer.get(new byte[espOffset]); // Skip IP, UDP header
         int actualSpi = buffer.getInt();
+        int actualSeqNum = buffer.getInt();
 
-        return actualSpi == spi;
+        if (actualSeqNum < 0) {
+            throw new UnsupportedOperationException(
+                    "actualSeqNum overflowed and needs to be converted to an unsigned integer");
+        }
+
+        boolean isSeqNumMatched = (seqNum == SEQ_NUM_MATCH_NOT_REQUIRED || seqNum == actualSeqNum);
+
+        return actualSpi == spi && isSeqNumMatched;
     }
 
     /**
@@ -173,29 +189,32 @@
             fail("Banned plaintext packet found");
         }
 
-        return isEsp(pkt, spi, encap);
+        return isEsp(pkt, spi, SEQ_NUM_MATCH_NOT_REQUIRED, encap);
     }
 
-    private static boolean isEsp(byte[] pkt, int spi, boolean encap) {
+    private static boolean isEsp(byte[] pkt, int spi, int seqNum, boolean encap) {
         if (isIpv6(pkt)) {
             if (encap) {
                 return pkt[IP6_PROTO_OFFSET] == IPPROTO_UDP
-                        && isSpiEqual(pkt, IP6_HDRLEN + UDP_HDRLEN, spi);
+                        && isMatchingEspPacket(pkt, IP6_HDRLEN + UDP_HDRLEN, spi, seqNum);
             } else {
-                return pkt[IP6_PROTO_OFFSET] == IPPROTO_ESP && isSpiEqual(pkt, IP6_HDRLEN, spi);
+                return pkt[IP6_PROTO_OFFSET] == IPPROTO_ESP
+                        && isMatchingEspPacket(pkt, IP6_HDRLEN, spi, seqNum);
             }
 
         } else {
             // Use default IPv4 header length (assuming no options)
             if (encap) {
                 return pkt[IP4_PROTO_OFFSET] == IPPROTO_UDP
-                        && isSpiEqual(pkt, IP4_HDRLEN + UDP_HDRLEN, spi);
+                        && isMatchingEspPacket(pkt, IP4_HDRLEN + UDP_HDRLEN, spi, seqNum);
             } else {
-                return pkt[IP4_PROTO_OFFSET] == IPPROTO_ESP && isSpiEqual(pkt, IP4_HDRLEN, spi);
+                return pkt[IP4_PROTO_OFFSET] == IPPROTO_ESP
+                        && isMatchingEspPacket(pkt, IP4_HDRLEN, spi, seqNum);
             }
         }
     }
 
+
     public static boolean isIpv6(byte[] pkt) {
         // First nibble shows IP version. 0x60 for IPv6
         return (pkt[0] & (byte) 0xF0) == (byte) 0x60;
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/IpMemoryStoreTest.java b/tests/unit/java/android/net/IpMemoryStoreTest.java
index 0b82759..e8f91e6 100644
--- a/tests/unit/java/android/net/IpMemoryStoreTest.java
+++ b/tests/unit/java/android/net/IpMemoryStoreTest.java
@@ -16,6 +16,11 @@
 
 package android.net;
 
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ROAM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_CONFIRM;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_ORGANIC;
+import static android.net.IIpMemoryStore.NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED;
+
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
@@ -68,6 +73,14 @@
             -128, 0, 89, 112, 91, -34 };
     private static final NetworkAttributes TEST_NETWORK_ATTRIBUTES = buildTestNetworkAttributes(
             "hint", 219);
+    private static final long ONE_WEEK_IN_MS = 7 * 24 * 3600 * 1000;
+    private static final long ONE_DAY_IN_MS = 24 * 3600 * 1000;
+    private static final int[] NETWORK_EVENT_NUD_FAILURES = new int[] {
+        NETWORK_EVENT_NUD_FAILURE_ROAM,
+        NETWORK_EVENT_NUD_FAILURE_CONFIRM,
+        NETWORK_EVENT_NUD_FAILURE_ORGANIC,
+        NETWORK_EVENT_NUD_FAILURE_MAC_ADDRESS_CHANGED
+    };
 
     @Mock
     Context mMockContext;
@@ -333,4 +346,31 @@
         mStore.factoryReset();
         verify(mMockService, times(1)).factoryReset();
     }
+
+    @Test
+    public void testNetworkEvents() throws Exception {
+        startIpMemoryStore(true /* supplyService */);
+        final String cluster = "cluster";
+
+        final long now = System.currentTimeMillis();
+        final long expiry = now + ONE_WEEK_IN_MS;
+        mStore.storeNetworkEvent(cluster, now, expiry, NETWORK_EVENT_NUD_FAILURE_ROAM,
+                status -> assertTrue("Store not successful : " + status.resultCode,
+                        status.isSuccess()));
+        verify(mMockService, times(1)).storeNetworkEvent(eq(cluster),
+                eq(now), eq(expiry), eq(NETWORK_EVENT_NUD_FAILURE_ROAM), any());
+
+        final long[] sinceTimes = new long[2];
+        sinceTimes[0] = now - ONE_WEEK_IN_MS;
+        sinceTimes[1] = now - ONE_DAY_IN_MS;
+        mStore.retrieveNetworkEventCount(cluster, sinceTimes, NETWORK_EVENT_NUD_FAILURES,
+                (status, counts) -> {
+                    assertTrue("Retrieve network event counts not successful : "
+                            + status.resultCode, status.isSuccess());
+                    assertEquals(new int[0], counts);
+                });
+
+        verify(mMockService, times(1)).retrieveNetworkEventCount(eq(cluster), eq(sinceTimes),
+                eq(NETWORK_EVENT_NUD_FAILURES), any());
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
index b47b97d..fb3004a3 100644
--- a/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/DnsManagerTest.java
@@ -325,18 +325,9 @@
         assertEquals(new InetAddress[0], cfgStrict.ips);
     }
 
-    @Test
-    public void testSendDnsConfiguration() throws Exception {
+    private void doTestSendDnsConfiguration(PrivateDnsConfig cfg, DohParamsParcel expectedDohParams)
+            throws Exception {
         reset(mMockDnsResolver);
-        final PrivateDnsConfig cfg = new PrivateDnsConfig(
-                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
-                null /* hostname */,
-                null /* ips */,
-                "doh.com" /* dohName */,
-                null /* dohIps */,
-                "/some-path{?dns}" /* dohPath */,
-                5353 /* dohPort */);
-
         mDnsManager.updatePrivateDns(new Network(TEST_NETID), cfg);
         final LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(TEST_IFACENAME);
@@ -361,13 +352,60 @@
         expectedParams.transportTypes = TEST_TRANSPORT_TYPES;
         expectedParams.resolverOptions = null;
         expectedParams.meteredNetwork = true;
-        expectedParams.dohParams = new DohParamsParcel.Builder()
+        expectedParams.dohParams = expectedDohParams;
+        expectedParams.interfaceNames = new String[]{TEST_IFACENAME};
+        verify(mMockDnsResolver, times(1)).setResolverConfiguration(eq(expectedParams));
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrDisabled() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                false /* ddrEnabled */,
+                null /* dohName */,
+                null /* dohIps */,
+                null /* dohPath */,
+                -1 /* dohPort */);
+        doTestSendDnsConfiguration(cfg, null /* expectedDohParams */);
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrEnabledEmpty() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                true /* ddrEnabled */,
+                null /* dohName */,
+                null /* dohIps */,
+                null /* dohPath */,
+                -1 /* dohPort */);
+
+        final DohParamsParcel params = new DohParamsParcel.Builder().build();
+        doTestSendDnsConfiguration(cfg, params);
+    }
+
+    @Test
+    public void testSendDnsConfiguration_ddrEnabled() throws Exception {
+        final PrivateDnsConfig cfg = new PrivateDnsConfig(
+                PRIVATE_DNS_MODE_OPPORTUNISTIC /* mode */,
+                null /* hostname */,
+                null /* ips */,
+                true /* ddrEnabled */,
+                "doh.com" /* dohName */,
+                null /* dohIps */,
+                "/some-path{?dns}" /* dohPath */,
+                5353 /* dohPort */);
+
+        final DohParamsParcel params = new DohParamsParcel.Builder()
                 .setName("doh.com")
                 .setDohpath("/some-path{?dns}")
                 .setPort(5353)
                 .build();
-        expectedParams.interfaceNames = new String[]{TEST_IFACENAME};
-        verify(mMockDnsResolver, times(1)).setResolverConfiguration(eq(expectedParams));
+
+        doTestSendDnsConfiguration(cfg, params);
     }
 
     @Test
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 b5c0132..ec47618 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -19,6 +19,8 @@
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
@@ -32,10 +34,12 @@
 import android.net.Network;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.testing.TestableLooper;
 import android.text.TextUtils;
 import android.util.Pair;
 
 import com.android.net.module.util.SharedLog;
+import com.android.server.connectivity.mdns.MdnsDiscoveryManager.DiscoveryExecutor;
 import com.android.server.connectivity.mdns.MdnsSocketClientBase.SocketCreationCallback;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -55,7 +59,9 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
 
 /** Tests for {@link MdnsDiscoveryManager}. */
 @DevSdkIgnoreRunner.MonitorThreadLeak
@@ -390,6 +396,48 @@
         verify(mockServiceTypeClientType1NullNetwork).notifySocketDestroyed();
     }
 
+    @Test
+    public void testDiscoveryExecutor() throws Exception {
+        final TestableLooper testableLooper = new TestableLooper(thread.getLooper());
+        final DiscoveryExecutor executor = new DiscoveryExecutor(testableLooper.getLooper());
+        try {
+            // Verify the checkAndRunOnHandlerThread method
+            final CompletableFuture<Boolean> future1 = new CompletableFuture<>();
+            executor.checkAndRunOnHandlerThread(()-> future1.complete(true));
+            assertTrue(future1.isDone());
+            assertTrue(future1.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS));
+
+            // Verify the execute method
+            final CompletableFuture<Boolean> future2 = new CompletableFuture<>();
+            executor.execute(()-> future2.complete(true));
+            testableLooper.processAllMessages();
+            assertTrue(future2.isDone());
+            assertTrue(future2.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS));
+
+            // Verify the executeDelayed method
+            final CompletableFuture<Boolean> future3 = new CompletableFuture<>();
+            // Schedule a task with 999 ms delay
+            executor.executeDelayed(()-> future3.complete(true), 999L);
+            testableLooper.processAllMessages();
+            assertFalse(future3.isDone());
+
+            // 500 ms have elapsed but do not exceed the target time (999 ms)
+            // The function should not be executed.
+            testableLooper.moveTimeForward(500L);
+            testableLooper.processAllMessages();
+            assertFalse(future3.isDone());
+
+            // 500 ms have elapsed again and have exceeded the target time (999 ms).
+            // The function should be executed.
+            testableLooper.moveTimeForward(500L);
+            testableLooper.processAllMessages();
+            assertTrue(future3.isDone());
+            assertTrue(future3.get(500L, TimeUnit.MILLISECONDS));
+        } finally {
+            testableLooper.destroy();
+        }
+    }
+
     private MdnsPacket createMdnsPacket(String serviceType) {
         final String[] type = TextUtils.split(serviceType, "\\.");
         final ArrayList<String> name = new ArrayList<>(type.length + 1);
diff --git a/thread/apex/Android.bp b/thread/apex/Android.bp
index edf000a..838c0d9 100644
--- a/thread/apex/Android.bp
+++ b/thread/apex/Android.bp
@@ -23,8 +23,8 @@
 // See https://android.googlesource.com/platform/system/core/+/HEAD/init/README.md#versioned-rc-files-within-apexs
 // for details of versioned rc files.
 prebuilt_etc {
-    name: "ot-daemon.init.34rc",
+    name: "ot-daemon.34rc",
     src: "ot-daemon.34rc",
-    filename: "init.34rc",
+    filename: "ot-daemon.34rc",
     installable: false,
 }
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/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 551b98f..ecaefd0 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -26,6 +26,7 @@
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
 import android.annotation.Size;
+import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.os.Binder;
 import android.os.OutcomeReceiver;
@@ -102,11 +103,12 @@
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
-    /** Minimum value of max power in unit of 0.01dBm. @hide */
-    private static final int POWER_LIMITATION_MIN = -32768;
-
-    /** Maximum value of max power in unit of 0.01dBm. @hide */
-    private static final int POWER_LIMITATION_MAX = 32767;
+    /** The value of max power to disable the Thread channel. */
+    // This constant can never change. It has "max" in the name not because it indicates
+    // maximum power, but because it's passed to an API that sets the maximum power to
+    // disabled the Thread channel.
+    @SuppressLint("MinMaxConstant")
+    public static final int MAX_POWER_CHANNEL_DISABLED = Integer.MIN_VALUE;
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -704,6 +706,10 @@
     /**
      * 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>If not set, the default max power is set by the Thread HAL service or the Thread radio
      * chip firmware.
      *
@@ -712,22 +718,27 @@
      * OutcomeReceiver#onError} will be called with a specific error:
      *
      * <ul>
-     *   <li>{@link ThreadNetworkException#ERROR_UNSUPPORTED_OPERATION} the operation is no
-     *       supported by the platform.
+     *   <li>{@link ThreadNetworkException#ERROR_UNSUPPORTED_FEATURE} the feature is not supported
+     *       by the platform.
      * </ul>
      *
      * @param channelMaxPowers SparseIntArray (key: channel, value: max power) consists of channel
      *     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. Max
-     *     power values should be between INT16_MIN (-32768) and INT16_MAX (32767). If the max power
-     *     is set to INT16_MAX, the corresponding channel is not supported.
+     *     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.
      * @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,
      *     or invalid channel or max power is configured.
-     * @hide
      */
+    @FlaggedApi(Flags.FLAG_CHANNEL_MAX_POWERS_ENABLED)
     @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
     public final void setChannelMaxPowers(
             @NonNull @Size(min = 1) SparseIntArray channelMaxPowers,
@@ -756,19 +767,6 @@
                                 + ActiveOperationalDataset.CHANNEL_MAX_24_GHZ
                                 + "]");
             }
-
-            if ((maxPower < POWER_LIMITATION_MIN) || (maxPower > POWER_LIMITATION_MAX)) {
-                throw new IllegalArgumentException(
-                        "Channel power ({channel: "
-                                + channel
-                                + ", maxPower: "
-                                + maxPower
-                                + "}) exceeds allowed range ["
-                                + POWER_LIMITATION_MIN
-                                + ", "
-                                + POWER_LIMITATION_MAX
-                                + "]");
-            }
         }
 
         try {
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkException.java b/thread/framework/java/android/net/thread/ThreadNetworkException.java
index b6973f8..1ea2459 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkException.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkException.java
@@ -141,16 +141,14 @@
     public static final int ERROR_THREAD_DISABLED = 12;
 
     /**
-     * The operation failed because it is not supported by the platform. For example, some platforms
-     * may not support setting the target power of each channel. The caller should not retry and may
-     * return an error to the user.
-     *
-     * @hide
+     * The operation failed because the feature is not supported by the platform. For example, some
+     * platforms may not support setting the target power of each channel. The caller should not
+     * retry and may return an error to the user.
      */
-    public static final int ERROR_UNSUPPORTED_OPERATION = 13;
+    public static final int ERROR_UNSUPPORTED_FEATURE = 13;
 
     private static final int ERROR_MIN = ERROR_INTERNAL_ERROR;
-    private static final int ERROR_MAX = ERROR_UNSUPPORTED_OPERATION;
+    private static final int ERROR_MAX = ERROR_UNSUPPORTED_FEATURE;
 
     private final int mErrorCode;
 
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 1447ff8..8d89e13 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -31,10 +31,10 @@
 import android.os.Handler;
 import android.os.RemoteException;
 import android.text.TextUtils;
-import android.util.Log;
 import android.util.SparseArray;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.SharedLog;
 import com.android.server.thread.openthread.DnsTxtAttribute;
 import com.android.server.thread.openthread.INsdDiscoverServiceCallback;
 import com.android.server.thread.openthread.INsdPublisher;
@@ -62,6 +62,7 @@
  */
 public final class NsdPublisher extends INsdPublisher.Stub {
     private static final String TAG = NsdPublisher.class.getSimpleName();
+    private static final SharedLog LOG = ThreadNetworkLogger.forSubComponent(TAG);
 
     // TODO: b/321883491 - specify network for mDNS operations
     @Nullable private Network mNetwork;
@@ -158,8 +159,7 @@
             int listenerId,
             String registrationType) {
         checkOnHandlerThread();
-        Log.i(
-                TAG,
+        LOG.i(
                 "Registering "
                         + registrationType
                         + ". Listener ID: "
@@ -171,7 +171,7 @@
         try {
             mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, mExecutor, listener);
         } catch (IllegalArgumentException e) {
-            Log.i(TAG, "Failed to register service. serviceInfo: " + serviceInfo, e);
+            LOG.e("Failed to register service. serviceInfo: " + serviceInfo, e);
             listener.onRegistrationFailed(serviceInfo, NsdManager.FAILURE_INTERNAL_ERROR);
         }
     }
@@ -184,8 +184,7 @@
         checkOnHandlerThread();
         RegistrationListener registrationListener = mRegistrationListeners.get(listenerId);
         if (registrationListener == null) {
-            Log.w(
-                    TAG,
+            LOG.w(
                     "Failed to unregister service."
                             + " Listener ID: "
                             + listenerId
@@ -193,8 +192,7 @@
 
             return;
         }
-        Log.i(
-                TAG,
+        LOG.i(
                 "Unregistering service."
                         + " Listener ID: "
                         + listenerId
@@ -212,13 +210,7 @@
     private void discoverServiceInternal(
             String type, INsdDiscoverServiceCallback callback, int listenerId) {
         checkOnHandlerThread();
-        Log.i(
-                TAG,
-                "Discovering services."
-                        + " Listener ID: "
-                        + listenerId
-                        + ", service type: "
-                        + type);
+        LOG.i("Discovering services." + " Listener ID: " + listenerId + ", service type: " + type);
 
         DiscoveryListener listener = new DiscoveryListener(listenerId, type, callback);
         mDiscoveryListeners.append(listenerId, listener);
@@ -237,15 +229,14 @@
 
         DiscoveryListener listener = mDiscoveryListeners.get(listenerId);
         if (listener == null) {
-            Log.w(
-                    TAG,
+            LOG.w(
                     "Failed to stop service discovery. Listener ID "
                             + listenerId
                             + ". The listener is null.");
             return;
         }
 
-        Log.i(TAG, "Stopping service discovery. Listener: " + listener);
+        LOG.i("Stopping service discovery. Listener: " + listener);
         mNsdManager.stopServiceDiscovery(listener);
     }
 
@@ -263,8 +254,7 @@
         serviceInfo.setServiceName(name);
         serviceInfo.setServiceType(type);
         serviceInfo.setNetwork(null);
-        Log.i(
-                TAG,
+        LOG.i(
                 "Resolving service."
                         + " Listener ID: "
                         + listenerId
@@ -288,21 +278,19 @@
 
         ServiceInfoListener listener = mServiceInfoListeners.get(listenerId);
         if (listener == null) {
-            Log.w(
-                    TAG,
+            LOG.w(
                     "Failed to stop service resolution. Listener ID: "
                             + listenerId
                             + ". The listener is null.");
             return;
         }
 
-        Log.i(TAG, "Stopping service resolution. Listener: " + listener);
+        LOG.i("Stopping service resolution. Listener: " + listener);
 
         try {
             mNsdManager.unregisterServiceInfoCallback(listener);
         } catch (IllegalArgumentException e) {
-            Log.w(
-                    TAG,
+            LOG.w(
                     "Failed to stop the service resolution because it's already stopped. Listener: "
                             + listener);
         }
@@ -330,7 +318,7 @@
                 listener);
         mHostInfoListeners.append(listenerId, listener);
 
-        Log.i(TAG, "Resolving host." + " Listener ID: " + listenerId + ", hostname: " + name);
+        LOG.i("Resolving host." + " Listener ID: " + listenerId + ", hostname: " + name);
     }
 
     @Override
@@ -343,14 +331,13 @@
 
         HostInfoListener listener = mHostInfoListeners.get(listenerId);
         if (listener == null) {
-            Log.w(
-                    TAG,
+            LOG.w(
                     "Failed to stop host resolution. Listener ID: "
                             + listenerId
                             + ". The listener is null.");
             return;
         }
-        Log.i(TAG, "Stopping host resolution. Listener: " + listener);
+        LOG.i("Stopping host resolution. Listener: " + listener);
         listener.cancel();
         mHostInfoListeners.remove(listenerId);
     }
@@ -373,14 +360,14 @@
             try {
                 mNsdManager.unregisterService(mRegistrationListeners.valueAt(i));
             } catch (IllegalArgumentException e) {
-                Log.i(
-                        TAG,
+                LOG.i(
                         "Failed to unregister."
                                 + " Listener ID: "
                                 + mRegistrationListeners.keyAt(i)
                                 + " serviceInfo: "
-                                + mRegistrationListeners.valueAt(i).mServiceInfo,
-                        e);
+                                + mRegistrationListeners.valueAt(i).mServiceInfo
+                                + ", error: "
+                                + e.getMessage());
             }
         }
         mRegistrationListeners.clear();
@@ -415,8 +402,7 @@
         public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
             checkOnHandlerThread();
             mRegistrationListeners.remove(mListenerId);
-            Log.i(
-                    TAG,
+            LOG.i(
                     "Failed to register listener ID: "
                             + mListenerId
                             + " error code: "
@@ -434,8 +420,7 @@
         public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
             checkOnHandlerThread();
             for (INsdStatusReceiver receiver : mUnregistrationReceivers) {
-                Log.i(
-                        TAG,
+                LOG.i(
                         "Failed to unregister."
                                 + "Listener ID: "
                                 + mListenerId
@@ -454,8 +439,7 @@
         @Override
         public void onServiceRegistered(NsdServiceInfo serviceInfo) {
             checkOnHandlerThread();
-            Log.i(
-                    TAG,
+            LOG.i(
                     "Registered successfully. "
                             + "Listener ID: "
                             + mListenerId
@@ -472,8 +456,7 @@
         public void onServiceUnregistered(NsdServiceInfo serviceInfo) {
             checkOnHandlerThread();
             for (INsdStatusReceiver receiver : mUnregistrationReceivers) {
-                Log.i(
-                        TAG,
+                LOG.i(
                         "Unregistered successfully. "
                                 + "Listener ID: "
                                 + mListenerId
@@ -505,8 +488,7 @@
 
         @Override
         public void onStartDiscoveryFailed(String serviceType, int errorCode) {
-            Log.e(
-                    TAG,
+            LOG.e(
                     "Failed to start service discovery."
                             + " Error code: "
                             + errorCode
@@ -517,8 +499,7 @@
 
         @Override
         public void onStopDiscoveryFailed(String serviceType, int errorCode) {
-            Log.e(
-                    TAG,
+            LOG.e(
                     "Failed to stop service discovery."
                             + " Error code: "
                             + errorCode
@@ -529,18 +510,18 @@
 
         @Override
         public void onDiscoveryStarted(String serviceType) {
-            Log.i(TAG, "Started service discovery. Listener: " + this);
+            LOG.i("Started service discovery. Listener: " + this);
         }
 
         @Override
         public void onDiscoveryStopped(String serviceType) {
-            Log.i(TAG, "Stopped service discovery. Listener: " + this);
+            LOG.i("Stopped service discovery. Listener: " + this);
             mDiscoveryListeners.remove(mListenerId);
         }
 
         @Override
         public void onServiceFound(NsdServiceInfo serviceInfo) {
-            Log.i(TAG, "Found service: " + serviceInfo);
+            LOG.i("Found service: " + serviceInfo);
             try {
                 mDiscoverServiceCallback.onServiceDiscovered(
                         serviceInfo.getServiceName(), mType, true);
@@ -551,7 +532,7 @@
 
         @Override
         public void onServiceLost(NsdServiceInfo serviceInfo) {
-            Log.i(TAG, "Lost service: " + serviceInfo);
+            LOG.i("Lost service: " + serviceInfo);
             try {
                 mDiscoverServiceCallback.onServiceDiscovered(
                         serviceInfo.getServiceName(), mType, false);
@@ -584,8 +565,7 @@
 
         @Override
         public void onServiceInfoCallbackRegistrationFailed(int errorCode) {
-            Log.e(
-                    TAG,
+            LOG.e(
                     "Failed to register service info callback."
                             + " Listener ID: "
                             + mListenerId
@@ -599,8 +579,7 @@
 
         @Override
         public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {
-            Log.i(
-                    TAG,
+            LOG.i(
                     "Service is resolved. "
                             + " Listener ID: "
                             + mListenerId
@@ -640,7 +619,7 @@
 
         @Override
         public void onServiceInfoCallbackUnregistered() {
-            Log.i(TAG, "The service info callback is unregistered. Listener: " + this);
+            LOG.i("The service info callback is unregistered. Listener: " + this);
             mServiceInfoListeners.remove(mListenerId);
         }
 
@@ -671,8 +650,7 @@
         public void onAnswer(@NonNull List<InetAddress> answerList, int rcode) {
             checkOnHandlerThread();
 
-            Log.i(
-                    TAG,
+            LOG.i(
                     "Host is resolved."
                             + " Listener ID: "
                             + mListenerId
@@ -698,14 +676,14 @@
         public void onError(@NonNull DnsResolver.DnsException error) {
             checkOnHandlerThread();
 
-            Log.i(
-                    TAG,
+            LOG.i(
                     "Failed to resolve host."
                             + " Listener ID: "
                             + mListenerId
                             + ", hostname: "
-                            + mHostname,
-                    error);
+                            + mHostname
+                            + ", error: "
+                            + error.getMessage());
             try {
                 mResolveHostCallback.onHostResolved(mHostname, Collections.emptyList());
             } catch (RemoteException e) {
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index b621a6a..6edaae9 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -40,7 +40,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_TIMEOUT;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
-import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
+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;
 
@@ -112,23 +112,24 @@
 import android.os.RemoteException;
 import android.os.SystemClock;
 import android.os.UserManager;
-import android.util.Log;
 import android.util.SparseArray;
 
 import com.android.connectivity.resources.R;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.SharedLog;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
 import com.android.server.thread.openthread.BackboneRouterState;
-import com.android.server.thread.openthread.BorderRouterConfiguration;
 import com.android.server.thread.openthread.DnsTxtAttribute;
 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.IOtStatusReceiver;
+import com.android.server.thread.openthread.InfraLinkState;
 import com.android.server.thread.openthread.Ipv6AddressInfo;
 import com.android.server.thread.openthread.MeshcopTxtAttributes;
 import com.android.server.thread.openthread.OnMeshPrefixConfig;
+import com.android.server.thread.openthread.OtDaemonConfiguration;
 import com.android.server.thread.openthread.OtDaemonState;
 
 import libcore.util.HexEncoding;
@@ -159,7 +160,8 @@
  */
 @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 final class ThreadNetworkControllerService extends IThreadNetworkController.Stub {
-    private static final String TAG = "ThreadNetworkService";
+    private static final String TAG = "ControllerService";
+    private static final SharedLog LOG = ThreadNetworkLogger.forSubComponent(TAG);
 
     // The model name length in utf-8 bytes
     private static final int MAX_MODEL_NAME_UTF8_BYTES = 24;
@@ -213,7 +215,8 @@
     private boolean mUserRestricted;
     private boolean mForceStopOtDaemonEnabled;
 
-    private BorderRouterConfiguration mBorderRouterConfig;
+    private OtDaemonConfiguration mOtDaemonConfig;
+    private InfraLinkState mInfraLinkState;
 
     @VisibleForTesting
     ThreadNetworkControllerService(
@@ -238,11 +241,8 @@
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
         mNetworkToInterface = new HashMap<Network, String>();
-        mBorderRouterConfig =
-                new BorderRouterConfiguration.Builder()
-                        .setIsBorderRoutingEnabled(true)
-                        .setInfraInterfaceName(null)
-                        .build();
+        mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
+        mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
         mNsdPublisher = nsdPublisher;
         mUserManager = userManager;
@@ -304,12 +304,12 @@
             return;
         }
 
-        Log.i(TAG, "Starting OT daemon...");
+        LOG.i("Starting OT daemon...");
 
         try {
             getOtDaemon();
         } catch (RemoteException e) {
-            Log.e(TAG, "Failed to initialize ot-daemon", e);
+            LOG.e("Failed to initialize ot-daemon", e);
         } catch (ThreadNetworkException e) {
             // no ThreadNetworkException.ERROR_THREAD_DISABLED error should be thrown
             throw new AssertionError(e);
@@ -423,7 +423,7 @@
 
     private void onOtDaemonDied() {
         checkOnHandlerThread();
-        Log.w(TAG, "OT daemon is dead, clean up...");
+        LOG.w("OT daemon is dead, clean up...");
 
         OperationReceiverWrapper.onOtDaemonDied();
         mOtDaemonCallbackProxy.onOtDaemonDied();
@@ -436,8 +436,7 @@
     public void initialize() {
         mHandler.post(
                 () -> {
-                    Log.d(
-                            TAG,
+                    LOG.v(
                             "Initializing Thread system service: Thread is "
                                     + (shouldEnableThread() ? "enabled" : "disabled"));
                     try {
@@ -494,7 +493,7 @@
             // become dead, so that it's guaranteed that ot-daemon is stopped when {@code
             // receiver} is completed
         } catch (RemoteException e) {
-            Log.e(TAG, "otDaemon.terminate failed", e);
+            LOG.e("otDaemon.terminate failed", e);
             receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
         } catch (ThreadNetworkException e) {
             // No ThreadNetworkException.ERROR_THREAD_DISABLED error will be thrown
@@ -525,7 +524,7 @@
             return;
         }
 
-        Log.i(TAG, "Set Thread enabled: " + isEnabled + ", persist: " + persist);
+        LOG.i("Set Thread enabled: " + isEnabled + ", persist: " + persist);
 
         if (persist) {
             // The persistent setting keeps the desired enabled state, thus it's set regardless
@@ -537,7 +536,7 @@
         try {
             getOtDaemon().setThreadEnabled(isEnabled, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.setThreadEnabled failed", e);
+            LOG.e("otDaemon.setThreadEnabled failed", e);
             receiver.onError(e);
         }
     }
@@ -554,7 +553,7 @@
             @NonNull IOperationReceiver operationReceiver) {
         checkOnHandlerThread();
 
-        Log.i(TAG, "Set Thread configuration: " + configuration);
+        LOG.i("Set Thread configuration: " + configuration);
 
         final boolean changed = mPersistentSettings.putConfiguration(configuration);
         try {
@@ -571,6 +570,7 @@
                 }
             }
         }
+        // TODO: set the configuration at ot-daemon
     }
 
     @Override
@@ -631,8 +631,7 @@
         if (mUserRestricted == newUserRestrictedState) {
             return;
         }
-        Log.i(
-                TAG,
+        LOG.i(
                 "Thread user restriction changed: "
                         + mUserRestricted
                         + " -> "
@@ -644,16 +643,14 @@
                 new IOperationReceiver.Stub() {
                     @Override
                     public void onSuccess() {
-                        Log.d(
-                                TAG,
+                        LOG.v(
                                 (shouldEnableThread ? "Enabled" : "Disabled")
                                         + " Thread due to user restriction change");
                     }
 
                     @Override
                     public void onError(int errorCode, String errorMessage) {
-                        Log.e(
-                                TAG,
+                        LOG.e(
                                 "Failed to "
                                         + (shouldEnableThread ? "enable" : "disable")
                                         + " Thread for user restriction change");
@@ -702,13 +699,13 @@
         @Override
         public void onAvailable(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Upstream network available: " + network);
+            LOG.i("Upstream network available: " + network);
         }
 
         @Override
         public void onLost(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Upstream network lost: " + network);
+            LOG.i("Upstream network lost: " + network);
 
             // TODO: disable border routing when upsteam network disconnected
         }
@@ -723,7 +720,7 @@
             if (Objects.equals(existingIfName, newIfName)) {
                 return;
             }
-            Log.i(TAG, "Upstream network changed: " + existingIfName + " -> " + newIfName);
+            LOG.i("Upstream network changed: " + existingIfName + " -> " + newIfName);
             mNetworkToInterface.put(network, newIfName);
 
             // TODO: disable border routing if netIfName is null
@@ -737,13 +734,13 @@
         @Override
         public void onAvailable(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Thread network is available: " + network);
+            LOG.i("Thread network is available: " + network);
         }
 
         @Override
         public void onLost(@NonNull Network network) {
             checkOnHandlerThread();
-            Log.i(TAG, "Thread network is lost: " + network);
+            LOG.i("Thread network is lost: " + network);
             disableBorderRouting();
         }
 
@@ -751,8 +748,7 @@
         public void onLocalNetworkInfoChanged(
                 @NonNull Network network, @NonNull LocalNetworkInfo localNetworkInfo) {
             checkOnHandlerThread();
-            Log.i(
-                    TAG,
+            LOG.i(
                     "LocalNetworkInfo of Thread network changed: {threadNetwork: "
                             + network
                             + ", localNetworkInfo: "
@@ -810,7 +806,7 @@
         return new NetworkAgent(
                 mContext,
                 mHandler.getLooper(),
-                TAG,
+                LOG.getTag(),
                 netCaps,
                 mTunIfController.getLinkProperties(),
                 newLocalNetworkConfig(),
@@ -827,7 +823,7 @@
         mNetworkAgent = newNetworkAgent();
         mNetworkAgent.register();
         mNetworkAgent.markConnected();
-        Log.i(TAG, "Registered Thread network");
+        LOG.i("Registered Thread network");
     }
 
     private void unregisterThreadNetwork() {
@@ -837,7 +833,7 @@
             return;
         }
 
-        Log.d(TAG, "Unregistering Thread network agent");
+        LOG.v("Unregistering Thread network agent");
 
         mNetworkAgent.unregister();
         mNetworkAgent = null;
@@ -863,7 +859,7 @@
         try {
             getOtDaemon().getChannelMasks(newChannelMasksReceiver(networkName, receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.getChannelMasks failed", e);
+            LOG.e("otDaemon.getChannelMasks failed", e);
             receiver.onError(e);
         }
     }
@@ -904,7 +900,7 @@
             now = clock.instant();
             authoritative = true;
         } catch (DateTimeException e) {
-            Log.w(TAG, "Failed to get authoritative time", e);
+            LOG.w("Failed to get authoritative time: " + e.getMessage());
         }
 
         int panId = random.nextInt(/* bound= */ 0xffff);
@@ -1055,7 +1051,7 @@
             case OT_ERROR_BUSY:
                 return ERROR_BUSY;
             case OT_ERROR_NOT_IMPLEMENTED:
-                return ERROR_UNSUPPORTED_OPERATION;
+                return ERROR_UNSUPPORTED_FEATURE;
             case OT_ERROR_NO_BUFS:
                 return ERROR_RESOURCE_EXHAUSTED;
             case OT_ERROR_PARSE:
@@ -1095,7 +1091,7 @@
             // The otDaemon.join() will leave first if this device is currently attached
             getOtDaemon().join(activeDataset.toThreadTlvs(), newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.join failed", e);
+            LOG.e("otDaemon.join failed", e);
             receiver.onError(e);
         }
     }
@@ -1120,7 +1116,7 @@
                     .scheduleMigration(
                             pendingDataset.toThreadTlvs(), newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.scheduleMigration failed", e);
+            LOG.e("otDaemon.scheduleMigration failed", e);
             receiver.onError(e);
         }
     }
@@ -1138,7 +1134,7 @@
         try {
             getOtDaemon().leave(newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.leave failed", e);
+            LOG.e("otDaemon.leave failed", e);
             receiver.onError(e);
         }
     }
@@ -1171,7 +1167,7 @@
         try {
             getOtDaemon().setCountryCode(countryCode, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.setCountryCode failed", e);
+            LOG.e("otDaemon.setCountryCode failed", e);
             receiver.onError(e);
         }
     }
@@ -1181,7 +1177,7 @@
             @Nullable String testNetworkInterfaceName, @NonNull IOperationReceiver receiver) {
         enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED, NETWORK_SETTINGS);
 
-        Log.i(TAG, "setTestNetworkAsUpstream: " + testNetworkInterfaceName);
+        LOG.i("setTestNetworkAsUpstream: " + testNetworkInterfaceName);
         mHandler.post(() -> setTestNetworkAsUpstreamInternal(testNetworkInterfaceName, receiver));
     }
 
@@ -1227,79 +1223,70 @@
         try {
             getOtDaemon().setChannelMaxPowers(channelMaxPowers, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.e(TAG, "otDaemon.setChannelMaxPowers failed", e);
+            LOG.e("otDaemon.setChannelMaxPowers failed", e);
             receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
         }
     }
 
-    private void configureBorderRouter(BorderRouterConfiguration borderRouterConfig) {
-        if (mBorderRouterConfig.equals(borderRouterConfig)) {
+    private void setInfraLinkState(InfraLinkState infraLinkState) {
+        if (mInfraLinkState.equals(infraLinkState)) {
             return;
         }
-        Log.i(
-                TAG,
-                "Configuring Border Router: " + mBorderRouterConfig + " -> " + borderRouterConfig);
-        mBorderRouterConfig = borderRouterConfig;
+        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + infraLinkState);
+        mInfraLinkState = infraLinkState;
         ParcelFileDescriptor infraIcmp6Socket = null;
-        if (mBorderRouterConfig.infraInterfaceName != null) {
+        if (mInfraLinkState.interfaceName != null) {
             try {
                 infraIcmp6Socket =
-                        mInfraIfController.createIcmp6Socket(
-                                mBorderRouterConfig.infraInterfaceName);
+                        mInfraIfController.createIcmp6Socket(mInfraLinkState.interfaceName);
             } catch (IOException e) {
-                Log.i(TAG, "Failed to create ICMPv6 socket on infra network interface", e);
+                LOG.e("Failed to create ICMPv6 socket on infra network interface", e);
             }
         }
         try {
             getOtDaemon()
-                    .configureBorderRouter(
-                            mBorderRouterConfig,
+                    .setInfraLinkState(
+                            mInfraLinkState,
                             infraIcmp6Socket,
-                            new ConfigureBorderRouterStatusReceiver());
+                            new LoggingOtStatusReceiver("setInfraLinkState"));
         } catch (RemoteException | ThreadNetworkException e) {
-            Log.w(TAG, "Failed to configure border router " + mBorderRouterConfig, e);
+            LOG.e("Failed to configure border router " + mOtDaemonConfig, e);
         }
     }
 
     private void enableBorderRouting(String infraIfName) {
-        BorderRouterConfiguration borderRouterConfig =
-                newBorderRouterConfigBuilder(mBorderRouterConfig)
-                        .setIsBorderRoutingEnabled(true)
-                        .setInfraInterfaceName(infraIfName)
-                        .build();
-        Log.i(TAG, "Enable border routing on AIL: " + infraIfName);
-        configureBorderRouter(borderRouterConfig);
+        InfraLinkState infraLinkState =
+                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(infraIfName).build();
+        LOG.i("Enable border routing on AIL: " + infraIfName);
+        setInfraLinkState(infraLinkState);
     }
 
     private void disableBorderRouting() {
         mUpstreamNetwork = null;
-        BorderRouterConfiguration borderRouterConfig =
-                newBorderRouterConfigBuilder(mBorderRouterConfig)
-                        .setIsBorderRoutingEnabled(false)
-                        .setInfraInterfaceName(null)
-                        .build();
-        Log.i(TAG, "Disabling border routing");
-        configureBorderRouter(borderRouterConfig);
+        InfraLinkState infraLinkState =
+                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(null).build();
+        LOG.i("Disabling border routing");
+        setInfraLinkState(infraLinkState);
     }
 
     private void handleThreadInterfaceStateChanged(boolean isUp) {
         try {
             mTunIfController.setInterfaceUp(isUp);
-            Log.i(TAG, "Thread TUN interface becomes " + (isUp ? "up" : "down"));
+            LOG.i("Thread TUN interface becomes " + (isUp ? "up" : "down"));
         } catch (IOException e) {
-            Log.e(TAG, "Failed to handle Thread interface state changes", e);
+            LOG.e("Failed to handle Thread interface state changes", e);
         }
     }
 
     private void handleDeviceRoleChanged(@DeviceRole int deviceRole) {
         if (ThreadNetworkController.isAttached(deviceRole)) {
-            Log.i(TAG, "Attached to the Thread network");
+            LOG.i("Attached to the Thread network");
 
             // This is an idempotent method which can be called for multiple times when the device
             // is already attached (e.g. going from Child to Router)
             registerThreadNetwork();
         } else {
-            Log.i(TAG, "Detached from the Thread network");
+            LOG.i("Detached from the Thread network");
 
             // This is an idempotent method which can be called for multiple times when the device
             // is already detached or stopped
@@ -1337,7 +1324,7 @@
         }
         final LocalNetworkConfig localNetworkConfig = newLocalNetworkConfig();
         mNetworkAgent.sendLocalNetworkConfig(localNetworkConfig);
-        Log.d(TAG, "Sent localNetworkConfig: " + localNetworkConfig);
+        LOG.v("Sent localNetworkConfig: " + localNetworkConfig);
     }
 
     private void handleMulticastForwardingChanged(BackboneRouterState state) {
@@ -1380,11 +1367,13 @@
         return builder.build();
     }
 
-    private static BorderRouterConfiguration.Builder newBorderRouterConfigBuilder(
-            BorderRouterConfiguration brConfig) {
-        return new BorderRouterConfiguration.Builder()
-                .setIsBorderRoutingEnabled(brConfig.isBorderRoutingEnabled)
-                .setInfraInterfaceName(brConfig.infraInterfaceName);
+    private static OtDaemonConfiguration.Builder newOtDaemonConfigBuilder(
+            OtDaemonConfiguration config) {
+        return new OtDaemonConfiguration.Builder();
+    }
+
+    private static InfraLinkState.Builder newInfraLinkStateBuilder(InfraLinkState infraLinkState) {
+        return new InfraLinkState.Builder().setInterfaceName(infraLinkState.interfaceName);
     }
 
     private static final class CallbackMetadata {
@@ -1408,17 +1397,21 @@
         }
     }
 
-    private static final class ConfigureBorderRouterStatusReceiver extends IOtStatusReceiver.Stub {
-        public ConfigureBorderRouterStatusReceiver() {}
+    private static class LoggingOtStatusReceiver extends IOtStatusReceiver.Stub {
+        private final String mAction;
+
+        LoggingOtStatusReceiver(String action) {
+            mAction = action;
+        }
 
         @Override
         public void onSuccess() {
-            Log.i(TAG, "Configured border router successfully");
+            LOG.i("The action " + mAction + " succeeded");
         }
 
         @Override
         public void onError(int i, String s) {
-            Log.w(TAG, String.format("Failed to configure border router: %d %s", i, s));
+            LOG.w("The action " + mAction + " failed: " + i + " " + s);
         }
     }
 
@@ -1455,7 +1448,7 @@
             try {
                 getOtDaemon().registerStateCallback(this, callbackMetadata.id);
             } catch (RemoteException | ThreadNetworkException e) {
-                Log.e(TAG, "otDaemon.registerStateCallback failed", e);
+                LOG.e("otDaemon.registerStateCallback failed", e);
             }
         }
 
@@ -1487,7 +1480,7 @@
             try {
                 getOtDaemon().registerStateCallback(this, callbackMetadata.id);
             } catch (RemoteException | ThreadNetworkException e) {
-                Log.e(TAG, "otDaemon.registerStateCallback failed", e);
+                LOG.e("otDaemon.registerStateCallback failed", e);
             }
         }
 
@@ -1579,7 +1572,7 @@
                 mActiveDataset = newActiveDataset;
             } catch (IllegalArgumentException e) {
                 // Is unlikely that OT will generate invalid Operational Dataset
-                Log.wtf(TAG, "Invalid Active Operational Dataset from OpenThread", e);
+                LOG.wtf("Invalid Active Operational Dataset from OpenThread", e);
             }
 
             PendingOperationalDataset newPendingDataset;
@@ -1594,7 +1587,7 @@
                 mPendingDataset = newPendingDataset;
             } catch (IllegalArgumentException e) {
                 // Is unlikely that OT will generate invalid Operational Dataset
-                Log.wtf(TAG, "Invalid Pending Operational Dataset from OpenThread", e);
+                LOG.wtf("Invalid Pending Operational Dataset from OpenThread", e);
             }
         }
 
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
index a194114..2cd34e8 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
@@ -38,10 +38,10 @@
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
 import android.util.ArrayMap;
-import android.util.Log;
 
 import com.android.connectivity.resources.R;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.SharedLog;
 import com.android.server.connectivity.ConnectivityResources;
 
 import java.io.FileDescriptor;
@@ -63,7 +63,9 @@
  */
 @TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 public class ThreadNetworkCountryCode {
-    private static final String TAG = "ThreadNetworkCountryCode";
+    private static final String TAG = "CountryCode";
+    private static final SharedLog LOG = ThreadNetworkLogger.forSubComponent(TAG);
+
     // To be used when there is no country code available.
     @VisibleForTesting public static final String DEFAULT_COUNTRY_CODE = "WW";
 
@@ -280,11 +282,11 @@
             String countryCode = addresses.get(0).getCountryCode();
 
             if (isValidCountryCode(countryCode)) {
-                Log.d(TAG, "Set location country code to: " + countryCode);
+                LOG.v("Set location country code to: " + countryCode);
                 mLocationCountryCodeInfo =
                         new CountryCodeInfo(countryCode, COUNTRY_CODE_SOURCE_LOCATION);
             } else {
-                Log.d(TAG, "Received invalid location country code");
+                LOG.v("Received invalid location country code");
                 mLocationCountryCodeInfo = null;
             }
 
@@ -296,8 +298,7 @@
         if ((location == null) || (mGeocoder == null)) return;
 
         if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
-            Log.wtf(
-                    TAG,
+            LOG.wtf(
                     "Unexpected call to set country code from the Geocoding location, "
                             + "Thread code never runs under T or lower.");
             return;
@@ -320,13 +321,13 @@
     private class WifiCountryCodeCallback implements ActiveCountryCodeChangedCallback {
         @Override
         public void onActiveCountryCodeChanged(String countryCode) {
-            Log.d(TAG, "Wifi country code is changed to " + countryCode);
+            LOG.v("Wifi country code is changed to " + countryCode);
             synchronized ("ThreadNetworkCountryCode.this") {
                 if (isValidCountryCode(countryCode)) {
                     mWifiCountryCodeInfo =
                             new CountryCodeInfo(countryCode, COUNTRY_CODE_SOURCE_WIFI);
                 } else {
-                    Log.w(TAG, "WiFi country code " + countryCode + " is invalid");
+                    LOG.w("WiFi country code " + countryCode + " is invalid");
                     mWifiCountryCodeInfo = null;
                 }
 
@@ -336,7 +337,7 @@
 
         @Override
         public void onCountryCodeInactive() {
-            Log.d(TAG, "Wifi country code is inactived");
+            LOG.v("Wifi country code is inactived");
             synchronized ("ThreadNetworkCountryCode.this") {
                 mWifiCountryCodeInfo = null;
                 updateCountryCode(false /* forceUpdate */);
@@ -346,8 +347,7 @@
 
     private synchronized void registerTelephonyCountryCodeCallback() {
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
-            Log.wtf(
-                    TAG,
+            LOG.wtf(
                     "Unexpected call to register the telephony country code changed callback, "
                             + "Thread code never runs under T or lower.");
             return;
@@ -387,7 +387,7 @@
                 mSubscriptionManager.getActiveSubscriptionInfoList();
 
         if (subscriptionInfoList == null) {
-            Log.d(TAG, "No SIM card is found");
+            LOG.v("No SIM card is found");
             return;
         }
 
@@ -399,11 +399,11 @@
             try {
                 countryCode = mTelephonyManager.getNetworkCountryIso(slotIndex);
             } catch (IllegalArgumentException e) {
-                Log.e(TAG, "Failed to get country code for slot index:" + slotIndex, e);
+                LOG.e("Failed to get country code for slot index:" + slotIndex, e);
                 continue;
             }
 
-            Log.d(TAG, "Telephony slot " + slotIndex + " country code is " + countryCode);
+            LOG.v("Telephony slot " + slotIndex + " country code is " + countryCode);
             setTelephonyCountryCodeAndLastKnownCountryCode(
                     slotIndex, countryCode, null /* lastKnownCountryCode */);
         }
@@ -411,8 +411,7 @@
 
     private synchronized void setTelephonyCountryCodeAndLastKnownCountryCode(
             int slotIndex, String countryCode, String lastKnownCountryCode) {
-        Log.d(
-                TAG,
+        LOG.v(
                 "Set telephony country code to: "
                         + countryCode
                         + ", last country code to: "
@@ -522,8 +521,7 @@
 
             @Override
             public void onError(int otError, String message) {
-                Log.e(
-                        TAG,
+                LOG.e(
                         "Error "
                                 + otError
                                 + ": "
@@ -545,11 +543,11 @@
         CountryCodeInfo countryCodeInfo = pickCountryCode();
 
         if (!forceUpdate && countryCodeInfo.isCountryCodeMatch(mCurrentCountryCodeInfo)) {
-            Log.i(TAG, "Ignoring already set country code " + countryCodeInfo.getCountryCode());
+            LOG.i("Ignoring already set country code " + countryCodeInfo.getCountryCode());
             return;
         }
 
-        Log.i(TAG, "Set country code: " + countryCodeInfo);
+        LOG.i("Set country code: " + countryCodeInfo);
         mThreadNetworkControllerService.setCountryCode(
                 countryCodeInfo.getCountryCode().toUpperCase(Locale.ROOT),
                 newOperationReceiver(countryCodeInfo));
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkLogger.java b/thread/service/java/com/android/server/thread/ThreadNetworkLogger.java
new file mode 100644
index 0000000..a765304
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkLogger.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.server.thread;
+
+import com.android.net.module.util.SharedLog;
+
+/**
+ * The Logger for Thread network.
+ *
+ * <p>Each class should log with its own tag using the logger of
+ * ThreadNetworkLogger.forSubComponent(TAG).
+ */
+public final class ThreadNetworkLogger {
+    private static final String TAG = "ThreadNetwork";
+    private static final SharedLog mLog = new SharedLog(TAG);
+
+    public static SharedLog forSubComponent(String subComponent) {
+        return mLog.forSubComponent(subComponent);
+    }
+
+    // Disable instantiation
+    private ThreadNetworkLogger() {}
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index 7c4c72d..fc18ef9 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -25,11 +25,11 @@
 import android.net.thread.ThreadConfiguration;
 import android.os.PersistableBundle;
 import android.util.AtomicFile;
-import android.util.Log;
 
 import com.android.connectivity.resources.R;
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.SharedLog;
 import com.android.server.connectivity.ConnectivityResources;
 
 import java.io.ByteArrayInputStream;
@@ -48,6 +48,7 @@
  */
 public class ThreadPersistentSettings {
     private static final String TAG = "ThreadPersistentSettings";
+    private static final SharedLog LOG = ThreadNetworkLogger.forSubComponent(TAG);
 
     /** File name used for storing settings. */
     private static final String FILE_NAME = "ThreadPersistentSettings.xml";
@@ -115,7 +116,7 @@
         readFromStoreFile();
         synchronized (mLock) {
             if (!mSettings.containsKey(THREAD_ENABLED.key)) {
-                Log.i(TAG, "\"thread_enabled\" is missing in settings file, using default value");
+                LOG.i("\"thread_enabled\" is missing in settings file, using default value");
                 put(
                         THREAD_ENABLED.key,
                         mResources.get().getBoolean(R.bool.config_thread_default_enabled));
@@ -243,7 +244,7 @@
                 writeToAtomicFile(mAtomicFile, outputStream.toByteArray());
             }
         } catch (IOException e) {
-            Log.wtf(TAG, "Write to store file failed", e);
+            LOG.wtf("Write to store file failed", e);
         }
     }
 
@@ -251,7 +252,7 @@
         try {
             final byte[] readData;
             synchronized (mLock) {
-                Log.i(TAG, "Reading from store file: " + mAtomicFile.getBaseFile());
+                LOG.i("Reading from store file: " + mAtomicFile.getBaseFile());
                 readData = readFromAtomicFile(mAtomicFile);
             }
             final ByteArrayInputStream inputStream = new ByteArrayInputStream(readData);
@@ -262,9 +263,9 @@
                 mSettings.putAll(bundleRead);
             }
         } catch (FileNotFoundException e) {
-            Log.w(TAG, "No store file to read", e);
+            LOG.w("No store file to read " + e.getMessage());
         } catch (IOException e) {
-            Log.e(TAG, "Read from store file failed", e);
+            LOG.e("Read from store file failed", e);
         }
     }
 
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index 976f93d..85a0371 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -38,10 +38,10 @@
 import android.os.SystemClock;
 import android.system.ErrnoException;
 import android.system.Os;
-import android.util.Log;
 
 import com.android.net.module.util.HexDump;
 import com.android.net.module.util.LinkPropertiesUtils.CompareResult;
+import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.netlink.NetlinkUtils;
 import com.android.net.module.util.netlink.StructIfinfoMsg;
 import com.android.net.module.util.netlink.StructNlAttr;
@@ -66,6 +66,7 @@
 public class TunInterfaceController {
     private static final String TAG = "TunIfController";
     private static final boolean DBG = false;
+    private static final SharedLog LOG = ThreadNetworkLogger.forSubComponent(TAG);
     private static final long INFINITE_LIFETIME = 0xffffffffL;
     static final int MTU = 1280;
 
@@ -147,7 +148,7 @@
 
     /** Adds a new address to the interface. */
     public void addAddress(LinkAddress address) {
-        Log.d(TAG, "Adding address " + address + " with flags: " + address.getFlags());
+        LOG.v("Adding address " + address + " with flags: " + address.getFlags());
 
         long preferredLifetimeSeconds;
         long validLifetimeSeconds;
@@ -180,7 +181,7 @@
                 (byte) address.getScope(),
                 preferredLifetimeSeconds,
                 validLifetimeSeconds)) {
-            Log.w(TAG, "Failed to add address " + address.getAddress().getHostAddress());
+            LOG.w("Failed to add address " + address.getAddress().getHostAddress());
             return;
         }
         mLinkProperties.addLinkAddress(address);
@@ -189,7 +190,7 @@
 
     /** Removes an address from the interface. */
     public void removeAddress(LinkAddress address) {
-        Log.d(TAG, "Removing address " + address);
+        LOG.v("Removing address " + address);
 
         // Intentionally update the mLinkProperties before send netlink message because the
         // address is already removed from ot-daemon and apps can't reach to the address even
@@ -200,7 +201,7 @@
                 Os.if_nametoindex(mIfName),
                 (Inet6Address) address.getAddress(),
                 (short) address.getPrefixLength())) {
-            Log.w(TAG, "Failed to remove address " + address.getAddress().getHostAddress());
+            LOG.w("Failed to remove address " + address.getAddress().getHostAddress());
         }
     }
 
@@ -287,7 +288,7 @@
         try {
             setInterfaceUp(false);
         } catch (IOException e) {
-            Log.e(TAG, "Failed to set Thread TUN interface down");
+            LOG.e("Failed to set Thread TUN interface down");
         }
     }
 
@@ -347,11 +348,15 @@
             if (e.getCause() instanceof ErrnoException) {
                 ErrnoException ee = (ErrnoException) e.getCause();
                 if (ee.errno == EADDRINUSE) {
-                    Log.w(TAG, "Already joined group" + address.getHostAddress(), e);
+                    LOG.w(
+                            "Already joined group "
+                                    + address.getHostAddress()
+                                    + ": "
+                                    + e.getMessage());
                     return;
                 }
             }
-            Log.e(TAG, "failed to join group " + address.getHostAddress(), e);
+            LOG.e("failed to join group " + address.getHostAddress(), e);
         }
     }
 
@@ -360,7 +365,7 @@
         try {
             mMulticastSocket.leaveGroup(socketAddress, mNetworkInterface);
         } catch (IOException e) {
-            Log.e(TAG, "failed to leave group " + address.getHostAddress(), e);
+            LOG.e("failed to leave group " + address.getHostAddress(), e);
         }
     }
 
@@ -415,14 +420,14 @@
         }
 
         if (DBG) {
-            Log.d(TAG, "ADDR_GEN_MODE message is:");
-            Log.d(TAG, HexDump.dumpHexString(msg));
+            LOG.v("ADDR_GEN_MODE message is:");
+            LOG.v(HexDump.dumpHexString(msg));
         }
 
         try {
             NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, msg);
         } catch (ErrnoException e) {
-            Log.e(TAG, "Failed to set ADDR_GEN_MODE to NONE", e);
+            LOG.e("Failed to set ADDR_GEN_MODE to NONE", e);
         }
     }
 }
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 6db7c9c..2630d21 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -40,6 +40,7 @@
     static_libs: [
         "androidx.test.ext.junit",
         "compatibility-device-util-axt",
+        "com.android.net.thread.flags-aconfig-java",
         "ctstestrunner-axt",
         "guava",
         "guava-android-testlib",
@@ -48,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/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 6eda1e9..34aabe2 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -22,6 +22,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" />
 
     <!--
         Only run tests if the device under test is SDK version 33 (Android 13) or above.
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 1a101b6..c048394 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -35,6 +35,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 
 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
@@ -70,12 +71,15 @@
 import android.os.Build;
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.util.SparseIntArray;
 
 import androidx.annotation.NonNull;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
 
 import com.android.net.module.util.ArrayTrackRecord;
+import com.android.net.thread.flags.Flags;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
 import org.junit.After;
@@ -116,11 +120,20 @@
     private static final int SET_CONFIGURATION_TIMEOUT_MILLIS = 1_000;
     private static final int SERVICE_DISCOVERY_TIMEOUT_MILLIS = 30_000;
     private static final int SERVICE_LOST_TIMEOUT_MILLIS = 20_000;
+    private static final int VALID_POWER = 32_767;
+    private static final int VALID_CHANNEL = 20;
+    private static final int INVALID_CHANNEL = 10;
     private static final String MESHCOP_SERVICE_TYPE = "_meshcop._udp";
     private static final String THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
     private static final ThreadConfiguration DEFAULT_CONFIG =
             new ThreadConfiguration.Builder().build();
+    private static final SparseIntArray CHANNEL_MAX_POWERS =
+            new SparseIntArray() {
+                {
+                    put(VALID_CHANNEL, VALID_POWER);
+                }
+            };
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
@@ -1460,4 +1473,52 @@
         @Override
         public void onServiceInfoCallbackUnregistered() {}
     }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_CHANNEL_MAX_POWERS_ENABLED})
+    public void setChannelMaxPowers_withPrivilegedPermission_success() throws Exception {
+        CompletableFuture<Void> powerFuture = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.setChannelMaxPowers(
+                                CHANNEL_MAX_POWERS, mExecutor, newOutcomeReceiver(powerFuture)));
+
+        try {
+            assertThat(powerFuture.get()).isNull();
+        } catch (ExecutionException exception) {
+            ThreadNetworkException thrown = (ThreadNetworkException) exception.getCause();
+            assertThat(thrown.getErrorCode()).isEqualTo(ERROR_UNSUPPORTED_FEATURE);
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_CHANNEL_MAX_POWERS_ENABLED})
+    public void setChannelMaxPowers_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () -> mController.setChannelMaxPowers(CHANNEL_MAX_POWERS, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_CHANNEL_MAX_POWERS_ENABLED})
+    public void setChannelMaxPowers_invalidChannel_throwsIllegalArgumentException() {
+        final SparseIntArray INVALID_CHANNEL_ARRAY =
+                new SparseIntArray() {
+                    {
+                        put(INVALID_CHANNEL, VALID_POWER);
+                    }
+                };
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.setChannelMaxPowers(new SparseIntArray(), mExecutor, v -> {}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.setChannelMaxPowers(INVALID_CHANNEL_ARRAY, mExecutor, v -> {}));
+    }
 }
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 71693af..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",
     ],
 }
 
@@ -58,6 +58,7 @@
     ],
     srcs: [
         "src/**/*.java",
+        "src/**/*.kt",
     ],
     compile_multilib: "both",
 }
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 9e8dc3a..103282a 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -34,7 +34,6 @@
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
-import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.truth.Truth.assertThat;
@@ -49,11 +48,9 @@
 import android.content.Context;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
-import android.net.LinkProperties;
-import android.net.MacAddress;
-import android.net.RouteInfo;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
+import android.net.thread.utils.IntegrationTestUtils;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
@@ -634,32 +631,16 @@
     }
 
     private void setUpInfraNetwork() throws Exception {
-        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,
-                        RouteInfo.RTN_UNICAST,
-                        1500 /* mtu */));
-        mInfraNetworkTracker =
-                runAsShell(
-                        MANAGE_TEST_NETWORKS,
-                        () -> initTestNetwork(mContext, lp, 5000 /* timeoutMs */));
-        String infraNetworkName = mInfraNetworkTracker.getTestIface().getInterfaceName();
-        mController.setTestNetworkAsUpstreamAndWait(infraNetworkName);
+        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController);
     }
 
     private void tearDownInfraNetwork() {
-        runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
+        IntegrationTestUtils.tearDownInfraNetwork(mInfraNetworkTracker);
     }
 
-    private void startInfraDeviceAndWaitForOnLinkAddr() throws Exception {
+    private void startInfraDeviceAndWaitForOnLinkAddr() {
         mInfraDevice =
-                new InfraNetworkDevice(MacAddress.fromString("1:2:3:4:5:6"), mInfraNetworkReader);
-        mInfraDevice.runSlaac(Duration.ofSeconds(60));
-        assertNotNull(mInfraDevice.ipv6Addr);
+                IntegrationTestUtils.startInfraDeviceAndWaitForOnLinkAddr(mInfraNetworkReader);
     }
 
     private void assertInfraLinkMemberOfGroup(Inet6Address address) throws Exception {
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java
deleted file mode 100644
index ba04348..0000000
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkControllerTest.java
+++ /dev/null
@@ -1,171 +0,0 @@
-/*
- * 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.thread;
-
-import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
-
-import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
-
-import static com.android.testutils.TestPermissionUtil.runAsShell;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import static org.junit.Assert.assertThrows;
-
-import android.content.Context;
-import android.net.thread.utils.ThreadFeatureCheckerRule;
-import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
-import android.os.OutcomeReceiver;
-import android.util.SparseIntArray;
-
-import androidx.test.core.app.ApplicationProvider;
-import androidx.test.filters.LargeTest;
-import androidx.test.runner.AndroidJUnit4;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-/** Tests for hide methods of {@link ThreadNetworkController}. */
-@LargeTest
-@RequiresThreadFeature
-@RunWith(AndroidJUnit4.class)
-public class ThreadNetworkControllerTest {
-    private static final int VALID_POWER = 32_767;
-    private static final int INVALID_POWER = 32_768;
-    private static final int VALID_CHANNEL = 20;
-    private static final int INVALID_CHANNEL = 10;
-    private static final String THREAD_NETWORK_PRIVILEGED =
-            "android.permission.THREAD_NETWORK_PRIVILEGED";
-
-    private static final SparseIntArray CHANNEL_MAX_POWERS =
-            new SparseIntArray() {
-                {
-                    put(20, 32767);
-                }
-            };
-
-    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
-
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ExecutorService mExecutor;
-    private ThreadNetworkController mController;
-
-    @Before
-    public void setUp() throws Exception {
-        mController =
-                mContext.getSystemService(ThreadNetworkManager.class)
-                        .getAllThreadNetworkControllers()
-                        .get(0);
-
-        mExecutor = Executors.newSingleThreadExecutor();
-    }
-
-    @After
-    public void tearDown() throws Exception {
-        dropAllPermissions();
-    }
-
-    @Test
-    public void setChannelMaxPowers_withPrivilegedPermission_success() throws Exception {
-        CompletableFuture<Void> powerFuture = new CompletableFuture<>();
-
-        runAsShell(
-                THREAD_NETWORK_PRIVILEGED,
-                () ->
-                        mController.setChannelMaxPowers(
-                                CHANNEL_MAX_POWERS, mExecutor, newOutcomeReceiver(powerFuture)));
-
-        try {
-            assertThat(powerFuture.get()).isNull();
-        } catch (ExecutionException exception) {
-            ThreadNetworkException thrown = (ThreadNetworkException) exception.getCause();
-            assertThat(thrown.getErrorCode()).isEqualTo(ERROR_UNSUPPORTED_OPERATION);
-        }
-    }
-
-    @Test
-    public void setChannelMaxPowers_withoutPrivilegedPermission_throwsSecurityException()
-            throws Exception {
-        dropAllPermissions();
-
-        assertThrows(
-                SecurityException.class,
-                () -> mController.setChannelMaxPowers(CHANNEL_MAX_POWERS, mExecutor, v -> {}));
-    }
-
-    @Test
-    public void setChannelMaxPowers_emptyChannelMaxPower_throwsIllegalArgumentException() {
-        assertThrows(
-                IllegalArgumentException.class,
-                () -> mController.setChannelMaxPowers(new SparseIntArray(), mExecutor, v -> {}));
-    }
-
-    @Test
-    public void setChannelMaxPowers_invalidChannel_throwsIllegalArgumentException() {
-        final SparseIntArray INVALID_CHANNEL_ARRAY =
-                new SparseIntArray() {
-                    {
-                        put(INVALID_CHANNEL, VALID_POWER);
-                    }
-                };
-
-        assertThrows(
-                IllegalArgumentException.class,
-                () -> mController.setChannelMaxPowers(INVALID_CHANNEL_ARRAY, mExecutor, v -> {}));
-    }
-
-    @Test
-    public void setChannelMaxPowers_invalidPower_throwsIllegalArgumentException() {
-        final SparseIntArray INVALID_POWER_ARRAY =
-                new SparseIntArray() {
-                    {
-                        put(VALID_CHANNEL, INVALID_POWER);
-                    }
-                };
-
-        assertThrows(
-                IllegalArgumentException.class,
-                () -> mController.setChannelMaxPowers(INVALID_POWER_ARRAY, mExecutor, v -> {}));
-    }
-
-    private static void dropAllPermissions() {
-        getInstrumentation().getUiAutomation().dropShellPermissionIdentity();
-    }
-
-    private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
-            CompletableFuture<V> future) {
-        return new OutcomeReceiver<V, ThreadNetworkException>() {
-            @Override
-            public void onResult(V result) {
-                future.complete(result);
-            }
-
-            @Override
-            public void onError(ThreadNetworkException e) {
-                future.completeExceptionally(e);
-            }
-        };
-    }
-}
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
deleted file mode 100644
index 82e9332..0000000
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
+++ /dev/null
@@ -1,563 +0,0 @@
-/*
- * 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 android.net.thread.utils;
-
-import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
-import static android.system.OsConstants.IPPROTO_ICMP;
-import static android.system.OsConstants.IPPROTO_ICMPV6;
-
-import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
-import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_PIO;
-import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT;
-
-import static com.google.common.io.BaseEncoding.base16;
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
-
-import static org.junit.Assert.assertNotNull;
-
-import static java.util.concurrent.TimeUnit.MILLISECONDS;
-import static java.util.concurrent.TimeUnit.SECONDS;
-
-import android.net.ConnectivityManager;
-import android.net.InetAddresses;
-import android.net.LinkAddress;
-import android.net.Network;
-import android.net.NetworkCapabilities;
-import android.net.NetworkRequest;
-import android.net.TestNetworkInterface;
-import android.net.nsd.NsdManager;
-import android.net.nsd.NsdServiceInfo;
-import android.net.thread.ActiveOperationalDataset;
-import android.net.thread.ThreadNetworkController;
-import android.os.Build;
-import android.os.Handler;
-import android.os.SystemClock;
-
-import androidx.annotation.NonNull;
-import androidx.test.core.app.ApplicationProvider;
-
-import com.android.net.module.util.Struct;
-import com.android.net.module.util.structs.Icmpv4Header;
-import com.android.net.module.util.structs.Icmpv6Header;
-import com.android.net.module.util.structs.Ipv4Header;
-import com.android.net.module.util.structs.Ipv6Header;
-import com.android.net.module.util.structs.PrefixInformationOption;
-import com.android.net.module.util.structs.RaHeader;
-import com.android.testutils.HandlerUtils;
-import com.android.testutils.TapPacketReader;
-
-import com.google.common.util.concurrent.SettableFuture;
-
-import java.io.FileDescriptor;
-import java.io.IOException;
-import java.net.DatagramPacket;
-import java.net.DatagramSocket;
-import java.net.Inet4Address;
-import java.net.Inet6Address;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.SocketAddress;
-import java.nio.ByteBuffer;
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.function.Predicate;
-import java.util.function.Supplier;
-
-/** Static utility methods relating to Thread integration tests. */
-public final class IntegrationTestUtils {
-    // The timeout of join() after restarting ot-daemon. The device needs to send 6 Link Request
-    // every 5 seconds, followed by 4 Parent Request every second. So this value needs to be 40
-    // seconds to be safe
-    public static final Duration RESTART_JOIN_TIMEOUT = Duration.ofSeconds(40);
-    public static final Duration JOIN_TIMEOUT = Duration.ofSeconds(30);
-    public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
-    public static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
-    public static final Duration SERVICE_DISCOVERY_TIMEOUT = Duration.ofSeconds(20);
-
-    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
-    private static final byte[] DEFAULT_DATASET_TLVS =
-            base16().decode(
-                            "0E080000000000010000000300001335060004001FFFE002"
-                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
-                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
-                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
-                                    + "B9D351B40C0402A0FFF8");
-    public static final ActiveOperationalDataset DEFAULT_DATASET =
-            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
-
-    private IntegrationTestUtils() {}
-
-    /**
-     * Waits for the given {@link Supplier} to be true until given timeout.
-     *
-     * @param condition the condition to check
-     * @param timeout the time to wait for the condition before throwing
-     * @throws TimeoutException if the condition is still not met when the timeout expires
-     */
-    public static void waitFor(Supplier<Boolean> condition, Duration timeout)
-            throws TimeoutException {
-        final long intervalMills = 500;
-        final long timeoutMills = timeout.toMillis();
-
-        for (long i = 0; i < timeoutMills; i += intervalMills) {
-            if (condition.get()) {
-                return;
-            }
-            SystemClock.sleep(intervalMills);
-        }
-        if (condition.get()) {
-            return;
-        }
-        throw new TimeoutException("The condition failed to become true in " + timeout);
-    }
-
-    /**
-     * Creates a {@link TapPacketReader} given the {@link TestNetworkInterface} and {@link Handler}.
-     *
-     * @param testNetworkInterface the TUN interface of the test network
-     * @param handler the handler to process the packets
-     * @return the {@link TapPacketReader}
-     */
-    public static TapPacketReader newPacketReader(
-            TestNetworkInterface testNetworkInterface, Handler handler) {
-        FileDescriptor fd = testNetworkInterface.getFileDescriptor().getFileDescriptor();
-        final TapPacketReader reader =
-                new TapPacketReader(handler, fd, testNetworkInterface.getMtu());
-        handler.post(() -> reader.start());
-        HandlerUtils.waitForIdle(handler, 5000 /* timeout in milliseconds */);
-        return reader;
-    }
-
-    /**
-     * Waits for the Thread module to enter any state of the given {@code deviceRoles}.
-     *
-     * @param controller the {@link ThreadNetworkController}
-     * @param deviceRoles the desired device roles. See also {@link
-     *     ThreadNetworkController.DeviceRole}
-     * @param timeout the time to wait for the expected state before throwing
-     * @return the {@link ThreadNetworkController.DeviceRole} after waiting
-     * @throws TimeoutException if the device hasn't become any of expected roles until the timeout
-     *     expires
-     */
-    public static int waitForStateAnyOf(
-            ThreadNetworkController controller, List<Integer> deviceRoles, Duration timeout)
-            throws TimeoutException {
-        SettableFuture<Integer> future = SettableFuture.create();
-        ThreadNetworkController.StateCallback callback =
-                newRole -> {
-                    if (deviceRoles.contains(newRole)) {
-                        future.set(newRole);
-                    }
-                };
-        controller.registerStateCallback(directExecutor(), callback);
-        try {
-            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
-        } catch (InterruptedException | ExecutionException e) {
-            throw new TimeoutException(
-                    String.format(
-                            "The device didn't become an expected role in %s: %s",
-                            timeout, e.getMessage()));
-        } finally {
-            controller.unregisterStateCallback(callback);
-        }
-    }
-
-    /**
-     * Polls for a packet from a given {@link TapPacketReader} that satisfies the {@code filter}.
-     *
-     * @param packetReader a TUN packet reader
-     * @param filter the filter to be applied on the packet
-     * @return the first IPv6 packet that satisfies the {@code filter}. If it has waited for more
-     *     than 3000ms to read the next packet, the method will return null
-     */
-    public static byte[] pollForPacket(TapPacketReader packetReader, Predicate<byte[]> filter) {
-        byte[] packet;
-        while ((packet = packetReader.poll(3000 /* timeoutMs */, filter)) != null) {
-            return packet;
-        }
-        return null;
-    }
-
-    /** Returns {@code true} if {@code packet} is an ICMPv4 packet of given {@code type}. */
-    public static boolean isExpectedIcmpv4Packet(byte[] packet, int type) {
-        ByteBuffer buf = makeByteBuffer(packet);
-        Ipv4Header header = extractIpv4Header(buf);
-        if (header == null) {
-            return false;
-        }
-        if (header.protocol != (byte) IPPROTO_ICMP) {
-            return false;
-        }
-        try {
-            return Struct.parse(Icmpv4Header.class, buf).type == (short) type;
-        } catch (IllegalArgumentException ignored) {
-            // It's fine that the passed in packet is malformed because it's could be sent
-            // by anybody.
-        }
-        return false;
-    }
-
-    /** Returns {@code true} if {@code packet} is an ICMPv6 packet of given {@code type}. */
-    public static boolean isExpectedIcmpv6Packet(byte[] packet, int type) {
-        ByteBuffer buf = makeByteBuffer(packet);
-        Ipv6Header header = extractIpv6Header(buf);
-        if (header == null) {
-            return false;
-        }
-        if (header.nextHeader != (byte) IPPROTO_ICMPV6) {
-            return false;
-        }
-        try {
-            return Struct.parse(Icmpv6Header.class, buf).type == (short) type;
-        } catch (IllegalArgumentException ignored) {
-            // It's fine that the passed in packet is malformed because it's could be sent
-            // by anybody.
-        }
-        return false;
-    }
-
-    public static boolean isFrom(byte[] packet, InetAddress src) {
-        if (src instanceof Inet4Address) {
-            return isFromIpv4Source(packet, (Inet4Address) src);
-        } else if (src instanceof Inet6Address) {
-            return isFromIpv6Source(packet, (Inet6Address) src);
-        }
-        return false;
-    }
-
-    public static boolean isTo(byte[] packet, InetAddress dest) {
-        if (dest instanceof Inet4Address) {
-            return isToIpv4Destination(packet, (Inet4Address) dest);
-        } else if (dest instanceof Inet6Address) {
-            return isToIpv6Destination(packet, (Inet6Address) dest);
-        }
-        return false;
-    }
-
-    private static boolean isFromIpv4Source(byte[] packet, Inet4Address src) {
-        Ipv4Header header = extractIpv4Header(makeByteBuffer(packet));
-        return header != null && header.srcIp.equals(src);
-    }
-
-    private static boolean isFromIpv6Source(byte[] packet, Inet6Address src) {
-        Ipv6Header header = extractIpv6Header(makeByteBuffer(packet));
-        return header != null && header.srcIp.equals(src);
-    }
-
-    private static boolean isToIpv4Destination(byte[] packet, Inet4Address dest) {
-        Ipv4Header header = extractIpv4Header(makeByteBuffer(packet));
-        return header != null && header.dstIp.equals(dest);
-    }
-
-    private static boolean isToIpv6Destination(byte[] packet, Inet6Address dest) {
-        Ipv6Header header = extractIpv6Header(makeByteBuffer(packet));
-        return header != null && header.dstIp.equals(dest);
-    }
-
-    private static ByteBuffer makeByteBuffer(byte[] packet) {
-        return packet == null ? null : ByteBuffer.wrap(packet);
-    }
-
-    private static Ipv4Header extractIpv4Header(ByteBuffer buf) {
-        try {
-            return Struct.parse(Ipv4Header.class, buf);
-        } catch (IllegalArgumentException ignored) {
-            // It's fine that the passed in packet is malformed because it's could be sent
-            // by anybody.
-        }
-        return null;
-    }
-
-    private static Ipv6Header extractIpv6Header(ByteBuffer buf) {
-        try {
-            return Struct.parse(Ipv6Header.class, buf);
-        } catch (IllegalArgumentException ignored) {
-            // It's fine that the passed in packet is malformed because it's could be sent
-            // by anybody.
-        }
-        return null;
-    }
-
-    /** Returns the Prefix Information Options (PIO) extracted from an ICMPv6 RA message. */
-    public static List<PrefixInformationOption> getRaPios(byte[] raMsg) {
-        final ArrayList<PrefixInformationOption> pioList = new ArrayList<>();
-
-        if (raMsg == null) {
-            return pioList;
-        }
-
-        final ByteBuffer buf = ByteBuffer.wrap(raMsg);
-        final Ipv6Header ipv6Header = Struct.parse(Ipv6Header.class, buf);
-        if (ipv6Header.nextHeader != (byte) IPPROTO_ICMPV6) {
-            return pioList;
-        }
-
-        final Icmpv6Header icmpv6Header = Struct.parse(Icmpv6Header.class, buf);
-        if (icmpv6Header.type != (short) ICMPV6_ROUTER_ADVERTISEMENT) {
-            return pioList;
-        }
-
-        Struct.parse(RaHeader.class, buf);
-        while (buf.position() < raMsg.length) {
-            final int currentPos = buf.position();
-            final int type = Byte.toUnsignedInt(buf.get());
-            final int length = Byte.toUnsignedInt(buf.get());
-            if (type == ICMPV6_ND_OPTION_PIO) {
-                final ByteBuffer pioBuf =
-                        ByteBuffer.wrap(
-                                buf.array(),
-                                currentPos,
-                                Struct.getSize(PrefixInformationOption.class));
-                final PrefixInformationOption pio =
-                        Struct.parse(PrefixInformationOption.class, pioBuf);
-                pioList.add(pio);
-
-                // Move ByteBuffer position to the next option.
-                buf.position(currentPos + Struct.getSize(PrefixInformationOption.class));
-            } else {
-                // The length is in units of 8 octets.
-                buf.position(currentPos + (length * 8));
-            }
-        }
-        return pioList;
-    }
-
-    /**
-     * Sends a UDP message to a destination.
-     *
-     * @param dstAddress the IP address of the destination
-     * @param dstPort the port of the destination
-     * @param message the message in UDP payload
-     * @throws IOException if failed to send the message
-     */
-    public static void sendUdpMessage(InetAddress dstAddress, int dstPort, String message)
-            throws IOException {
-        SocketAddress dstSockAddr = new InetSocketAddress(dstAddress, dstPort);
-
-        try (DatagramSocket socket = new DatagramSocket()) {
-            socket.connect(dstSockAddr);
-
-            byte[] msgBytes = message.getBytes();
-            DatagramPacket packet = new DatagramPacket(msgBytes, msgBytes.length);
-
-            socket.send(packet);
-        }
-    }
-
-    public static boolean isInMulticastGroup(String interfaceName, Inet6Address address) {
-        final String cmd = "ip -6 maddr show dev " + interfaceName;
-        final String output = runShellCommandOrThrow(cmd);
-        final String addressStr = address.getHostAddress();
-        for (final String line : output.split("\\n")) {
-            if (line.contains(addressStr)) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    public static List<LinkAddress> getIpv6LinkAddresses(String interfaceName) {
-        List<LinkAddress> addresses = new ArrayList<>();
-        final String cmd = " ip -6 addr show dev " + interfaceName;
-        final String output = runShellCommandOrThrow(cmd);
-
-        for (final String line : output.split("\\n")) {
-            if (line.contains("inet6")) {
-                addresses.add(parseAddressLine(line));
-            }
-        }
-
-        return addresses;
-    }
-
-    /** Return the first discovered service of {@code serviceType}. */
-    public static NsdServiceInfo discoverService(NsdManager nsdManager, String serviceType)
-            throws Exception {
-        CompletableFuture<NsdServiceInfo> serviceInfoFuture = new CompletableFuture<>();
-        NsdManager.DiscoveryListener listener =
-                new DefaultDiscoveryListener() {
-                    @Override
-                    public void onServiceFound(NsdServiceInfo serviceInfo) {
-                        serviceInfoFuture.complete(serviceInfo);
-                    }
-                };
-        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
-        try {
-            serviceInfoFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
-        } finally {
-            nsdManager.stopServiceDiscovery(listener);
-        }
-
-        return serviceInfoFuture.get();
-    }
-
-    /**
-     * Returns the {@link NsdServiceInfo} when a service instance of {@code serviceType} gets lost.
-     */
-    public static NsdManager.DiscoveryListener discoverForServiceLost(
-            NsdManager nsdManager,
-            String serviceType,
-            CompletableFuture<NsdServiceInfo> serviceInfoFuture) {
-        NsdManager.DiscoveryListener listener =
-                new DefaultDiscoveryListener() {
-                    @Override
-                    public void onServiceLost(NsdServiceInfo serviceInfo) {
-                        serviceInfoFuture.complete(serviceInfo);
-                    }
-                };
-        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener);
-        return listener;
-    }
-
-    /** Resolves the service. */
-    public static NsdServiceInfo resolveService(NsdManager nsdManager, NsdServiceInfo serviceInfo)
-            throws Exception {
-        return resolveServiceUntil(nsdManager, serviceInfo, s -> true);
-    }
-
-    /** Returns the first resolved service that satisfies the {@code predicate}. */
-    public static NsdServiceInfo resolveServiceUntil(
-            NsdManager nsdManager, NsdServiceInfo serviceInfo, Predicate<NsdServiceInfo> predicate)
-            throws Exception {
-        CompletableFuture<NsdServiceInfo> resolvedServiceInfoFuture = new CompletableFuture<>();
-        NsdManager.ServiceInfoCallback callback =
-                new DefaultServiceInfoCallback() {
-                    @Override
-                    public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {
-                        if (predicate.test(serviceInfo)) {
-                            resolvedServiceInfoFuture.complete(serviceInfo);
-                        }
-                    }
-                };
-        nsdManager.registerServiceInfoCallback(serviceInfo, directExecutor(), callback);
-        try {
-            return resolvedServiceInfoFuture.get(
-                    SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
-        } finally {
-            nsdManager.unregisterServiceInfoCallback(callback);
-        }
-    }
-
-    public static String getPrefixesFromNetData(String netData) {
-        int startIdx = netData.indexOf("Prefixes:");
-        int endIdx = netData.indexOf("Routes:");
-        return netData.substring(startIdx, endIdx);
-    }
-
-    public static Network getThreadNetwork(Duration timeout) throws Exception {
-        CompletableFuture<Network> networkFuture = new CompletableFuture<>();
-        ConnectivityManager cm =
-                ApplicationProvider.getApplicationContext()
-                        .getSystemService(ConnectivityManager.class);
-        NetworkRequest.Builder networkRequestBuilder =
-                new NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_THREAD);
-        // Before V, we need to explicitly set `NET_CAPABILITY_LOCAL_NETWORK` capability to request
-        // a Thread network.
-        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-            networkRequestBuilder.addCapability(NET_CAPABILITY_LOCAL_NETWORK);
-        }
-        NetworkRequest networkRequest = networkRequestBuilder.build();
-        ConnectivityManager.NetworkCallback networkCallback =
-                new ConnectivityManager.NetworkCallback() {
-                    @Override
-                    public void onAvailable(Network network) {
-                        networkFuture.complete(network);
-                    }
-                };
-        cm.registerNetworkCallback(networkRequest, networkCallback);
-        return networkFuture.get(timeout.toSeconds(), SECONDS);
-    }
-
-    /**
-     * Let the FTD join the specified Thread network and wait for border routing to be available.
-     *
-     * @return the OMR address
-     */
-    public static Inet6Address joinNetworkAndWaitForOmr(
-            FullThreadDevice ftd, ActiveOperationalDataset dataset) throws Exception {
-        ftd.factoryReset();
-        ftd.joinNetwork(dataset);
-        ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
-        waitFor(() -> ftd.getOmrAddress() != null, Duration.ofSeconds(60));
-        Inet6Address ftdOmr = ftd.getOmrAddress();
-        assertNotNull(ftdOmr);
-        return ftdOmr;
-    }
-
-    private static class DefaultDiscoveryListener implements NsdManager.DiscoveryListener {
-        @Override
-        public void onStartDiscoveryFailed(String serviceType, int errorCode) {}
-
-        @Override
-        public void onStopDiscoveryFailed(String serviceType, int errorCode) {}
-
-        @Override
-        public void onDiscoveryStarted(String serviceType) {}
-
-        @Override
-        public void onDiscoveryStopped(String serviceType) {}
-
-        @Override
-        public void onServiceFound(NsdServiceInfo serviceInfo) {}
-
-        @Override
-        public void onServiceLost(NsdServiceInfo serviceInfo) {}
-    }
-
-    private static class DefaultServiceInfoCallback implements NsdManager.ServiceInfoCallback {
-        @Override
-        public void onServiceInfoCallbackRegistrationFailed(int errorCode) {}
-
-        @Override
-        public void onServiceUpdated(@NonNull NsdServiceInfo serviceInfo) {}
-
-        @Override
-        public void onServiceLost() {}
-
-        @Override
-        public void onServiceInfoCallbackUnregistered() {}
-    }
-
-    /**
-     * Parses a line of output from "ip -6 addr show" into a {@link LinkAddress}.
-     *
-     * <p>Example line: "inet6 2001:db8:1:1::1/64 scope global deprecated"
-     */
-    private static LinkAddress parseAddressLine(String line) {
-        String[] parts = line.trim().split("\\s+");
-        String addressString = parts[1];
-        String[] pieces = addressString.split("/", 2);
-        int prefixLength = Integer.parseInt(pieces[1]);
-        final InetAddress address = InetAddresses.parseNumericAddress(pieces[0]);
-        long deprecationTimeMillis =
-                line.contains("deprecated")
-                        ? SystemClock.elapsedRealtime()
-                        : LinkAddress.LIFETIME_PERMANENT;
-
-        return new LinkAddress(
-                address,
-                prefixLength,
-                0 /* flags */,
-                0 /* scope */,
-                deprecationTimeMillis,
-                LinkAddress.LIFETIME_PERMANENT /* expirationTime */);
-    }
-}
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
new file mode 100644
index 0000000..fa9855e
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -0,0 +1,598 @@
+/*
+ * 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.thread.utils
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.InetAddresses.parseNumericAddress
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.MacAddress
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.RouteInfo
+import android.net.TestNetworkInterface
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.net.thread.ActiveOperationalDataset
+import android.net.thread.ThreadNetworkController
+import android.os.Build
+import android.os.Handler
+import android.os.SystemClock
+import android.system.OsConstants
+import androidx.test.core.app.ApplicationProvider
+import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
+import com.android.net.module.util.NetworkStackConstants
+import com.android.net.module.util.Struct
+import com.android.net.module.util.structs.Icmpv4Header
+import com.android.net.module.util.structs.Icmpv6Header
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.Ipv6Header
+import com.android.net.module.util.structs.PrefixInformationOption
+import com.android.net.module.util.structs.RaHeader
+import com.android.testutils.TapPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.android.testutils.initTestNetwork
+import com.android.testutils.runAsShell
+import com.android.testutils.waitForIdle
+import com.google.common.io.BaseEncoding
+import com.google.common.util.concurrent.MoreExecutors
+import com.google.common.util.concurrent.MoreExecutors.directExecutor
+import com.google.common.util.concurrent.SettableFuture
+import java.io.IOException
+import java.lang.Byte.toUnsignedInt
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.SocketAddress
+import java.nio.ByteBuffer
+import java.time.Duration
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import java.util.function.Predicate
+import java.util.function.Supplier
+import org.junit.Assert
+
+/** Utilities for Thread integration tests. */
+object IntegrationTestUtils {
+    // The timeout of join() after restarting ot-daemon. The device needs to send 6 Link Request
+    // every 5 seconds, followed by 4 Parent Request every second. So this value needs to be 40
+    // seconds to be safe
+    @JvmField
+    val RESTART_JOIN_TIMEOUT: Duration = Duration.ofSeconds(40)
+
+    @JvmField
+    val JOIN_TIMEOUT: Duration = Duration.ofSeconds(30)
+
+    @JvmField
+    val LEAVE_TIMEOUT: Duration = Duration.ofSeconds(2)
+
+    @JvmField
+    val CALLBACK_TIMEOUT: Duration = Duration.ofSeconds(1)
+
+    @JvmField
+    val SERVICE_DISCOVERY_TIMEOUT: Duration = Duration.ofSeconds(20)
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
+    private val DEFAULT_DATASET_TLVS: ByteArray = BaseEncoding.base16().decode(
+        ("0E080000000000010000000300001335060004001FFFE002"
+                + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                + "B9D351B40C0402A0FFF8")
+    )
+
+    @JvmField
+    val DEFAULT_DATASET: ActiveOperationalDataset =
+        ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS)
+
+    /**
+     * Waits for the given [Supplier] to be true until given timeout.
+     *
+     * @param condition the condition to check
+     * @param timeout the time to wait for the condition before throwing
+     * @throws TimeoutException if the condition is still not met when the timeout expires
+     */
+    @JvmStatic
+    @Throws(TimeoutException::class)
+    fun waitFor(condition: Supplier<Boolean>, timeout: Duration) {
+        val intervalMills: Long = 500
+        val timeoutMills = timeout.toMillis()
+
+        var i: Long = 0
+        while (i < timeoutMills) {
+            if (condition.get()) {
+                return
+            }
+            SystemClock.sleep(intervalMills)
+            i += intervalMills
+        }
+        if (condition.get()) {
+            return
+        }
+        throw TimeoutException("The condition failed to become true in $timeout")
+    }
+
+    /**
+     * Creates a [TapPacketReader] given the [TestNetworkInterface] and [Handler].
+     *
+     * @param testNetworkInterface the TUN interface of the test network
+     * @param handler the handler to process the packets
+     * @return the [TapPacketReader]
+     */
+    @JvmStatic
+    fun newPacketReader(
+        testNetworkInterface: TestNetworkInterface, handler: Handler
+    ): TapPacketReader {
+        val fd = testNetworkInterface.fileDescriptor.fileDescriptor
+        val reader = TapPacketReader(handler, fd, testNetworkInterface.mtu)
+        handler.post { reader.start() }
+        handler.waitForIdle(timeoutMs = 5000)
+        return reader
+    }
+
+    /**
+     * Waits for the Thread module to enter any state of the given `deviceRoles`.
+     *
+     * @param controller the [ThreadNetworkController]
+     * @param deviceRoles the desired device roles. See also [     ]
+     * @param timeout the time to wait for the expected state before throwing
+     * @return the [ThreadNetworkController.DeviceRole] after waiting
+     * @throws TimeoutException if the device hasn't become any of expected roles until the timeout
+     * expires
+     */
+    @JvmStatic
+    @Throws(TimeoutException::class)
+    fun waitForStateAnyOf(
+        controller: ThreadNetworkController, deviceRoles: List<Int>, timeout: Duration
+    ): Int {
+        val future = SettableFuture.create<Int>()
+        val callback = ThreadNetworkController.StateCallback { newRole: Int ->
+            if (deviceRoles.contains(newRole)) {
+                future.set(newRole)
+            }
+        }
+        controller.registerStateCallback(MoreExecutors.directExecutor(), callback)
+        try {
+            return future[timeout.toMillis(), TimeUnit.MILLISECONDS]
+        } catch (e: InterruptedException) {
+            throw TimeoutException(
+                "The device didn't become an expected role in $timeout: $e.message"
+            )
+        } catch (e: ExecutionException) {
+            throw TimeoutException(
+                "The device didn't become an expected role in $timeout: $e.message"
+            )
+        } finally {
+            controller.unregisterStateCallback(callback)
+        }
+    }
+
+    /**
+     * Polls for a packet from a given [TapPacketReader] that satisfies the `filter`.
+     *
+     * @param packetReader a TUN packet reader
+     * @param filter the filter to be applied on the packet
+     * @return the first IPv6 packet that satisfies the `filter`. If it has waited for more
+     * than 3000ms to read the next packet, the method will return null
+     */
+    @JvmStatic
+    fun pollForPacket(packetReader: TapPacketReader, filter: Predicate<ByteArray>): ByteArray? {
+        var packet: ByteArray?
+        while ((packetReader.poll(3000 /* timeoutMs */, filter).also { packet = it }) != null) {
+            return packet
+        }
+        return null
+    }
+
+    /** Returns `true` if `packet` is an ICMPv4 packet of given `type`.  */
+    @JvmStatic
+    fun isExpectedIcmpv4Packet(packet: ByteArray, type: Int): Boolean {
+        val buf = makeByteBuffer(packet)
+        val header = extractIpv4Header(buf) ?: return false
+        if (header.protocol != OsConstants.IPPROTO_ICMP.toByte()) {
+            return false
+        }
+        try {
+            return Struct.parse(Icmpv4Header::class.java, buf).type == type.toShort()
+        } catch (ignored: IllegalArgumentException) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return false
+    }
+
+    /** Returns `true` if `packet` is an ICMPv6 packet of given `type`.  */
+    @JvmStatic
+    fun isExpectedIcmpv6Packet(packet: ByteArray, type: Int): Boolean {
+        val buf = makeByteBuffer(packet)
+        val header = extractIpv6Header(buf) ?: return false
+        if (header.nextHeader != OsConstants.IPPROTO_ICMPV6.toByte()) {
+            return false
+        }
+        try {
+            return Struct.parse(Icmpv6Header::class.java, buf).type == type.toShort()
+        } catch (ignored: IllegalArgumentException) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return false
+    }
+
+    @JvmStatic
+    fun isFrom(packet: ByteArray, src: InetAddress): Boolean {
+        when (src) {
+            is Inet4Address -> return isFromIpv4Source(packet, src)
+            is Inet6Address -> return isFromIpv6Source(packet, src)
+            else -> return false
+        }
+    }
+
+    @JvmStatic
+    fun isTo(packet: ByteArray, dest: InetAddress): Boolean {
+        when (dest) {
+            is Inet4Address -> return isToIpv4Destination(packet, dest)
+            is Inet6Address -> return isToIpv6Destination(packet, dest)
+            else -> return false
+        }
+    }
+
+    private fun isFromIpv4Source(packet: ByteArray, src: Inet4Address): Boolean {
+        val header = extractIpv4Header(makeByteBuffer(packet))
+        return header?.srcIp == src
+    }
+
+    private fun isFromIpv6Source(packet: ByteArray, src: Inet6Address): Boolean {
+        val header = extractIpv6Header(makeByteBuffer(packet))
+        return header?.srcIp == src
+    }
+
+    private fun isToIpv4Destination(packet: ByteArray, dest: Inet4Address): Boolean {
+        val header = extractIpv4Header(makeByteBuffer(packet))
+        return header?.dstIp == dest
+    }
+
+    private fun isToIpv6Destination(packet: ByteArray, dest: Inet6Address): Boolean {
+        val header = extractIpv6Header(makeByteBuffer(packet))
+        return header?.dstIp == dest
+    }
+
+    private fun makeByteBuffer(packet: ByteArray): ByteBuffer {
+        return ByteBuffer.wrap(packet)
+    }
+
+    private fun extractIpv4Header(buf: ByteBuffer): Ipv4Header? {
+        try {
+            return Struct.parse(Ipv4Header::class.java, buf)
+        } catch (ignored: IllegalArgumentException) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return null
+    }
+
+    private fun extractIpv6Header(buf: ByteBuffer): Ipv6Header? {
+        try {
+            return Struct.parse(Ipv6Header::class.java, buf)
+        } catch (ignored: IllegalArgumentException) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return null
+    }
+
+    /** Returns the Prefix Information Options (PIO) extracted from an ICMPv6 RA message.  */
+    @JvmStatic
+    fun getRaPios(raMsg: ByteArray?): List<PrefixInformationOption> {
+        val pioList = ArrayList<PrefixInformationOption>()
+
+        raMsg ?: return pioList
+
+        val buf = ByteBuffer.wrap(raMsg)
+        val ipv6Header = Struct.parse(Ipv6Header::class.java, buf)
+        if (ipv6Header.nextHeader != OsConstants.IPPROTO_ICMPV6.toByte()) {
+            return pioList
+        }
+
+        val icmpv6Header = Struct.parse(Icmpv6Header::class.java, buf)
+        if (icmpv6Header.type != NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT.toShort()) {
+            return pioList
+        }
+
+        Struct.parse(RaHeader::class.java, buf)
+        while (buf.position() < raMsg.size) {
+            val currentPos = buf.position()
+            val type = toUnsignedInt(buf.get())
+            val length = toUnsignedInt(buf.get())
+            if (type == NetworkStackConstants.ICMPV6_ND_OPTION_PIO) {
+                val pioBuf = ByteBuffer.wrap(
+                    buf.array(), currentPos, Struct.getSize(PrefixInformationOption::class.java)
+                )
+                val pio = Struct.parse(PrefixInformationOption::class.java, pioBuf)
+                pioList.add(pio)
+
+                // Move ByteBuffer position to the next option.
+                buf.position(
+                    currentPos + Struct.getSize(PrefixInformationOption::class.java)
+                )
+            } else {
+                // The length is in units of 8 octets.
+                buf.position(currentPos + (length * 8))
+            }
+        }
+        return pioList
+    }
+
+    /**
+     * Sends a UDP message to a destination.
+     *
+     * @param dstAddress the IP address of the destination
+     * @param dstPort the port of the destination
+     * @param message the message in UDP payload
+     * @throws IOException if failed to send the message
+     */
+    @JvmStatic
+    @Throws(IOException::class)
+    fun sendUdpMessage(dstAddress: InetAddress, dstPort: Int, message: String) {
+        val dstSockAddr: SocketAddress = InetSocketAddress(dstAddress, dstPort)
+
+        DatagramSocket().use { socket ->
+            socket.connect(dstSockAddr)
+            val msgBytes = message.toByteArray()
+            val packet = DatagramPacket(msgBytes, msgBytes.size)
+            socket.send(packet)
+        }
+    }
+
+    @JvmStatic
+    fun isInMulticastGroup(interfaceName: String, address: Inet6Address): Boolean {
+        val cmd = "ip -6 maddr show dev $interfaceName"
+        val output: String = runShellCommandOrThrow(cmd)
+        val addressStr = address.hostAddress
+        for (line in output.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) {
+            if (line.contains(addressStr)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    @JvmStatic
+    fun getIpv6LinkAddresses(interfaceName: String): List<LinkAddress> {
+        val addresses: MutableList<LinkAddress> = ArrayList()
+        val cmd = " ip -6 addr show dev $interfaceName"
+        val output: String = runShellCommandOrThrow(cmd)
+
+        for (line in output.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) {
+            if (line.contains("inet6")) {
+                addresses.add(parseAddressLine(line))
+            }
+        }
+
+        return addresses
+    }
+
+    /** Return the first discovered service of `serviceType`.  */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun discoverService(nsdManager: NsdManager, serviceType: String): NsdServiceInfo {
+        val serviceInfoFuture = CompletableFuture<NsdServiceInfo>()
+        val listener: NsdManager.DiscoveryListener = object : DefaultDiscoveryListener() {
+            override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+                serviceInfoFuture.complete(serviceInfo)
+            }
+        }
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
+        try {
+            serviceInfoFuture[SERVICE_DISCOVERY_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS]
+        } finally {
+            nsdManager.stopServiceDiscovery(listener)
+        }
+
+        return serviceInfoFuture.get()
+    }
+
+    /**
+     * Returns the [NsdServiceInfo] when a service instance of `serviceType` gets lost.
+     */
+    @JvmStatic
+    fun discoverForServiceLost(
+        nsdManager: NsdManager,
+        serviceType: String?,
+        serviceInfoFuture: CompletableFuture<NsdServiceInfo?>
+    ): NsdManager.DiscoveryListener {
+        val listener: NsdManager.DiscoveryListener = object : DefaultDiscoveryListener() {
+            override fun onServiceLost(serviceInfo: NsdServiceInfo): Unit {
+                serviceInfoFuture.complete(serviceInfo)
+            }
+        }
+        nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
+        return listener
+    }
+
+    /** Resolves the service.  */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun resolveService(nsdManager: NsdManager, serviceInfo: NsdServiceInfo): NsdServiceInfo {
+        return resolveServiceUntil(nsdManager, serviceInfo) { true }
+    }
+
+    /** Returns the first resolved service that satisfies the `predicate`.  */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun resolveServiceUntil(
+        nsdManager: NsdManager, serviceInfo: NsdServiceInfo, predicate: Predicate<NsdServiceInfo>
+    ): NsdServiceInfo {
+        val resolvedServiceInfoFuture = CompletableFuture<NsdServiceInfo>()
+        val callback: NsdManager.ServiceInfoCallback = object : DefaultServiceInfoCallback() {
+            override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
+                if (predicate.test(serviceInfo)) {
+                    resolvedServiceInfoFuture.complete(serviceInfo)
+                }
+            }
+        }
+        nsdManager.registerServiceInfoCallback(serviceInfo, directExecutor(), callback)
+        try {
+            return resolvedServiceInfoFuture[
+                SERVICE_DISCOVERY_TIMEOUT.toMillis(),
+                TimeUnit.MILLISECONDS]
+        } finally {
+            nsdManager.unregisterServiceInfoCallback(callback)
+        }
+    }
+
+    @JvmStatic
+    fun getPrefixesFromNetData(netData: String): String {
+        val startIdx = netData.indexOf("Prefixes:")
+        val endIdx = netData.indexOf("Routes:")
+        return netData.substring(startIdx, endIdx)
+    }
+
+    @JvmStatic
+    @Throws(Exception::class)
+    fun getThreadNetwork(timeout: Duration): Network {
+        val networkFuture = CompletableFuture<Network>()
+        val cm =
+            ApplicationProvider.getApplicationContext<Context>()
+                .getSystemService(ConnectivityManager::class.java)
+        val networkRequestBuilder =
+            NetworkRequest.Builder().addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+        // Before V, we need to explicitly set `NET_CAPABILITY_LOCAL_NETWORK` capability to request
+        // a Thread network.
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
+        }
+        val networkRequest = networkRequestBuilder.build()
+        val networkCallback: ConnectivityManager.NetworkCallback =
+            object : ConnectivityManager.NetworkCallback() {
+                override fun onAvailable(network: Network) {
+                    networkFuture.complete(network)
+                }
+            }
+        cm.registerNetworkCallback(networkRequest, networkCallback)
+        return networkFuture[timeout.toSeconds(), TimeUnit.SECONDS]
+    }
+
+    /**
+     * Let the FTD join the specified Thread network and wait for border routing to be available.
+     *
+     * @return the OMR address
+     */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun joinNetworkAndWaitForOmr(
+        ftd: FullThreadDevice, dataset: ActiveOperationalDataset
+    ): Inet6Address {
+        ftd.factoryReset()
+        ftd.joinNetwork(dataset)
+        ftd.waitForStateAnyOf(listOf("router", "child"), JOIN_TIMEOUT)
+        waitFor({ ftd.omrAddress != null }, Duration.ofSeconds(60))
+        Assert.assertNotNull(ftd.omrAddress)
+        return ftd.omrAddress
+    }
+
+    private open class DefaultDiscoveryListener : NsdManager.DiscoveryListener {
+        override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
+        override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
+        override fun onDiscoveryStarted(serviceType: String) {}
+        override fun onDiscoveryStopped(serviceType: String) {}
+        override fun onServiceFound(serviceInfo: NsdServiceInfo) {}
+        override fun onServiceLost(serviceInfo: NsdServiceInfo) {}
+    }
+
+    private open class DefaultServiceInfoCallback : NsdManager.ServiceInfoCallback {
+        override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
+        override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {}
+        override fun onServiceLost(): Unit {}
+        override fun onServiceInfoCallbackUnregistered() {}
+    }
+
+    /**
+     * Parses a line of output from "ip -6 addr show" into a [LinkAddress].
+     *
+     * Example line: "inet6 2001:db8:1:1::1/64 scope global deprecated"
+     */
+    private fun parseAddressLine(line: String): LinkAddress {
+        val parts = line.split("\\s+".toRegex()).filter { it.isNotEmpty() }.toTypedArray()
+        val addressString = parts[1]
+        val pieces = addressString.split("/".toRegex(), limit = 2).toTypedArray()
+        val prefixLength = pieces[1].toInt()
+        val address = parseNumericAddress(pieces[0])
+        val deprecationTimeMillis =
+            if (line.contains("deprecated")) SystemClock.elapsedRealtime()
+            else LinkAddress.LIFETIME_PERMANENT
+
+        return LinkAddress(
+            address, prefixLength,
+            0 /* flags */, 0 /* scope */,
+            deprecationTimeMillis, LinkAddress.LIFETIME_PERMANENT /* expirationTime */
+        )
+    }
+
+    @JvmStatic
+    @JvmOverloads
+    fun startInfraDeviceAndWaitForOnLinkAddr(
+        tapPacketReader: TapPacketReader,
+        macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6")
+    ): InfraNetworkDevice {
+        val infraDevice = InfraNetworkDevice(macAddress, tapPacketReader)
+        infraDevice.runSlaac(Duration.ofSeconds(60))
+        requireNotNull(infraDevice.ipv6Addr)
+        return infraDevice
+    }
+
+    @JvmStatic
+    @Throws(java.lang.Exception::class)
+    fun setUpInfraNetwork(
+        context: Context, controller: ThreadNetworkControllerWrapper
+    ): 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,
+                supplier = { initTestNetwork(context, lp, setupTimeoutMs = 5000) })
+        val infraNetworkName: String = infraNetworkTracker.testIface.getInterfaceName()
+        controller.setTestNetworkAsUpstreamAndWait(infraNetworkName)
+
+        return infraNetworkTracker
+    }
+
+    @JvmStatic
+    fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
+        runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
+    }
+}
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/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index ac74372..0423578 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -19,7 +19,7 @@
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
-import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_OPERATION;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 import static android.os.Process.SYSTEM_UID;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -394,7 +394,7 @@
         doAnswer(
                         invoke -> {
                             getSetChannelMaxPowersReceiver(invoke)
-                                    .onError(ERROR_UNSUPPORTED_OPERATION, "");
+                                    .onError(ERROR_UNSUPPORTED_FEATURE, "");
                             return null;
                         })
                 .when(mMockService)