Merge tag 'android-16.0.0_r1' of https://android.googlesource.com/platform/packages/modules/Connectivity into HEAD

Android 16.0.0 release 1

Change-Id: I93829a8d1063190018824b2f72d3cb14612f18ef

# -----BEGIN PGP SIGNATURE-----
#
# iF0EABECAB0WIQRDQNE1cO+UXoOBCWTorT+BmrEOeAUCaEhhFgAKCRDorT+BmrEO
# eG9VAJ9sIHLdPUKe5k4RzVnFxFI95stlAACfUfzMDDzhb6icAVTItNf1slJNVhY=
# =1Lbw
# -----END PGP SIGNATURE-----
# gpg: Signature faite le mar 10 jun 2025 12:45:10 EDT
# gpg:                avec la clef DSA 4340D13570EF945E83810964E8AD3F819AB10E78
# gpg: Impossible de vérifier la signature : Pas de clef publique
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..347e497
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,2 @@
+IndentWidth: 4
+AllowShortIfStatementsOnASingleLine: WithoutElse
diff --git a/OWNERS_core_networking b/OWNERS_core_networking
index 078ccde..4b73639 100644
--- a/OWNERS_core_networking
+++ b/OWNERS_core_networking
@@ -1,4 +1,5 @@
 jchalard@google.com
+jimictw@google.com
 junyulai@google.com
 lorenzo@google.com
 maze@google.com
diff --git a/OWNERS_core_networking_xts b/OWNERS_core_networking_xts
index 9e4e4a1..60ca885 100644
--- a/OWNERS_core_networking_xts
+++ b/OWNERS_core_networking_xts
@@ -10,3 +10,5 @@
 # In addition to cherry-picks, flaky test fixes and no-op refactors, also for
 # NsdManager tests
 reminv@google.com #{LAST_RESORT_SUGGESTION}
+# Only for APF firmware tests (to verify correct behaviour of the wifi APF interpreter)
+yuyanghuang@google.com #{LAST_RESORT_SUGGESTION}
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index 39009cb..c301397 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -1,15 +1,13 @@
 [Builtin Hooks]
 bpfmt = true
 clang_format = true
-ktfmt = true
 
 [Builtin Hooks Options]
 clang_format = --commit ${PREUPLOAD_COMMIT} --style file --extensions c,h,cc,cpp,hpp
-ktfmt = --kotlinlang-style
 
 [Hook Scripts]
 checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT}
 
-ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py --no-verify-format -f ${PREUPLOAD_FILES}
+ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py --disabled-rules comment-wrapping -f ${PREUPLOAD_FILES}
 
 hidden_api_txt_checksorted_hook = ${REPO_ROOT}/tools/platform-compat/hiddenapi/checksorted_sha.sh ${PREUPLOAD_COMMIT} ${REPO_ROOT}
diff --git a/TEST_MAPPING b/TEST_MAPPING
index c1bc31e..8592af2 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -77,7 +77,7 @@
       "name": "libnetworkstats_test"
     },
     {
-      "name": "CtsTetheringTestLatestSdk",
+      "name": "CtsTetheringTest",
       "options": [
         {
           "exclude-annotation": "com.android.testutils.NetworkStackModuleTest"
@@ -226,6 +226,9 @@
     },
     {
       "name": "FrameworksNetIntegrationTests"
+    },
+    {
+      "name": "CtsTetheringTest"
     }
   ],
   "postsubmit": [
@@ -391,7 +394,7 @@
       "name": "libnetworkstats_test[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
     },
     {
-      "name": "CtsTetheringTestLatestSdk[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "name": "CtsTetheringTest[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
       "options": [
         {
           "exclude-annotation": "com.android.testutils.NetworkStackModuleTest"
@@ -406,7 +409,7 @@
       "keywords": ["sim"]
     },
     {
-      "name": "CtsTetheringTestLatestSdk[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "name": "CtsTetheringTest[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
       "keywords": ["sim"],
       "options": [
         {
@@ -427,6 +430,9 @@
   "automotive-mumd-presubmit": [
     {
       "name": "CtsNetTestCases"
+    },
+    {
+      "name": "CtsNetTestCasesUpdateStatsPermission"
     }
   ],
   "imports": [
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 5cf5528..091849b 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -58,11 +58,13 @@
         ":framework-connectivity-shared-srcs",
         ":services-tethering-shared-srcs",
         ":statslog-connectivity-java-gen",
+        ":statslog-framework-connectivity-java-gen",
         ":statslog-tethering-java-gen",
     ],
     static_libs: [
         "androidx.annotation_annotation",
         "connectivity-net-module-utils-bpf",
+        "com.android.net.flags-aconfig-java",
         "modules-utils-build",
         "modules-utils-statemachine",
         "networkstack-client",
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 0c05354..531489d 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -47,15 +47,6 @@
 // as the above target may have different "enabled" values
 // depending on the branch
 
-apex_defaults {
-    name: "CronetInTetheringApexDefaults",
-    jni_libs: [
-        "cronet_aml_components_cronet_android_cronet",
-        "//external/cronet/third_party/boringssl:libcrypto",
-        "//external/cronet/third_party/boringssl:libssl",
-    ],
-}
-
 apex {
     name: "com.android.tethering",
     defaults: [
@@ -102,15 +93,14 @@
         "dscpPolicy.o",
         "netd.o",
         "offload.o",
-        "offload@mainline.o",
         "test.o",
-        "test@mainline.o",
     ],
     apps: [
         "ServiceConnectivityResources",
     ],
     prebuilts: [
         "current_sdkinfo",
+        "netbpfload.31rc",
         "netbpfload.33rc",
         "netbpfload.35rc",
         "ot-daemon.34rc",
@@ -155,7 +145,10 @@
         "framework-connectivity",
         "framework-connectivity-t",
         "framework-tethering",
-    ],
+    ] + select(release_flag("RELEASE_MOVE_VCN_TO_MAINLINE"), {
+        true: ["framework-connectivity-b"],
+        default: [],
+    }),
     apex_available: ["com.android.tethering"],
 
     // The bootclasspath_fragments that provide APIs on which this depends.
@@ -195,6 +188,7 @@
             "android.net.http",
             "android.net.netstats",
             "android.net.util",
+            "android.net.vcn",
         ],
 
         // The following packages and all their subpackages currently only
diff --git a/Tethering/apex/canned_fs_config b/Tethering/apex/canned_fs_config
index 1f5fcfa..edc5515 100644
--- a/Tethering/apex/canned_fs_config
+++ b/Tethering/apex/canned_fs_config
@@ -1,3 +1,3 @@
-/bin/for-system 0 1000 0750
+/bin/for-system 1029 1000 0750
 /bin/for-system/clatd 1029 1029 06755
 /bin/netbpfload 0 0 0750
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index e2498e4..d2a8c13 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -111,6 +111,7 @@
         "sdk_module-lib_current_framework-wifi",
     ],
     static_libs: [
+        "modules-utils-build",
         "com.android.net.flags-aconfig-java",
     ],
     aidl: {
diff --git a/Tethering/common/TetheringLib/api/module-lib-current.txt b/Tethering/common/TetheringLib/api/module-lib-current.txt
index 01bd983..3ba8e1b 100644
--- a/Tethering/common/TetheringLib/api/module-lib-current.txt
+++ b/Tethering/common/TetheringLib/api/module-lib-current.txt
@@ -22,7 +22,7 @@
     method public boolean isTetheringSupported(@NonNull String);
     method public void requestLatestTetheringEntitlementResult(int, @NonNull android.os.ResultReceiver, boolean);
     method @Deprecated public int setUsbTethering(boolean);
-    method @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.WRITE_SETTINGS}) public void startTethering(int, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.StartTetheringCallback);
+    method @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void startTethering(int, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.StartTetheringCallback);
     method @Deprecated public int tether(@NonNull String);
     method @Deprecated public int untether(@NonNull String);
   }
diff --git a/Tethering/common/TetheringLib/api/system-current.txt b/Tethering/common/TetheringLib/api/system-current.txt
index 3b9708e..c0c0abc 100644
--- a/Tethering/common/TetheringLib/api/system-current.txt
+++ b/Tethering/common/TetheringLib/api/system-current.txt
@@ -20,9 +20,9 @@
   }
 
   public class TetheringManager {
-    method @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.WRITE_SETTINGS}) public void requestLatestTetheringEntitlementResult(int, boolean, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.OnTetheringEntitlementResultListener);
-    method @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.WRITE_SETTINGS}) public void stopAllTethering();
-    method @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.WRITE_SETTINGS}) public void stopTethering(int);
+    method @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void requestLatestTetheringEntitlementResult(int, boolean, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.OnTetheringEntitlementResultListener);
+    method @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void stopAllTethering();
+    method @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public void stopTethering(int);
     field @Deprecated public static final String ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED";
     field public static final int CONNECTIVITY_SCOPE_LOCAL = 2; // 0x2
     field public static final String EXTRA_ACTIVE_LOCAL_ONLY = "android.net.extra.ACTIVE_LOCAL_ONLY";
diff --git a/Tethering/common/TetheringLib/src/android/net/ITetheringConnector.aidl b/Tethering/common/TetheringLib/src/android/net/ITetheringConnector.aidl
index 77e78bd..7d244e2 100644
--- a/Tethering/common/TetheringLib/src/android/net/ITetheringConnector.aidl
+++ b/Tethering/common/TetheringLib/src/android/net/ITetheringConnector.aidl
@@ -18,6 +18,7 @@
 import android.net.IIntResultListener;
 import android.net.ITetheringEventCallback;
 import android.net.TetheringRequestParcel;
+import android.net.TetheringManager.TetheringRequest;
 import android.os.ResultReceiver;
 
 /** @hide */
@@ -37,6 +38,9 @@
     void stopTethering(int type, String callerPkg, String callingAttributionTag,
             IIntResultListener receiver);
 
+    void stopTetheringRequest(in TetheringRequest request, String callerPkg,
+            String callingAttributionTag, IIntResultListener receiver);
+
     void requestLatestTetheringEntitlementResult(int type, in ResultReceiver receiver,
             boolean showEntitlementUi, String callerPkg, String callingAttributionTag);
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 25bfb45..a8a471d 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -28,6 +28,7 @@
 import android.content.Context;
 import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiManager;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.ConditionVariable;
 import android.os.IBinder;
@@ -36,11 +37,13 @@
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
+import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.flags.Flags;
 
 import java.lang.annotation.Retention;
@@ -332,6 +335,16 @@
     public static final int TETHER_ERROR_UNKNOWN_REQUEST = 17;
     @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
     public static final int TETHER_ERROR_DUPLICATE_REQUEST = 18;
+    /**
+     * Never used outside Tethering.java.
+     * @hide
+     */
+    public static final int TETHER_ERROR_BLUETOOTH_SERVICE_PENDING = 19;
+    /**
+     * Never used outside Tethering.java.
+     * @hide
+     */
+    public static final int TETHER_ERROR_SOFT_AP_CALLBACK_PENDING = 20;
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -388,7 +401,9 @@
         // up and be sent from a worker thread; later, they are always sent from the caller thread.
         // Considering that it's just oneway binder calls, and ordering is preserved, this seems
         // better than inconsistent behavior persisting after boot.
-        if (connector != null) {
+        // If system server restarted, mConnectorSupplier might temporarily return a stale (i.e.
+        // dead) version of TetheringService.
+        if (connector != null && connector.isBinderAlive()) {
             mConnector = ITetheringConnector.Stub.asInterface(connector);
         } else {
             startPollingForConnector();
@@ -423,9 +438,8 @@
                 } catch (InterruptedException e) {
                     // Not much to do here, the system needs to wait for the connector
                 }
-
                 final IBinder connector = mConnectorSupplier.get();
-                if (connector != null) {
+                if (connector != null && connector.isBinderAlive()) {
                     onTetheringConnected(ITetheringConnector.Stub.asInterface(connector));
                     return;
                 }
@@ -656,6 +670,13 @@
         }
     }
 
+    private void unsupportedAfterV() {
+        if (SdkLevel.isAtLeastB()) {
+            throw new UnsupportedOperationException("Not supported after SDK version "
+                    + Build.VERSION_CODES.VANILLA_ICE_CREAM);
+        }
+    }
+
     /**
      * Attempt to tether the named interface.  This will setup a dhcp server
      * on the interface, forward and NAT IP v4 packets and forward DNS requests
@@ -665,8 +686,10 @@
      * access will of course fail until an upstream network interface becomes
      * active.
      *
-     * @deprecated The only usages is PanService. It uses this for legacy reasons
-     * and will migrate away as soon as possible.
+     * @deprecated Legacy tethering API. Callers should instead use
+     *             {@link #startTethering(int, Executor, StartTetheringCallback)}.
+     *             On SDK versions after {@link Build.VERSION_CODES.VANILLA_ICE_CREAM}, this will
+     *             throw an UnsupportedOperationException.
      *
      * @param iface the interface name to tether.
      * @return error a {@code TETHER_ERROR} value indicating success or failure type
@@ -676,6 +699,8 @@
     @Deprecated
     @SystemApi(client = MODULE_LIBRARIES)
     public int tether(@NonNull final String iface) {
+        unsupportedAfterV();
+
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "tether caller:" + callerPkg);
         final RequestDispatcher dispatcher = new RequestDispatcher();
@@ -699,14 +724,18 @@
     /**
      * Stop tethering the named interface.
      *
-     * @deprecated The only usages is PanService. It uses this for legacy reasons
-     * and will migrate away as soon as possible.
+     * @deprecated Legacy tethering API. Callers should instead use
+     *             {@link #stopTethering(int)}.
+     *             On SDK versions after {@link Build.VERSION_CODES.VANILLA_ICE_CREAM}, this will
+     *             throw an UnsupportedOperationException.
      *
      * {@hide}
      */
     @Deprecated
     @SystemApi(client = MODULE_LIBRARIES)
     public int untether(@NonNull final String iface) {
+        unsupportedAfterV();
+
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "untether caller:" + callerPkg);
 
@@ -790,6 +819,46 @@
      */
     @SuppressLint("UnflaggedApi")
     public static final class TetheringRequest implements Parcelable {
+        /**
+         * Tethering started by an explicit call to startTethering.
+         * @hide
+         */
+        public static final int REQUEST_TYPE_EXPLICIT = 0;
+
+        /**
+         * Tethering implicitly started by broadcasts (LOHS and P2P). Can never be pending.
+         * @hide
+         */
+        public static final int REQUEST_TYPE_IMPLICIT = 1;
+
+        /**
+         * Tethering started by the legacy tether() call. Can only happen on V-.
+         * @hide
+         */
+        public static final int REQUEST_TYPE_LEGACY = 2;
+
+        /**
+         * Tethering started but there was no pending request found. This may happen if Tethering is
+         * started and immediately stopped before the link layer goes up, or if we get a link layer
+         * event without a prior call to startTethering (e.g. adb shell cmd wifi start-softap).
+         * @hide
+         */
+        public static final int REQUEST_TYPE_PLACEHOLDER = 3;
+
+        /**
+         * Type of request, used to keep track of whether the request was explicitly sent by
+         * startTethering, implicitly created by broadcasts, or via legacy tether().
+         * @hide
+         */
+        @Retention(RetentionPolicy.SOURCE)
+        @IntDef(prefix = "TYPE_", value = {
+                REQUEST_TYPE_EXPLICIT,
+                REQUEST_TYPE_IMPLICIT,
+                REQUEST_TYPE_LEGACY,
+                REQUEST_TYPE_PLACEHOLDER,
+        })
+        public @interface RequestType {}
+
         /** A configuration set for TetheringRequest. */
         private final TetheringRequestParcel mRequestParcel;
 
@@ -849,6 +918,7 @@
                 mBuilderParcel.uid = Process.INVALID_UID;
                 mBuilderParcel.softApConfig = null;
                 mBuilderParcel.interfaceName = null;
+                mBuilderParcel.requestType = REQUEST_TYPE_EXPLICIT;
             }
 
             /**
@@ -1144,6 +1214,14 @@
         }
 
         /**
+         * Get the type of the request.
+         * @hide
+         */
+        public @RequestType int getRequestType() {
+            return mRequestParcel.requestType;
+        }
+
+        /**
          * String of TetheringRequest detail.
          * @hide
          */
@@ -1151,6 +1229,13 @@
         public String toString() {
             StringJoiner sj = new StringJoiner(", ", "TetheringRequest[ ", " ]");
             sj.add(typeToString(mRequestParcel.tetheringType));
+            if (mRequestParcel.requestType == REQUEST_TYPE_IMPLICIT) {
+                sj.add("IMPLICIT");
+            } else if (mRequestParcel.requestType == REQUEST_TYPE_LEGACY) {
+                sj.add("LEGACY");
+            } else if (mRequestParcel.requestType == REQUEST_TYPE_PLACEHOLDER) {
+                sj.add("PLACEHOLDER");
+            }
             if (mRequestParcel.localIPv4Address != null) {
                 sj.add("localIpv4Address=" + mRequestParcel.localIPv4Address);
             }
@@ -1179,6 +1264,44 @@
             return sj.toString();
         }
 
+        @SuppressLint("UnflaggedApi")
+        private static boolean supportsInterfaceName(int tetheringType) {
+            // TODO: Check the interface name for TETHERING_WIFI and TETHERING_WIFI_P2P once
+            //       they're actually used.
+            // Suppress lint for TETHERING_VIRTUAL since this method is only used internally.
+            return tetheringType == TETHERING_VIRTUAL;
+        }
+
+        private static boolean supportsConcurrentConnectivityScopes(int tetheringType) {
+            // Currently, only WIFI supports simultaneous local and global connectivity.
+            // This can't happen for REQUEST_TYPE_EXPLICIT requests, because
+            // TetheringRequest.Builder will not allow building an explicit TetheringRequest
+            // with TETHERING_WIFI and CONNECTIVITY_SCOPE_LOCAL, but when local-only hotspot
+            // is running, there is a REQUEST_TYPE_IMPLICIT request in the serving request list.
+            return tetheringType == TETHERING_WIFI;
+        }
+
+        /**
+         * Returns true if the other TetheringRequest "fuzzy" matches this one. This is used
+         * internally to match tracked requests with external requests from API calls, and to reject
+         * additional requests that the link layer has no capacity for.
+         * @hide
+         */
+        public boolean fuzzyMatches(final TetheringRequest other) {
+            if (other == null) return false;
+            final int type = getTetheringType();
+            if (type != other.getTetheringType()) return false;
+            if (supportsInterfaceName(type)
+                    && !TextUtils.equals(getInterfaceName(), other.getInterfaceName())) {
+                return false;
+            }
+            if (supportsConcurrentConnectivityScopes(type)
+                    && getConnectivityScope() != other.getConnectivityScope()) {
+                return false;
+            }
+            return true;
+        }
+
         /**
          * @hide
          */
@@ -1187,17 +1310,29 @@
         public boolean equals(Object obj) {
             if (this == obj) return true;
             if (!(obj instanceof TetheringRequest otherRequest)) return false;
+            if (!equalsIgnoreUidPackage(otherRequest)) return false;
             TetheringRequestParcel parcel = getParcel();
             TetheringRequestParcel otherParcel = otherRequest.getParcel();
-            return parcel.tetheringType == otherParcel.tetheringType
+            return parcel.uid == otherParcel.uid
+                    && Objects.equals(parcel.packageName, otherParcel.packageName);
+        }
+
+        /**
+         * @hide
+         */
+        public boolean equalsIgnoreUidPackage(TetheringRequest otherRequest) {
+            TetheringRequestParcel parcel = getParcel();
+            TetheringRequestParcel otherParcel = otherRequest.getParcel();
+            // Note: Changes here should also be reflected in fuzzyMatches(TetheringRequest) when
+            //       appropriate.
+            return parcel.requestType == otherParcel.requestType
+                    && parcel.tetheringType == otherParcel.tetheringType
                     && Objects.equals(parcel.localIPv4Address, otherParcel.localIPv4Address)
                     && Objects.equals(parcel.staticClientAddress, otherParcel.staticClientAddress)
                     && parcel.exemptFromEntitlementCheck == otherParcel.exemptFromEntitlementCheck
                     && parcel.showProvisioningUi == otherParcel.showProvisioningUi
                     && parcel.connectivityScope == otherParcel.connectivityScope
                     && Objects.equals(parcel.softApConfig, otherParcel.softApConfig)
-                    && parcel.uid == otherParcel.uid
-                    && Objects.equals(parcel.packageName, otherParcel.packageName)
                     && Objects.equals(parcel.interfaceName, otherParcel.interfaceName);
         }
 
@@ -1290,18 +1425,12 @@
      * Starts tethering and runs tether provisioning for the given type if needed. If provisioning
      * fails, stopTethering will be called automatically.
      *
-     * <p>Without {@link android.Manifest.permission.TETHER_PRIVILEGED} permission, the call will
-     * fail if a tethering entitlement check is required.
-     *
      * @param type The tethering type, on of the {@code TetheringManager#TETHERING_*} constants.
      * @param executor {@link Executor} to specify the thread upon which the callback of
      *         TetheringRequest will be invoked.
      * @hide
      */
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     @SystemApi(client = MODULE_LIBRARIES)
     public void startTethering(int type, @NonNull final Executor executor,
             @NonNull final StartTetheringCallback callback) {
@@ -1312,14 +1441,9 @@
      * Stops tethering for the given type. Also cancels any provisioning rechecks for that type if
      * applicable.
      *
-     * <p>Without {@link android.Manifest.permission.TETHER_PRIVILEGED} permission, the call will
-     * fail if a tethering entitlement check is required.
      * @hide
      */
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     @SystemApi
     public void stopTethering(@TetheringType final int type) {
         final String callerPkg = mContext.getOpPackageName();
@@ -1345,7 +1469,25 @@
     @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
     public void stopTethering(@NonNull TetheringRequest request,
             @NonNull final Executor executor, @NonNull final StopTetheringCallback callback) {
-        throw new UnsupportedOperationException();
+        Objects.requireNonNull(request);
+        Objects.requireNonNull(executor);
+        Objects.requireNonNull(callback);
+
+        final String callerPkg = mContext.getOpPackageName();
+        Log.i(TAG, "stopTethering: request=" + request + ", caller=" + callerPkg);
+        getConnector(c -> c.stopTetheringRequest(request, callerPkg, getAttributionTag(),
+                new IIntResultListener.Stub() {
+                    @Override
+                    public void onResult(final int resultCode) {
+                        executor.execute(() -> {
+                            if (resultCode == TETHER_ERROR_NO_ERROR) {
+                                callback.onStopTetheringSucceeded();
+                            } else {
+                                callback.onStopTetheringFailed(resultCode);
+                            }
+                        });
+                    }
+                }));
     }
 
     /**
@@ -1374,9 +1516,6 @@
      * {@link #TETHER_ERROR_ENTITLEMENT_UNKNOWN} will be returned. If {@code showEntitlementUi} is
      * true, entitlement will be run.
      *
-     * <p>Without {@link android.Manifest.permission.TETHER_PRIVILEGED} permission, the call will
-     * fail if a tethering entitlement check is required.
-     *
      * @param type the downstream type of tethering. Must be one of {@code #TETHERING_*} constants.
      * @param showEntitlementUi a boolean indicating whether to check result for the UI-based
      *         entitlement check or the silent entitlement check.
@@ -1387,10 +1526,7 @@
      * @hide
      */
     @SystemApi
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     public void requestLatestTetheringEntitlementResult(@TetheringType int type,
             boolean showEntitlementUi,
             @NonNull Executor executor,
@@ -2073,10 +2209,7 @@
      * @hide
      */
     @SystemApi
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     public void stopAllTethering() {
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "stopAllTethering caller:" + callerPkg);
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
index 97c9b9a..9863385 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
@@ -24,6 +24,7 @@
  * @hide
  */
 parcelable TetheringRequestParcel {
+    int requestType;
     int tetheringType;
     LinkAddress localIPv4Address;
     LinkAddress staticClientAddress;
diff --git a/Tethering/proguard.flags b/Tethering/proguard.flags
index 47e2848..6d857b1 100644
--- a/Tethering/proguard.flags
+++ b/Tethering/proguard.flags
@@ -1,3 +1,6 @@
+# Keep JNI registered methods
+-keepclasseswithmembers,includedescriptorclasses class * { native <methods>; }
+
 # Keep class's integer static field for MessageUtils to parsing their name.
 -keepclassmembers class com.android.server.**,android.net.**,com.android.networkstack.** {
     static final % POLICY_*;
@@ -7,18 +10,6 @@
     static final % EVENT_*;
 }
 
--keep class com.android.networkstack.tethering.util.BpfMap {
-    native <methods>;
-}
-
--keep class com.android.networkstack.tethering.util.TcUtils {
-    native <methods>;
-}
-
--keep class com.android.networkstack.tethering.util.TetheringUtils {
-    native <methods>;
-}
-
 # Ensure runtime-visible field annotations are kept when using R8 full mode.
 -keepattributes RuntimeVisibleAnnotations,AnnotationDefault
 -keep interface com.android.networkstack.tethering.util.Struct$Field {
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index ebc9e4e..a651b1b 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -16,14 +16,22 @@
 
 package android.net.ip;
 
+import static android.net.INetd.LOCAL_NET_ID;
 import static android.net.RouteInfo.RTN_UNICAST;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
+import static android.net.TetheringManager.TETHERING_BLUETOOTH;
+import static android.net.TetheringManager.TETHERING_ETHERNET;
+import static android.net.TetheringManager.TETHERING_NCM;
+import static android.net.TetheringManager.TETHERING_WIFI;
+import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHERING_WIGIG;
 import static android.net.TetheringManager.TETHER_ERROR_DHCPSERVER_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_ENABLE_FORWARDING_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_IFACE_CFG_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_INTERNAL_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
+import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
 import static android.net.TetheringManager.TETHER_ERROR_TETHER_IFACE_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_UNTETHER_IFACE_ERROR;
 import static android.net.TetheringManager.TetheringRequest.checkStaticAddressConfiguration;
@@ -33,20 +41,25 @@
 
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHERING_LOCAL_NETWORK_AGENT;
 import static com.android.networkstack.tethering.TetheringConfiguration.USE_SYNC_SM;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 import static com.android.networkstack.tethering.util.TetheringMessageBase.BASE_IPSERVER;
+import static com.android.networkstack.tethering.util.TetheringUtils.getTransportTypeForTetherableType;
 
+import android.annotation.SuppressLint;
+import android.content.Context;
 import android.net.INetd;
 import android.net.INetworkStackStatusCallback;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.MacAddress;
+import android.net.NetworkAgent;
 import android.net.RouteInfo;
 import android.net.TetheredClient;
-import android.net.TetheringManager;
 import android.net.TetheringManager.TetheringRequest;
+import android.net.connectivity.ConnectivityInternalApiUtil;
 import android.net.dhcp.DhcpLeaseParcelable;
 import android.net.dhcp.DhcpServerCallbacks;
 import android.net.dhcp.DhcpServingParamsParcel;
@@ -54,7 +67,9 @@
 import android.net.dhcp.IDhcpEventCallbacks;
 import android.net.dhcp.IDhcpServer;
 import android.net.ip.RouterAdvertisementDaemon.RaParams;
+import android.os.Build;
 import android.os.Handler;
+import android.os.Looper;
 import android.os.Message;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -64,11 +79,13 @@
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.MessageUtils;
 import com.android.internal.util.State;
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.IIpv4PrefixRequest;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
@@ -168,10 +185,10 @@
         /**
          * Request Tethering change.
          *
-         * @param request the TetheringRequest this IpServer was enabled with.
+         * @param tetheringType the downstream type of this IpServer.
          * @param enabled enable or disable tethering.
          */
-        public void requestEnableTethering(TetheringRequest request, boolean enabled) { }
+        public void requestEnableTethering(int tetheringType, boolean enabled) { }
     }
 
     /** Capture IpServer dependencies, for injection. */
@@ -198,6 +215,23 @@
         /** Create a DhcpServer instance to be used by IpServer. */
         public abstract void makeDhcpServer(String ifName, DhcpServingParamsParcel params,
                 DhcpServerCallbacks cb);
+
+        /**
+         * @see DeviceConfigUtils#isTetheringFeatureEnabled
+         */
+        public boolean isFeatureEnabled(Context context, String name) {
+            return DeviceConfigUtils.isTetheringFeatureEnabled(context, name);
+        }
+
+        /** Create a NetworkAgent instance to be used by IpServer. */
+        @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+        @SuppressLint("NewApi")
+        public NetworkAgent makeNetworkAgent(
+                @NonNull Context context, @NonNull Looper looper, @NonNull String logTag,
+                int interfaceType, @NonNull LinkProperties lp) {
+            return ConnectivityInternalApiUtil.buildTetheringNetworkAgent(
+                    context, looper, logTag, getTransportTypeForTetherableType(interfaceType), lp);
+        }
     }
 
     // request from the user that it wants to tether
@@ -298,16 +332,28 @@
 
     private final TetheringMetrics mTetheringMetrics;
     private final Handler mHandler;
+    private final Context mContext;
+
+    private final boolean mSupportLocalAgent;
+
+    // This will be null if the TetheredState is not entered or feature not supported.
+    // This will be only accessed from the IpServer handler thread.
+    private NetworkAgent mTetheringAgent;
+
+    private static boolean everRegistered(@NonNull NetworkAgent agent) {
+        return agent.getNetwork() != null;
+    }
 
     // TODO: Add a dependency object to pass the data members or variables from the tethering
     // object. It helps to reduce the arguments of the constructor.
     public IpServer(
-            String ifaceName, Handler handler, int interfaceType, SharedLog log,
-            INetd netd, @NonNull BpfCoordinator bpfCoordinator,
+            String ifaceName, @NonNull Context context, Handler handler, int interfaceType,
+            SharedLog log, INetd netd, @NonNull BpfCoordinator bpfCoordinator,
             RoutingCoordinatorManager routingCoordinatorManager, Callback callback,
             TetheringConfiguration config,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
         super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
+        mContext = Objects.requireNonNull(context);
         mHandler = handler;
         mLog = log.forSubComponent(ifaceName);
         mNetd = netd;
@@ -333,6 +379,10 @@
         mLastError = TETHER_ERROR_NO_ERROR;
         mServingMode = STATE_AVAILABLE;
 
+        // Tethering network agent is supported on V+, and will be rolled out gradually.
+        mSupportLocalAgent = SdkLevel.isAtLeastV()
+                && mDeps.isFeatureEnabled(mContext, TETHERING_LOCAL_NETWORK_AGENT);
+
         mInitialState = new InitialState();
         mLocalHotspotState = new LocalHotspotState();
         mTetheredState = new TetheredState();
@@ -409,12 +459,6 @@
         return mIpv4PrefixRequest;
     }
 
-    /** The TetheringRequest the IpServer started with. */
-    @Nullable
-    public TetheringRequest getTetheringRequest() {
-        return mTetheringRequest;
-    }
-
     /**
      * Get the latest list of DHCP leases that was reported. Must be called on the IpServer looper
      * thread.
@@ -423,9 +467,11 @@
         return Collections.unmodifiableList(mDhcpLeases);
     }
 
-    /** Enable this IpServer. IpServer state machine will be tethered or localHotspot state. */
-    public void enable(final int requestedState, final TetheringRequest request) {
-        sendMessage(CMD_TETHER_REQUESTED, requestedState, 0, request);
+    /**
+     * Enable this IpServer. IpServer state machine will be tethered or localHotspot state based on
+     * the connectivity scope of the TetheringRequest. */
+    public void enable(@NonNull final TetheringRequest request) {
+        sendMessage(CMD_TETHER_REQUESTED, 0, 0, request);
     }
 
     /** Stop this IpServer. After this is called this IpServer should not be used any more. */
@@ -589,8 +635,8 @@
             @NonNull final Inet4Address dnsServer, @NonNull LinkAddress serverAddr,
             @Nullable Inet4Address clientAddr) {
         final boolean changePrefixOnDecline =
-                (mInterfaceType == TetheringManager.TETHERING_NCM && clientAddr == null);
-        final int subnetPrefixLength = mInterfaceType == TetheringManager.TETHERING_WIFI_P2P
+                (mInterfaceType == TETHERING_NCM && clientAddr == null);
+        final int subnetPrefixLength = mInterfaceType == TETHERING_WIFI_P2P
                 ? mP2pLeasesSubnetPrefixLength : 0 /* default value */;
 
         return new DhcpServingParamsParcelExt()
@@ -690,10 +736,10 @@
         final IpPrefix ipv4Prefix = asIpPrefix(mIpv4Address);
 
         final Boolean setIfaceUp;
-        if (mInterfaceType == TetheringManager.TETHERING_WIFI
-                || mInterfaceType == TetheringManager.TETHERING_WIFI_P2P
-                || mInterfaceType == TetheringManager.TETHERING_ETHERNET
-                || mInterfaceType == TetheringManager.TETHERING_WIGIG) {
+        if (mInterfaceType == TETHERING_WIFI
+                || mInterfaceType == TETHERING_WIFI_P2P
+                || mInterfaceType == TETHERING_ETHERNET
+                || mInterfaceType == TETHERING_WIGIG) {
             // The WiFi and Ethernet stack has ownership of the interface up/down state.
             // It is unclear whether the Bluetooth or USB stacks will manage their own
             // state.
@@ -719,12 +765,12 @@
 
     private boolean shouldNotConfigureBluetoothInterface() {
         // Before T, bluetooth tethering configures the interface elsewhere.
-        return (mInterfaceType == TetheringManager.TETHERING_BLUETOOTH) && !SdkLevel.isAtLeastT();
+        return (mInterfaceType == TETHERING_BLUETOOTH) && !SdkLevel.isAtLeastT();
     }
 
     private boolean shouldUseWifiP2pDedicatedIp() {
         return mIsWifiP2pDedicatedIpEnabled
-                && mInterfaceType == TetheringManager.TETHERING_WIFI_P2P;
+                && mInterfaceType == TETHERING_WIFI_P2P;
     }
 
     private LinkAddress requestIpv4Address(final int scope, final boolean useLastAddress) {
@@ -785,6 +831,7 @@
     //
     // TODO: Evaluate using a data structure than is more directly suited to
     // communicating only the relevant information.
+    @SuppressLint("NewApi")
     private void updateUpstreamIPv6LinkProperties(LinkProperties v6only, int ttlAdjustment) {
         if (mRaDaemon == null) return;
 
@@ -844,15 +891,12 @@
         }
     }
 
-    private void removeRoutesFromLocalNetwork(@NonNull final List<RouteInfo> toBeRemoved) {
-        final int removalFailures = NetdUtils.removeRoutesFromLocalNetwork(
-                mNetd, toBeRemoved);
+    private void removeRoutesFromNetwork(int netId, @NonNull final List<RouteInfo> toBeRemoved) {
+        final int removalFailures = NetdUtils.removeRoutesFromNetwork(mNetd, netId, toBeRemoved);
         if (removalFailures > 0) {
-            mLog.e(String.format("Failed to remove %d IPv6 routes from local table.",
-                    removalFailures));
+            mLog.e("Failed to remove " + removalFailures
+                    + " IPv6 routes from network " + netId + ".");
         }
-
-        for (RouteInfo route : toBeRemoved) mLinkProperties.removeRoute(route);
     }
 
     private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
@@ -879,27 +923,31 @@
         }
     }
 
-    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+    private void addRoutesToNetwork(int netId,
+            @NonNull final List<RouteInfo> toBeAdded) {
         // It's safe to call addInterfaceToNetwork() even if
-        // the interface is already in the local_network.
-        addInterfaceToNetwork(INetd.LOCAL_NET_ID, mIfaceName);
+        // the interface is already in the network.
+        addInterfaceToNetwork(netId, mIfaceName);
         try {
             // Add routes from local network. Note that adding routes that
             // already exist does not cause an error (EEXIST is silently ignored).
-            NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
+            NetdUtils.addRoutesToNetwork(mNetd, netId, mIfaceName, toBeAdded);
         } catch (IllegalStateException e) {
             mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
             return;
         }
-
-        for (RouteInfo route : toBeAdded) mLinkProperties.addRoute(route);
     }
 
     private void configureLocalIPv6Routes(
             ArraySet<IpPrefix> deprecatedPrefixes, ArraySet<IpPrefix> newPrefixes) {
         // [1] Remove the routes that are deprecated.
         if (!deprecatedPrefixes.isEmpty()) {
-            removeRoutesFromLocalNetwork(getLocalRoutesFor(mIfaceName, deprecatedPrefixes));
+            final List<RouteInfo> routesToBeRemoved =
+                    getLocalRoutesFor(mIfaceName, deprecatedPrefixes);
+            if (mTetheringAgent == null) {
+                removeRoutesFromNetwork(LOCAL_NET_ID, routesToBeRemoved);
+            }
+            for (RouteInfo route : routesToBeRemoved) mLinkProperties.removeRoute(route);
         }
 
         // [2] Add only the routes that have not previously been added.
@@ -910,7 +958,12 @@
             }
 
             if (!addedPrefixes.isEmpty()) {
-                addRoutesToLocalNetwork(getLocalRoutesFor(mIfaceName, addedPrefixes));
+                final List<RouteInfo> routesToBeAdded =
+                        getLocalRoutesFor(mIfaceName, addedPrefixes);
+                if (mTetheringAgent == null) {
+                    addRoutesToNetwork(LOCAL_NET_ID, routesToBeAdded);
+                }
+                for (RouteInfo route : routesToBeAdded) mLinkProperties.addRoute(route);
             }
         }
     }
@@ -1016,11 +1069,11 @@
         mLinkProperties.setInterfaceName(mIfaceName);
     }
 
-    private void maybeConfigureStaticIp(final TetheringRequest request) {
+    private void maybeConfigureStaticIp(@NonNull final TetheringRequest request) {
         // Ignore static address configuration if they are invalid or null. In theory, static
         // addresses should not be invalid here because TetheringManager do not allow caller to
         // specify invalid static address configuration.
-        if (request == null || request.getLocalIpv4Address() == null
+        if (request.getLocalIpv4Address() == null
                 || request.getClientStaticIpv4Address() == null || !checkStaticAddressConfiguration(
                 request.getLocalIpv4Address(), request.getClientStaticIpv4Address())) {
             return;
@@ -1043,13 +1096,13 @@
                 case CMD_TETHER_REQUESTED:
                     mLastError = TETHER_ERROR_NO_ERROR;
                     mTetheringRequest = (TetheringRequest) message.obj;
-                    switch (message.arg1) {
-                        case STATE_LOCAL_ONLY:
-                            maybeConfigureStaticIp((TetheringRequest) message.obj);
+                    switch (mTetheringRequest.getConnectivityScope()) {
+                        case CONNECTIVITY_SCOPE_LOCAL:
+                            maybeConfigureStaticIp(mTetheringRequest);
                             transitionTo(mLocalHotspotState);
                             break;
-                        case STATE_TETHERED:
-                            maybeConfigureStaticIp((TetheringRequest) message.obj);
+                        case CONNECTIVITY_SCOPE_GLOBAL:
+                            maybeConfigureStaticIp(mTetheringRequest);
                             transitionTo(mTetheredState);
                             break;
                         default:
@@ -1107,14 +1160,45 @@
             return CONNECTIVITY_SCOPE_LOCAL;
         }
 
+        @SuppressLint("NewApi")
         private void startServingInterface() {
+            // TODO: Enable Network Agent for Wifi P2P Group Owner mode when Network Agent
+            //  for Group Client mode is supported.
+            if (mSupportLocalAgent && getScope() == CONNECTIVITY_SCOPE_GLOBAL) {
+                try {
+                    mTetheringAgent = mDeps.makeNetworkAgent(mContext, Looper.myLooper(), TAG,
+                            mInterfaceType, mLinkProperties);
+                    // Entering CONNECTING state, the ConnectivityService will create the
+                    // native network.
+                    mTetheringAgent.register();
+                } catch (RuntimeException e) {
+                    mLog.e("Error Creating Local Network", e);
+                    // If an exception occurs during the creation or registration of the
+                    // NetworkAgent, it typically indicates a problem with the system services.
+                    mLastError = TETHER_ERROR_SERVICE_UNAVAIL;
+                    return;
+                }
+            }
+
             if (!startIPv4(getScope())) {
                 mLastError = TETHER_ERROR_IFACE_CFG_ERROR;
                 return;
             }
 
             try {
-                NetdUtils.tetherInterface(mNetd, mIfaceName, asIpPrefix(mIpv4Address));
+                // Enable IPv6, disable accepting RA, etc. See TetherController::tetherInterface()
+                // for more detail.
+                mNetd.tetherInterfaceAdd(mIfaceName);
+                if (mTetheringAgent == null) {
+                    NetdUtils.networkAddInterface(mNetd, LOCAL_NET_ID, mIfaceName,
+                            20 /* maxAttempts */, 50 /* pollingIntervalMs */);
+                    // Activate a route to dest and IPv6 link local.
+                    NetdUtils.modifyRoute(mNetd, NetdUtils.ModifyOperation.ADD, LOCAL_NET_ID,
+                            new RouteInfo(asIpPrefix(mIpv4Address), null, mIfaceName, RTN_UNICAST));
+                    NetdUtils.modifyRoute(mNetd, NetdUtils.ModifyOperation.ADD, LOCAL_NET_ID,
+                            new RouteInfo(new IpPrefix("fe80::/64"), null, mIfaceName,
+                                    RTN_UNICAST));
+                }
             } catch (RemoteException | ServiceSpecificException | IllegalStateException e) {
                 mLog.e("Error Tethering", e);
                 mLastError = TETHER_ERROR_TETHER_IFACE_ERROR;
@@ -1126,17 +1210,32 @@
                 // TODO: Make this a fatal error once Bluetooth IPv6 is sorted.
                 return;
             }
+
+            if (mTetheringAgent != null && everRegistered(mTetheringAgent)) {
+                mTetheringAgent.sendLinkProperties(mLinkProperties);
+                // Mark it connected to notify the applications for
+                // the network availability.
+                mTetheringAgent.markConnected();
+            }
         }
 
         @Override
+        @SuppressLint("NewApi")
         public void exit() {
             // Note that at this point, we're leaving the tethered state.  We can fail any
             // of these operations, but it doesn't really change that we have to try them
             // all in sequence.
             stopIPv6();
 
+            // Reset interface for tethering.
             try {
-                NetdUtils.untetherInterface(mNetd, mIfaceName);
+                try {
+                    mNetd.tetherInterfaceRemove(mIfaceName);
+                } finally {
+                    if (mTetheringAgent == null) {
+                        mNetd.networkRemoveInterface(LOCAL_NET_ID, mIfaceName);
+                    }
+                }
             } catch (RemoteException | ServiceSpecificException e) {
                 mLastError = TETHER_ERROR_UNTETHER_IFACE_ERROR;
                 mLog.e("Failed to untether interface: " + e);
@@ -1145,6 +1244,11 @@
             stopIPv4();
             mBpfCoordinator.removeIpServer(IpServer.this);
 
+            if (mTetheringAgent != null && everRegistered(mTetheringAgent)) {
+                mTetheringAgent.unregister();
+                mTetheringAgent = null;
+            }
+
             resetLinkProperties();
 
             mTetheringMetrics.updateErrorCode(mInterfaceType, mLastError);
@@ -1164,6 +1268,12 @@
                     break;
                 case CMD_IPV6_TETHER_UPDATE:
                     updateUpstreamIPv6LinkProperties((LinkProperties) message.obj, message.arg1);
+                    // Sends update to the NetworkAgent.
+                    // TODO: Refactor the callers of sendLinkProperties()
+                    //  and move these code into sendLinkProperties().
+                    if (mTetheringAgent != null && everRegistered(mTetheringAgent)) {
+                        mTetheringAgent.sendLinkProperties(mLinkProperties);
+                    }
                     sendLinkProperties();
                     break;
                 case CMD_IP_FORWARDING_ENABLE_ERROR:
@@ -1178,8 +1288,8 @@
                     handleNewPrefixRequest((IpPrefix) message.obj);
                     break;
                 case CMD_NOTIFY_PREFIX_CONFLICT:
-                    mLog.i("restart tethering: " + mIfaceName);
-                    mCallback.requestEnableTethering(mTetheringRequest, false /* enabled */);
+                    mLog.i("restart tethering: " + mInterfaceType);
+                    mCallback.requestEnableTethering(mInterfaceType, false /* enabled */);
                     transitionTo(mWaitingForRestartState);
                     break;
                 case CMD_SERVICE_FAILED_TO_START:
@@ -1213,14 +1323,21 @@
                 return;
             }
 
-            // Remove deprecated routes from local network.
-            removeRoutesFromLocalNetwork(
-                    Collections.singletonList(getDirectConnectedRoute(deprecatedLinkAddress)));
+            // Remove deprecated routes from downstream network.
+            final List<RouteInfo> routesToBeRemoved =
+                    List.of(getDirectConnectedRoute(deprecatedLinkAddress));
+            if (mTetheringAgent == null) {
+                removeRoutesFromNetwork(LOCAL_NET_ID, routesToBeRemoved);
+            }
+            for (RouteInfo route : routesToBeRemoved) mLinkProperties.removeRoute(route);
             mLinkProperties.removeLinkAddress(deprecatedLinkAddress);
 
-            // Add new routes to local network.
-            addRoutesToLocalNetwork(
-                    Collections.singletonList(getDirectConnectedRoute(mIpv4Address)));
+            // Add new routes to downstream network.
+            final List<RouteInfo> routesToBeAdded = List.of(getDirectConnectedRoute(mIpv4Address));
+            if (mTetheringAgent == null) {
+                addRoutesToNetwork(LOCAL_NET_ID, routesToBeAdded);
+            }
+            for (RouteInfo route : routesToBeAdded) mLinkProperties.addRoute(route);
             mLinkProperties.addLinkAddress(mIpv4Address);
 
             // Update local DNS caching server with new IPv4 address, otherwise, dnsmasq doesn't
@@ -1232,6 +1349,10 @@
                 mLog.e("Failed to update local DNS caching server");
                 return;
             }
+            // Sends update to the NetworkAgent.
+            if (mTetheringAgent != null && everRegistered(mTetheringAgent)) {
+                mTetheringAgent.sendLinkProperties(mLinkProperties);
+            }
             sendLinkProperties();
 
             // Notify DHCP server that new prefix/route has been applied on IpServer.
@@ -1463,12 +1584,12 @@
                 case CMD_TETHER_UNREQUESTED:
                     transitionTo(mInitialState);
                     mLog.i("Untethered (unrequested) and restarting " + mIfaceName);
-                    mCallback.requestEnableTethering(mTetheringRequest, true /* enabled */);
+                    mCallback.requestEnableTethering(mInterfaceType, true /* enabled */);
                     break;
                 case CMD_INTERFACE_DOWN:
                     transitionTo(mUnavailableState);
                     mLog.i("Untethered (interface down) and restarting " + mIfaceName);
-                    mCallback.requestEnableTethering(mTetheringRequest, true /* enabled */);
+                    mCallback.requestEnableTethering(mInterfaceType, true /* enabled */);
                     break;
                 default:
                     return false;
diff --git a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
index a942166..900b505 100644
--- a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
+++ b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
@@ -62,6 +62,7 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.FrameworkConnectivityStatsLog;
 import com.android.net.module.util.SharedLog;
 
 import java.io.PrintWriter;
@@ -154,14 +155,27 @@
 
             // Only launch entitlement UI for the current user if it is allowed to
             // change tethering. This usually means the system user or the admin users in HSUM.
-            // TODO (b/382624069): Figure out whether it is safe to call createContextAsUser
-            //  from secondary user. And re-enable the check or remove the code accordingly.
-            if (false) {
+            if (SdkLevel.isAtLeastT()) {
                 // Create a user context for the current foreground user as UserManager#isAdmin()
                 // operates on the context user.
                 final int currentUserId = getCurrentUser();
                 final UserHandle currentUser = UserHandle.of(currentUserId);
-                final Context userContext = mContext.createContextAsUser(currentUser, 0);
+                final Context userContext;
+                try {
+                    // There is no safe way to invoke this method since tethering package
+                    // might not be installed for a certain user on the OEM devices,
+                    // refer to b/382628161.
+                    userContext = mContext.createContextAsUser(currentUser, 0);
+                } catch (IllegalStateException e) {
+                    FrameworkConnectivityStatsLog.write(
+                            FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                            FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_ENTITLEMENT_CREATE_CONTEXT_AS_USER_THROWS
+                    );
+                    // Fallback to startActivity if createContextAsUser failed.
+                    mLog.e("createContextAsUser failed, fallback to startActivity", e);
+                    mContext.startActivity(intent);
+                    return intent;
+                }
                 final UserManager userManager = userContext.getSystemService(UserManager.class);
 
                 if (userManager.isAdminUser()) {
diff --git a/Tethering/src/com/android/networkstack/tethering/RequestTracker.java b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
new file mode 100644
index 0000000..c91ff58
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2025 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.networkstack.tethering;
+
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
+
+import android.net.TetheringManager.TetheringRequest;
+import android.net.ip.IpServer;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Helper class to keep track of tethering requests.
+ * The intended usage of this class is
+ * 1) Add a pending request with {@link #addPendingRequest(TetheringRequest)} before asking the link
+ *    layer to start.
+ * 2) When the link layer is up, use {@link #getOrCreatePendingRequest(int)} to get a request to
+ *    start IP serving with.
+ * 3) Remove pending request with {@link #removePendingRequest(TetheringRequest)}.
+ * Note: This class is not thread-safe.
+ */
+public class RequestTracker {
+    private static final String TAG = RequestTracker.class.getSimpleName();
+
+    @NonNull
+    private final boolean mUseFuzzyMatching;
+
+    public RequestTracker(boolean useFuzzyMatching) {
+        mUseFuzzyMatching = useFuzzyMatching;
+    }
+
+    public enum AddResult {
+        /**
+         * Request was successfully added
+         */
+        SUCCESS,
+        /**
+         * Failure indicating that the request could not be added due to a request of the same type
+         * with conflicting parameters already pending. If so, we must stop tethering for the
+         * pending request before trying to add the result again. Only returned on V-.
+         */
+        FAILURE_DUPLICATE_REQUEST_RESTART,
+        /**
+         * Failure indicating that the request could not be added due to a fuzzy-matched request
+         * already pending or serving. Only returned on B+.
+         */
+        FAILURE_DUPLICATE_REQUEST_ERROR,
+    }
+
+    /**
+     * List of pending requests added by {@link #addPendingRequest(TetheringRequest)}
+     * There can be only one per type, since we remove every request of the
+     * same type when we add a request.
+     */
+    private final List<TetheringRequest> mPendingRequests = new ArrayList<>();
+    /**
+     * List of serving requests added by
+     * {@link #promoteRequestToServing(IpServer, TetheringRequest)}.
+     */
+    private final Map<IpServer, TetheringRequest> mServingRequests = new ArrayMap<>();
+
+    @VisibleForTesting
+    List<TetheringRequest> getPendingTetheringRequests() {
+        return new ArrayList<>(mPendingRequests);
+    }
+
+    /**
+     * Adds a pending request or fails with FAILURE_CONFLICTING_REQUEST_FAIL if the request
+     * fuzzy-matches an existing request (either pending or serving).
+     */
+    public AddResult addPendingRequestFuzzyMatched(@NonNull final TetheringRequest newRequest) {
+        List<TetheringRequest> existingRequests = new ArrayList<>();
+        existingRequests.addAll(mServingRequests.values());
+        existingRequests.addAll(mPendingRequests);
+        for (TetheringRequest request : existingRequests) {
+            if (request.fuzzyMatches(newRequest)) {
+                Log.i(TAG, "Cannot add pending request due to existing fuzzy-matched "
+                        + "request: " + request);
+                return AddResult.FAILURE_DUPLICATE_REQUEST_ERROR;
+            }
+        }
+
+        mPendingRequests.add(newRequest);
+        return AddResult.SUCCESS;
+    }
+
+    /**
+     * Add a pending request and listener. The request should be added before asking the link layer
+     * to start, and should be retrieved with {@link #getNextPendingRequest(int)} once the link
+     * layer comes up. The result of the add operation will be returned as an AddResult code.
+     */
+    public AddResult addPendingRequest(@NonNull final TetheringRequest newRequest) {
+        if (mUseFuzzyMatching) {
+            return addPendingRequestFuzzyMatched(newRequest);
+        }
+
+        // Check the existing requests to see if it is OK to add the new request.
+        for (TetheringRequest existingRequest : mPendingRequests) {
+            if (existingRequest.getTetheringType() != newRequest.getTetheringType()) {
+                continue;
+            }
+
+            // Can't add request if there's a request of the same type with different
+            // parameters.
+            if (!existingRequest.equalsIgnoreUidPackage(newRequest)) {
+                return AddResult.FAILURE_DUPLICATE_REQUEST_RESTART;
+            }
+        }
+
+        // Remove the existing pending request of the same type. We already filter out for
+        // conflicting parameters above, so these would have been equivalent anyway (except for
+        // UID).
+        removeAllPendingRequests(newRequest.getTetheringType());
+        mPendingRequests.add(newRequest);
+        return AddResult.SUCCESS;
+    }
+
+    /**
+     * Gets the next pending TetheringRequest of a given type, or creates a placeholder request if
+     * there are none.
+     * Note: There are edge cases where the pending request is absent and we must temporarily
+     * synthesize a placeholder request, such as if stopTethering was called before link
+     * layer went up, or if the link layer goes up without us poking it (e.g. adb shell
+     * cmd wifi start-softap). These placeholder requests only specify the tethering type
+     * and the default connectivity scope.
+     */
+    @NonNull
+    public TetheringRequest getOrCreatePendingRequest(int type) {
+        TetheringRequest pending = getNextPendingRequest(type);
+        if (pending != null) return pending;
+
+        Log.w(TAG, "No pending TetheringRequest for type " + type + " found, creating a"
+                + " placeholder request");
+        return createPlaceholderRequest(type);
+    }
+
+    /**
+     * Same as {@link #getOrCreatePendingRequest(int)} but returns {@code null} if there's no
+     * pending request found.
+     *
+     * @param type Tethering type of the pending request
+     * @return pending request or {@code null} if there are none.
+     */
+    @Nullable
+    public TetheringRequest getNextPendingRequest(int type) {
+        for (TetheringRequest request : mPendingRequests) {
+            if (request.getTetheringType() == type) return request;
+        }
+        return null;
+    }
+
+    /**
+     * Removes all pending requests of the given tethering type.
+     *
+     * @param type Tethering type
+     */
+    public void removeAllPendingRequests(final int type) {
+        mPendingRequests.removeIf(r -> r.getTetheringType() == type);
+    }
+
+    /**
+     * Removes a specific pending request.
+     *
+     * Note: For V-, this will be the same as removeAllPendingRequests to align with historical
+     * behavior.
+     *
+     * @param request Request to be removed
+     */
+    public void removePendingRequest(@NonNull TetheringRequest request) {
+        if (!mUseFuzzyMatching) {
+            // Remove all requests of the same type to match the historical behavior.
+            removeAllPendingRequests(request.getTetheringType());
+            return;
+        }
+
+        mPendingRequests.removeIf(r -> r.equals(request));
+    }
+
+    /**
+     * Removes a tethering request from the pending list and promotes it to serving with the
+     * IpServer that is using it.
+     * Note: If mUseFuzzyMatching is false, then the request will be removed from the pending list,
+     * but it will not be added to serving list.
+     */
+    public void promoteRequestToServing(@NonNull final IpServer ipServer,
+            @NonNull final TetheringRequest tetheringRequest) {
+        removePendingRequest(tetheringRequest);
+        if (!mUseFuzzyMatching) return;
+        mServingRequests.put(ipServer, tetheringRequest);
+    }
+
+
+    /**
+     * Returns the serving request tied to the given IpServer, or null if there is none.
+     * Note: If mUseFuzzyMatching is false, then this will always return null.
+     */
+    @Nullable
+    public TetheringRequest getServingRequest(@NonNull final IpServer ipServer) {
+        return mServingRequests.get(ipServer);
+    }
+
+    /**
+     * Removes the serving request tied to the given IpServer.
+     * Note: If mUseFuzzyMatching is false, then this is a no-op since serving requests are unused
+     * for that configuration.
+     */
+    public void removeServingRequest(@NonNull final IpServer ipServer) {
+        mServingRequests.remove(ipServer);
+    }
+
+    /**
+     * Removes all serving requests of the given tethering type.
+     *
+     * @param type Tethering type
+     */
+    public void removeAllServingRequests(final int type) {
+        mServingRequests.entrySet().removeIf(e -> e.getValue().getTetheringType() == type);
+    }
+
+    @VisibleForTesting
+    List<TetheringRequest> getServingTetheringRequests() {
+        return new ArrayList<>(mServingRequests.values());
+    }
+
+    /**
+     * Returns an existing (pending or serving) request that fuzzy matches the given request.
+     * Optionally specify matchUid to only return requests with the same uid.
+     */
+    public TetheringRequest findFuzzyMatchedRequest(
+            @NonNull final TetheringRequest tetheringRequest, boolean matchUid) {
+        List<TetheringRequest> allRequests = new ArrayList<>();
+        allRequests.addAll(getPendingTetheringRequests());
+        allRequests.addAll(mServingRequests.values());
+        for (TetheringRequest request : allRequests) {
+            if (!request.fuzzyMatches(tetheringRequest)) continue;
+            if (matchUid && tetheringRequest.getUid() != request.getUid()) continue;
+            return request;
+        }
+        return null;
+    }
+}
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 254b60f..0730639 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -28,7 +28,7 @@
 import static android.net.ConnectivityManager.EXTRA_NETWORK_INFO;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
 import static android.net.TetheringManager.ACTION_TETHER_STATE_CHANGED;
-import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
 import static android.net.TetheringManager.EXTRA_ACTIVE_LOCAL_ONLY;
 import static android.net.TetheringManager.EXTRA_ACTIVE_TETHER;
 import static android.net.TetheringManager.EXTRA_AVAILABLE_TETHER;
@@ -42,15 +42,20 @@
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
 import static android.net.TetheringManager.TETHERING_WIGIG;
+import static android.net.TetheringManager.TETHER_ERROR_BLUETOOTH_SERVICE_PENDING;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_INTERNAL_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
+import static android.net.TetheringManager.TETHER_ERROR_SOFT_AP_CALLBACK_PENDING;
 import static android.net.TetheringManager.TETHER_ERROR_UNAVAIL_IFACE;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_IFACE;
+import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_TYPE;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_FAILED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STARTED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STOPPED;
+import static android.net.TetheringManager.TetheringRequest.REQUEST_TYPE_PLACEHOLDER;
 import static android.net.TetheringManager.toIfaces;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_INTERFACE_NAME;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_MODE;
@@ -64,10 +69,21 @@
 import static android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED;
 import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
 
+import static com.android.networkstack.tethering.RequestTracker.AddResult.FAILURE_DUPLICATE_REQUEST_ERROR;
+import static com.android.networkstack.tethering.RequestTracker.AddResult.FAILURE_DUPLICATE_REQUEST_RESTART;
 import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_FORCE_USB_FUNCTIONS;
 import static com.android.networkstack.tethering.TetheringNotificationUpdater.DOWNSTREAM_NONE;
 import static com.android.networkstack.tethering.UpstreamNetworkMonitor.isCellular;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_P2P;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_P2P_SUCCESS;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_SUCCESS;
+import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_TETHER_WITH_PLACEHOLDER_REQUEST;
 import static com.android.networkstack.tethering.util.TetheringMessageBase.BASE_MAIN_SM;
+import static com.android.networkstack.tethering.util.TetheringUtils.createImplicitLocalOnlyTetheringRequest;
+import static com.android.networkstack.tethering.util.TetheringUtils.createLegacyGlobalScopeTetheringRequest;
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
 
 import android.app.usage.NetworkStatsManager;
 import android.bluetooth.BluetoothAdapter;
@@ -99,6 +115,7 @@
 import android.net.TetheringManager.TetheringRequest;
 import android.net.Uri;
 import android.net.ip.IpServer;
+import android.net.wifi.SoftApState;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.p2p.WifiP2pGroup;
@@ -122,7 +139,6 @@
 import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.util.Log;
-import android.util.Pair;
 import android.util.SparseArray;
 
 import androidx.annotation.NonNull;
@@ -140,11 +156,13 @@
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.SharedLog;
+import com.android.net.module.util.TerribleErrorLog;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceCallbackShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceRequestShim;
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
+import com.android.networkstack.tethering.metrics.TetheringStatsLog;
 import com.android.networkstack.tethering.util.InterfaceSet;
 import com.android.networkstack.tethering.util.PrefixUtils;
 import com.android.networkstack.tethering.util.VersionedBroadcastListener;
@@ -229,11 +247,7 @@
     private final SharedLog mLog = new SharedLog(TAG);
     private final RemoteCallbackList<ITetheringEventCallback> mTetheringEventCallbacks =
             new RemoteCallbackList<>();
-    // Currently active tethering requests per tethering type. Only one of each type can be
-    // requested at a time. After a tethering type is requested, the map keeps tethering parameters
-    // to be used after the interface comes up asynchronously.
-    private final SparseArray<TetheringRequest> mActiveTetheringRequests =
-            new SparseArray<>();
+    private final RequestTracker mRequestTracker;
 
     private final Context mContext;
     private final ArrayMap<String, TetherState> mTetherStates;
@@ -280,7 +294,10 @@
     private SettingsObserver mSettingsObserver;
     private BluetoothPan mBluetoothPan;
     private PanServiceListener mBluetoothPanListener;
-    private ArrayList<Pair<Boolean, IIntResultListener>> mPendingPanRequests;
+    // Pending listener for starting Bluetooth tethering before the PAN service is connected. Once
+    // the service is connected, the bluetooth iface will be requested and the listener will be
+    // called.
+    private IIntResultListener mPendingPanRequestListener;
     // AIDL doesn't support Set<Integer>. Maintain a int bitmap here. When the bitmap is passed to
     // TetheringManager, TetheringManager would convert it to a set of Integer types.
     // mSupportedTypeBitmap should always be updated inside tethering internal thread but it may be
@@ -296,11 +313,7 @@
         mLooper = mDeps.makeTetheringLooper();
         mNotificationUpdater = mDeps.makeNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.makeTetheringMetrics(mContext);
-
-        // This is intended to ensrure that if something calls startTethering(bluetooth) just after
-        // bluetooth is enabled. Before onServiceConnected is called, store the calls into this
-        // list and handle them as soon as onServiceConnected is called.
-        mPendingPanRequests = new ArrayList<>();
+        mRequestTracker = new RequestTracker(isTetheringWithSoftApConfigEnabled());
 
         mTetherStates = new ArrayMap<>();
         mConnectedClientsTracker = new ConnectedClientsTracker();
@@ -451,6 +464,10 @@
         return mSettingsObserver;
     }
 
+    boolean isTetheringWithSoftApConfigEnabled() {
+        return mDeps.isTetheringWithSoftApConfigEnabled();
+    }
+
     /**
      * Start to register callbacks.
      * Call this function when tethering is ready to handle callback events.
@@ -592,15 +609,42 @@
     // This method needs to exist because TETHERING_BLUETOOTH before Android T and TETHERING_WIGIG
     // can't use enableIpServing.
     private void processInterfaceStateChange(final String iface, boolean enabled) {
+        final int type = ifaceNameToType(iface);
         // Do not listen to USB interface state changes or USB interface add/removes. USB tethering
         // is driven only by USB_ACTION broadcasts.
-        final int type = ifaceNameToType(iface);
         if (type == TETHERING_USB || type == TETHERING_NCM) return;
 
+        // On T+, BLUETOOTH uses enableIpServing.
         if (type == TETHERING_BLUETOOTH && SdkLevel.isAtLeastT()) return;
 
+        // Cannot happen: on S+, tetherableWigigRegexps is always empty.
+        if (type == TETHERING_WIGIG && SdkLevel.isAtLeastS()) return;
+
+        // After V, disallow this legacy codepath from starting tethering of any type:
+        // everything must call ensureIpServerStarted directly.
+        //
+        // Don't touch the teardown path for now. It's more complicated because:
+        // - ensureIpServerStarted and ensureIpServerStopped act on different
+        //   tethering types.
+        // - Depending on the type, ensureIpServerStopped is either called twice (once
+        //   on interface down and once on interface removed) or just once (on
+        //   interface removed).
+        //
+        // Note that this only affects WIFI and WIFI_P2P. The other types are either
+        // ignored above, or ignored by ensureIpServerStarted. Note that even for WIFI
+        // and WIFI_P2P, this code should not ever run in normal use, because the
+        // hotspot and p2p code do not call tether(). It's possible that this could
+        // happen in the field due to unforeseen OEM modifications. If it does happen,
+        // a terrible error is logged in tether().
+        // TODO: fix the teardown path to stop depending on interface state notifications.
+        // These are not necessary since most/all link layers have their own teardown
+        // notifications, and can race with those notifications.
+        if (enabled && SdkLevel.isAtLeastB()) {
+            return;
+        }
+
         if (enabled) {
-            ensureIpServerStarted(iface);
+            ensureIpServerStartedForInterface(iface);
         } else {
             ensureIpServerStopped(iface);
         }
@@ -661,15 +705,20 @@
             final IIntResultListener listener) {
         mHandler.post(() -> {
             final int type = request.getTetheringType();
-            final TetheringRequest unfinishedRequest = mActiveTetheringRequests.get(type);
-            // If tethering is already enabled with a different request,
-            // disable before re-enabling.
-            if (unfinishedRequest != null && !unfinishedRequest.equals(request)) {
-                enableTetheringInternal(type, false /* disabled */,
-                        unfinishedRequest.getInterfaceName(), null);
-                mEntitlementMgr.stopProvisioningIfNeeded(type);
+            RequestTracker.AddResult result = mRequestTracker.addPendingRequest(request);
+            // If tethering is already pending with a conflicting request, stop tethering before
+            // starting.
+            if (result == FAILURE_DUPLICATE_REQUEST_RESTART) {
+                stopTetheringInternal(type); // Also removes the request from the tracker.
+                mRequestTracker.addPendingRequest(request);
+            } else if (result == FAILURE_DUPLICATE_REQUEST_ERROR) {
+                // Reject any fuzzy matched request.
+                // TODO: Add a CTS test to verify back-to-back start/stop calls succeed. This must
+                // be for a non-Wifi type, since Wifi will reject the start calls if it hasn't
+                // brought down the SoftAP yet.
+                sendTetherResult(listener, TETHER_ERROR_DUPLICATE_REQUEST);
+                return;
             }
-            mActiveTetheringRequests.put(type, request);
 
             if (request.isExemptFromEntitlementCheck()) {
                 mEntitlementMgr.setExemptedDownstreamType(type);
@@ -677,7 +726,7 @@
                 mEntitlementMgr.startProvisioningIfNeeded(type,
                         request.getShouldShowEntitlementUi());
             }
-            enableTetheringInternal(type, true /* enabled */, request.getInterfaceName(), listener);
+            enableTetheringInternal(true /* enabled */, request, listener);
             mTetheringMetrics.createBuilder(type, callerPkg);
         });
     }
@@ -687,10 +736,34 @@
             stopTetheringInternal(type);
         });
     }
-    void stopTetheringInternal(int type) {
-        mActiveTetheringRequests.remove(type);
 
-        enableTetheringInternal(type, false /* disabled */, null, null);
+    void stopTetheringRequest(@NonNull final TetheringRequest request,
+            @NonNull final IIntResultListener listener) {
+        if (!isTetheringWithSoftApConfigEnabled()) return;
+        final boolean hasNetworkSettings = hasCallingPermission(NETWORK_SETTINGS);
+        mHandler.post(() -> {
+            if (mRequestTracker.findFuzzyMatchedRequest(request, !hasNetworkSettings) != null) {
+                final int type = request.getTetheringType();
+                stopTetheringInternal(type);
+                // TODO: We should send the success result after the waiting for tethering to
+                //       actually stop.
+                sendTetherResult(listener, TETHER_ERROR_NO_ERROR);
+                return;
+            }
+
+            // Request doesn't match any active requests, ignore.
+            sendTetherResult(listener, TETHER_ERROR_UNKNOWN_REQUEST);
+        });
+    }
+
+    void stopTetheringInternal(int type) {
+        mRequestTracker.removeAllPendingRequests(type);
+        mRequestTracker.removeAllServingRequests(type);
+
+        // Using a placeholder here is ok since none of the disable APIs use the request for
+        // anything. We simply need the tethering type to know which link layer to poke for removal.
+        // TODO: Remove the placeholder here and loop through each pending/serving request.
+        enableTetheringInternal(false /* disabled */, createPlaceholderRequest(type), null);
         mEntitlementMgr.stopProvisioningIfNeeded(type);
     }
 
@@ -698,18 +771,19 @@
      * Enables or disables tethering for the given type. If provisioning is required, it will
      * schedule provisioning rechecks for the specified interface.
      */
-    private void enableTetheringInternal(int type, boolean enable,
-            String iface, final IIntResultListener listener) {
-        int result = TETHER_ERROR_NO_ERROR;
+    private void enableTetheringInternal(boolean enable, @NonNull final TetheringRequest request,
+            final IIntResultListener listener) {
+        final int type = request.getTetheringType();
+        final int result;
         switch (type) {
             case TETHERING_WIFI:
-                result = setWifiTethering(enable);
+                result = setWifiTethering(enable, request, listener);
                 break;
             case TETHERING_USB:
                 result = setUsbTethering(enable);
                 break;
             case TETHERING_BLUETOOTH:
-                setBluetoothTethering(enable, listener);
+                result = setBluetoothTethering(enable, listener);
                 break;
             case TETHERING_NCM:
                 result = setNcmTethering(enable);
@@ -718,37 +792,45 @@
                 result = setEthernetTethering(enable);
                 break;
             case TETHERING_VIRTUAL:
-                result = setVirtualMachineTethering(enable, iface);
+                result = setVirtualMachineTethering(enable, request);
                 break;
             default:
                 Log.w(TAG, "Invalid tether type.");
                 result = TETHER_ERROR_UNKNOWN_TYPE;
         }
 
-        // The result of Bluetooth tethering will be sent by #setBluetoothTethering.
-        if (type != TETHERING_BLUETOOTH) {
-            sendTetherResult(listener, result, type);
-        }
+        // The result of Bluetooth tethering will be sent after the pan service connects.
+        if (result == TETHER_ERROR_BLUETOOTH_SERVICE_PENDING) return;
+
+        // The result of Wifi tethering will be sent after the SoftApCallback result.
+        if (result == TETHER_ERROR_SOFT_AP_CALLBACK_PENDING) return;
+
+        sendTetherResultAndRemoveOnError(request, listener, result);
     }
 
-    private void sendTetherResult(final IIntResultListener listener, final int result,
-            final int type) {
+    private void sendTetherResult(final IIntResultListener listener, final int result) {
         if (listener != null) {
             try {
                 listener.onResult(result);
-            } catch (RemoteException e) { }
+            } catch (RemoteException e) {
+            }
         }
+    }
 
-        // If changing tethering fail, remove corresponding request
-        // no matter who trigger the start/stop.
+    private void sendTetherResultAndRemoveOnError(TetheringRequest request,
+            final IIntResultListener listener, final int result) {
+        sendTetherResult(listener, result);
+
         if (result != TETHER_ERROR_NO_ERROR) {
-            mActiveTetheringRequests.remove(type);
+            mRequestTracker.removePendingRequest(request);
+            final int type = request.getTetheringType();
             mTetheringMetrics.updateErrorCode(type, result);
             mTetheringMetrics.sendReport(type);
         }
     }
 
-    private int setWifiTethering(final boolean enable) {
+    private int setWifiTethering(final boolean enable, TetheringRequest request,
+            IIntResultListener listener) {
         final long ident = Binder.clearCallingIdentity();
         try {
             final WifiManager mgr = getWifiManager();
@@ -756,8 +838,34 @@
                 mLog.e("setWifiTethering: failed to get WifiManager!");
                 return TETHER_ERROR_SERVICE_UNAVAIL;
             }
-            if ((enable && mgr.startTetheredHotspot(null /* use existing softap config */))
-                    || (!enable && mgr.stopSoftAp())) {
+            final boolean success;
+            if (enable) {
+                if (isTetheringWithSoftApConfigEnabled()) {
+                    // Notes:
+                    // - A call to startTetheredHotspot can only succeed if the SoftAp is idle. If
+                    //   the SoftAp is running or is being disabled, the call will fail.
+                    // - If a call to startTetheredHotspot fails, the callback is immediately called
+                    //   with WIFI_AP_STATE_FAILED and a null interface.
+                    // - If a call to startTetheredHotspot succeeds, the passed-in callback is the
+                    //   only callback that will receive future WIFI_AP_STATE_ENABLED and
+                    //   WIFI_AP_STATE_DISABLED events in the future, until another call to
+                    //   startTetheredHotspot succeeds, at which point the old callback will stop
+                    //   receiving any events.
+                    // - Wifi may decide to restart the hotspot at any time (such as for a CC
+                    //   change), and if it does so, it will send WIFI_AP_STATE_DISABLED and then
+                    //   either WIFI_AP_STATE_ENABLED or (if restarting fails) WIFI_AP_STATE_FAILED.
+                    mgr.startTetheredHotspot(request, mExecutor,
+                            new StartTetheringSoftApCallback(listener));
+                    // Result isn't used since we get the real result via
+                    // StartTetheringSoftApCallback.
+                    return TETHER_ERROR_SOFT_AP_CALLBACK_PENDING;
+                }
+                success = mgr.startTetheredHotspot(null);
+            } else {
+                success = mgr.stopSoftAp();
+            }
+
+            if (success) {
                 return TETHER_ERROR_NO_ERROR;
             }
         } finally {
@@ -767,13 +875,12 @@
         return TETHER_ERROR_INTERNAL_ERROR;
     }
 
-    private void setBluetoothTethering(final boolean enable, final IIntResultListener listener) {
+    private int setBluetoothTethering(final boolean enable, final IIntResultListener listener) {
         final BluetoothAdapter adapter = mDeps.getBluetoothAdapter();
         if (adapter == null || !adapter.isEnabled()) {
             Log.w(TAG, "Tried to enable bluetooth tethering with null or disabled adapter. null: "
                     + (adapter == null));
-            sendTetherResult(listener, TETHER_ERROR_SERVICE_UNAVAIL, TETHERING_BLUETOOTH);
-            return;
+            return TETHER_ERROR_SERVICE_UNAVAIL;
         }
 
         if (mBluetoothPanListener != null && mBluetoothPanListener.isConnected()) {
@@ -781,16 +888,26 @@
             // When bluetooth tethering is enabled, any time a PAN client pairs with this
             // host, bluetooth will bring up a bt-pan interface and notify tethering to
             // enable IP serving.
-            setBluetoothTetheringSettings(mBluetoothPan, enable, listener);
-            return;
+            return setBluetoothTetheringSettings(mBluetoothPan, enable);
         }
 
-        // The reference of IIntResultListener should only exist when application want to start
-        // tethering but tethering is not bound to pan service yet. Even if the calling process
-        // dies, the referenice of IIntResultListener would still keep in mPendingPanRequests. Once
-        // tethering bound to pan service (onServiceConnected) or bluetooth just crash
-        // (onServiceDisconnected), all the references from mPendingPanRequests would be cleared.
-        mPendingPanRequests.add(new Pair(enable, listener));
+        if (!enable) {
+            // The service is not connected. If disabling tethering, there's no point starting
+            // the service just to stop tethering since tethering is not started. Just remove
+            // any pending request to enable tethering, and notify them that they have failed.
+            if (mPendingPanRequestListener != null) {
+                sendTetherResult(mPendingPanRequestListener, TETHER_ERROR_SERVICE_UNAVAIL);
+            }
+            mPendingPanRequestListener = null;
+            return TETHER_ERROR_NO_ERROR;
+        }
+
+        // Only allow one pending request at a time.
+        if (mPendingPanRequestListener != null) {
+            return TETHER_ERROR_SERVICE_UNAVAIL;
+        }
+
+        mPendingPanRequestListener = listener;
 
         // Bluetooth tethering is not a popular feature. To avoid bind to bluetooth pan service all
         // the time but user never use bluetooth tethering. mBluetoothPanListener is created first
@@ -800,6 +917,7 @@
             mBluetoothPanListener = new PanServiceListener();
             adapter.getProfileProxy(mContext, mBluetoothPanListener, BluetoothProfile.PAN);
         }
+        return TETHER_ERROR_BLUETOOTH_SERVICE_PENDING;
     }
 
     private class PanServiceListener implements ServiceListener {
@@ -816,10 +934,15 @@
                 mBluetoothPan = (BluetoothPan) proxy;
                 mIsConnected = true;
 
-                for (Pair<Boolean, IIntResultListener> request : mPendingPanRequests) {
-                    setBluetoothTetheringSettings(mBluetoothPan, request.first, request.second);
+                if (mPendingPanRequestListener != null) {
+                    final int result = setBluetoothTetheringSettings(mBluetoothPan,
+                            true /* enable */);
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            result);
                 }
-                mPendingPanRequests.clear();
+                mPendingPanRequestListener = null;
             });
         }
 
@@ -830,11 +953,13 @@
                 // reachable before next onServiceConnected.
                 mIsConnected = false;
 
-                for (Pair<Boolean, IIntResultListener> request : mPendingPanRequests) {
-                    sendTetherResult(request.second, TETHER_ERROR_SERVICE_UNAVAIL,
-                            TETHERING_BLUETOOTH);
+                if (mPendingPanRequestListener != null) {
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            TETHER_ERROR_SERVICE_UNAVAIL);
                 }
-                mPendingPanRequests.clear();
+                mPendingPanRequestListener = null;
                 mBluetoothIfaceRequest = null;
                 mBluetoothCallback = null;
                 maybeDisableBluetoothIpServing();
@@ -846,8 +971,8 @@
         }
     }
 
-    private void setBluetoothTetheringSettings(@NonNull final BluetoothPan bluetoothPan,
-            final boolean enable, final IIntResultListener listener) {
+    private int setBluetoothTetheringSettings(@NonNull final BluetoothPan bluetoothPan,
+            final boolean enable) {
         if (SdkLevel.isAtLeastT()) {
             changeBluetoothTetheringSettings(bluetoothPan, enable);
         } else {
@@ -856,9 +981,8 @@
 
         // Enabling bluetooth tethering settings can silently fail. Send internal error if the
         // result is not expected.
-        final int result = bluetoothPan.isTetheringOn() == enable
+        return bluetoothPan.isTetheringOn() == enable
                 ? TETHER_ERROR_NO_ERROR : TETHER_ERROR_INTERNAL_ERROR;
-        sendTetherResult(listener, result, TETHERING_BLUETOOTH);
     }
 
     private void changeBluetoothTetheringSettingsPreT(@NonNull final BluetoothPan bluetoothPan,
@@ -904,7 +1028,9 @@
         public void onAvailable(String iface) {
             if (this != mBluetoothCallback) return;
 
-            enableIpServing(TETHERING_BLUETOOTH, iface, getRequestedState(TETHERING_BLUETOOTH));
+            final TetheringRequest request =
+                    mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH);
+            enableIpServing(request, iface);
             mConfiguredBluetoothIface = iface;
         }
 
@@ -959,7 +1085,10 @@
                 // Ethernet callback arrived after Ethernet tethering stopped. Ignore.
                 return;
             }
-            enableIpServing(TETHERING_ETHERNET, iface, getRequestedState(TETHERING_ETHERNET));
+
+            final TetheringRequest request = mRequestTracker.getOrCreatePendingRequest(
+                    TETHERING_ETHERNET);
+            enableIpServing(request, iface);
             mConfiguredEthernetIface = iface;
         }
 
@@ -973,17 +1102,16 @@
         }
     }
 
-    private int setVirtualMachineTethering(final boolean enable, String iface) {
+    private int setVirtualMachineTethering(final boolean enable,
+            @NonNull final TetheringRequest request) {
+        final String iface = request.getInterfaceName();
         if (enable) {
             if (TextUtils.isEmpty(iface)) {
                 mConfiguredVirtualIface = "avf_tap_fixed";
             } else {
                 mConfiguredVirtualIface = iface;
             }
-            enableIpServing(
-                    TETHERING_VIRTUAL,
-                    mConfiguredVirtualIface,
-                    getRequestedState(TETHERING_VIRTUAL));
+            enableIpServing(request, mConfiguredVirtualIface);
         } else if (mConfiguredVirtualIface != null) {
             ensureIpServerStopped(mConfiguredVirtualIface);
             mConfiguredVirtualIface = null;
@@ -991,15 +1119,76 @@
         return TETHER_ERROR_NO_ERROR;
     }
 
-    void tether(String iface, int requestedState, final IIntResultListener listener) {
-        mHandler.post(() -> {
-            try {
-                listener.onResult(tether(iface, requestedState));
-            } catch (RemoteException e) { }
-        });
+    private void handleLegacyTether(String iface, final IIntResultListener listener) {
+        if (SdkLevel.isAtLeastB()) {
+            // After V, the TetheringManager and ConnectivityManager tether and untether methods
+            // throw UnsupportedOperationException, so this cannot happen in normal use. Ensure
+            // that this code cannot run even if callers use raw binder calls or other
+            // unsupported methods.
+            return;
+        }
+
+        final int type = ifaceNameToType(iface);
+        if (type == TETHERING_INVALID) {
+            Log.e(TAG, "Ignoring call to legacy tether for unknown iface " + iface);
+            sendTetherResult(listener, TETHER_ERROR_UNKNOWN_IFACE);
+        }
+
+        TetheringRequest request = mRequestTracker.getNextPendingRequest(type);
+        if (request == null) {
+            request = createLegacyGlobalScopeTetheringRequest(type);
+        }
+        int result = tetherInternal(request, iface);
+        switch (type) {
+            case TETHERING_WIFI:
+                TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
+                        "Legacy tether API called on Wifi iface " + iface,
+                        CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                        CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI);
+                if (result == TETHER_ERROR_NO_ERROR) {
+                    TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
+                            "Legacy tether API succeeded on Wifi iface " + iface,
+                            CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                            CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_SUCCESS);
+                }
+                break;
+            case TETHERING_WIFI_P2P:
+                TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
+                        "Legacy tether API called on Wifi P2P iface " + iface,
+                        CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                        CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_P2P);
+                if (result == TETHER_ERROR_NO_ERROR) {
+                    TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
+                            "Legacy tether API succeeded on Wifi P2P iface " + iface,
+                            CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                            CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_P2P_SUCCESS);
+                }
+                break;
+            default:
+                // Do nothing
+                break;
+        }
+        sendTetherResult(listener, result);
     }
 
-    private int tether(String iface, int requestedState) {
+    /**
+     * Legacy tether API that starts tethering with CONNECTIVITY_SCOPE_GLOBAL on the given iface.
+     *
+     * This API relies on the IpServer having been started for the interface by
+     * processInterfaceStateChanged beforehand, which is only possible for
+     *     - WIGIG Pre-S
+     *     - BLUETOOTH Pre-T
+     *     - WIFI
+     *     - WIFI_P2P.
+     * Note that WIFI and WIFI_P2P already start tethering on their respective ifaces via
+     * WIFI_(AP/P2P_STATE_CHANGED broadcasts, which makes this API redundant for those types unless
+     * those broadcasts are disabled by OEM.
+     */
+    void legacyTether(String iface, final IIntResultListener listener) {
+        mHandler.post(() -> handleLegacyTether(iface, listener));
+    }
+
+    private int tetherInternal(@NonNull TetheringRequest request, String iface) {
         if (DBG) Log.d(TAG, "Tethering " + iface);
         TetherState tetherState = mTetherStates.get(iface);
         if (tetherState == null) {
@@ -1012,29 +1201,33 @@
             Log.e(TAG, "Tried to Tether an unavailable iface: " + iface + ", ignoring");
             return TETHER_ERROR_UNAVAIL_IFACE;
         }
-        // NOTE: If a CMD_TETHER_REQUESTED message is already in the TISM's queue but not yet
+        mRequestTracker.promoteRequestToServing(tetherState.ipServer, request);
+        // NOTE: If a CMD_TETHER_REQUESTED message is already in the IpServer's queue but not yet
         // processed, this will be a no-op and it will not return an error.
-        //
-        // This code cannot race with untether() because they both run on the handler thread.
-        final int type = tetherState.ipServer.interfaceType();
-        final TetheringRequest request = mActiveTetheringRequests.get(type, null);
-        if (request != null) {
-            mActiveTetheringRequests.delete(type);
+        tetherState.ipServer.enable(request);
+        if (request.getRequestType() == REQUEST_TYPE_PLACEHOLDER) {
+            TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
+                    "Started tethering with placeholder request: " + request,
+                    CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                    CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_TETHER_WITH_PLACEHOLDER_REQUEST);
         }
-        tetherState.ipServer.enable(requestedState, request);
         return TETHER_ERROR_NO_ERROR;
     }
 
-    void untether(String iface, final IIntResultListener listener) {
+    void legacyUntether(String iface, final IIntResultListener listener) {
+        if (SdkLevel.isAtLeastB()) {
+            // After V, the TetheringManager and ConnectivityManager tether and untether methods
+            // throw UnsupportedOperationException, so this cannot happen in normal use. Ensure
+            // that this code cannot run even if callers use raw binder calls or other
+            // unsupported methods.
+            return;
+        }
         mHandler.post(() -> {
-            try {
-                listener.onResult(untether(iface));
-            } catch (RemoteException e) {
-            }
+            sendTetherResult(listener, legacyUntetherInternal(iface));
         });
     }
 
-    int untether(String iface) {
+    int legacyUntetherInternal(String iface) {
         if (DBG) Log.d(TAG, "Untethering " + iface);
         TetherState tetherState = mTetherStates.get(iface);
         if (tetherState == null) {
@@ -1045,11 +1238,11 @@
             Log.e(TAG, "Tried to untether an inactive iface :" + iface + ", ignoring");
             return TETHER_ERROR_UNAVAIL_IFACE;
         }
-        tetherState.ipServer.unwanted();
+        ensureIpServerUnwanted(tetherState.ipServer);
         return TETHER_ERROR_NO_ERROR;
     }
 
-    void untetherAll() {
+    void stopAllTethering() {
         stopTethering(TETHERING_WIFI);
         stopTethering(TETHERING_WIFI_P2P);
         stopTethering(TETHERING_USB);
@@ -1073,22 +1266,6 @@
         return mEntitlementMgr.isTetherProvisioningRequired(cfg);
     }
 
-    private int getRequestedState(int type) {
-        final TetheringRequest request = mActiveTetheringRequests.get(type);
-
-        // The request could have been deleted before we had a chance to complete it.
-        // If so, assume that the scope is the default scope for this tethering type.
-        // This likely doesn't matter - if the request has been deleted, then tethering is
-        // likely going to be stopped soon anyway.
-        final int connectivityScope = (request != null)
-                ? request.getConnectivityScope()
-                : TetheringRequest.getDefaultConnectivityScope(type);
-
-        return connectivityScope == CONNECTIVITY_SCOPE_LOCAL
-                ? IpServer.STATE_LOCAL_ONLY
-                : IpServer.STATE_TETHERED;
-    }
-
     private int getServedUsbType(boolean forNcmFunction) {
         // TETHERING_NCM is only used if the device does not use NCM for regular USB tethering.
         if (forNcmFunction && !mConfig.isUsingNcm()) return TETHERING_NCM;
@@ -1141,7 +1318,12 @@
             final TetherState tetherState = mTetherStates.valueAt(i);
             final int type = tetherState.ipServer.interfaceType();
             final String iface = mTetherStates.keyAt(i);
-            final TetheringRequest request = tetherState.ipServer.getTetheringRequest();
+            // Note: serving requests are only populated on B+. B+ also uses the sync state
+            // machine by default. This ensures that the serving request is (correctly) populated
+            // after the IpServer enters the available state and before it enters the serving
+            // state.
+            final TetheringRequest request =
+                    mRequestTracker.getServingRequest(tetherState.ipServer);
             final boolean includeSoftApConfig = request != null && cookie != null
                     && (cookie.uid == request.getUid() || cookie.hasSystemPrivilege);
             final TetheringInterface tetheringIface = new TetheringInterface(type, iface,
@@ -1219,7 +1401,7 @@
                 mLog.log("OBSERVED data saver changed");
                 handleDataSaverChanged();
             } else if (action.equals(TetheringNotificationUpdater.ACTION_DISABLE_TETHERING)) {
-                untetherAll();
+                stopAllTethering();
             }
         }
 
@@ -1308,6 +1490,9 @@
             final String ifname = intent.getStringExtra(EXTRA_WIFI_AP_INTERFACE_NAME);
             final int ipmode = intent.getIntExtra(EXTRA_WIFI_AP_MODE, IFACE_IP_MODE_UNSPECIFIED);
 
+            // In B+, Tethered AP is handled by StartTetheringSoftApCallback.
+            if (isTetheringWithSoftApConfigEnabled() && ipmode == IFACE_IP_MODE_TETHERED) return;
+
             switch (curState) {
                 case WifiManager.WIFI_AP_STATE_ENABLING:
                     // We can see this state on the way to both enabled and failure states.
@@ -1383,14 +1568,66 @@
 
             mDataSaverEnabled = isDataSaverEnabled;
             if (mDataSaverEnabled) {
-                untetherAll();
+                stopAllTethering();
+            }
+        }
+    }
+
+    class StartTetheringSoftApCallback implements SoftApCallback {
+
+        @Nullable
+        IIntResultListener mPendingListener;
+
+        StartTetheringSoftApCallback(IIntResultListener pendingListener) {
+            mPendingListener = pendingListener;
+        }
+
+        @Override
+        public void onStateChanged(SoftApState softApState) {
+            final int state = softApState.getState();
+            final String iface = softApState.getIface();
+            final TetheringRequest request = softApState.getTetheringRequest();
+            switch (softApState.getState()) {
+                case WifiManager.WIFI_AP_STATE_ENABLED:
+                    enableIpServing(request, iface);
+                    // If stopTethering has already been called, IP serving will still be started,
+                    // but as soon as the wifi code processes the stop, WIFI_AP_STATE_DISABLED will
+                    // be sent and tethering will be stopped again.
+                    sendTetherResultAndRemoveOnError(request, mPendingListener,
+                            TETHER_ERROR_NO_ERROR);
+                    mPendingListener = null;
+                    break;
+                case WifiManager.WIFI_AP_STATE_FAILED:
+                    // TODO: if a call to startTethering happens just after a call to stopTethering,
+                    // the start will fail because hotspot is still being disabled. This likely
+                    // cannot be fixed in tethering code but must be fixed in WiFi.
+                    sendTetherResultAndRemoveOnError(request, mPendingListener,
+                            TETHER_ERROR_INTERNAL_ERROR);
+                    mPendingListener = null;
+                    break;
+                case WifiManager.WIFI_AP_STATE_DISABLED:
+                    // TODO(b/403164072): SoftAP may restart due to CC change, in which we'll get
+                    // DISABLED -> ENABLED (or FAILED). Before the transition back to ENABLED is
+                    // complete, it is possible that a new Wifi request is accepted since there's no
+                    // active request to fuzzy-match it, which will unexpectedly cause Wifi to
+                    // overwrite this SoftApCallback. This should be fixed in Wifi to disallow any
+                    // new calls to startTetheredHotspot while SoftAP is restarting.
+                    disableWifiIpServing(iface, state);
+                    break;
+                default:
+                    break;
             }
         }
     }
 
     @VisibleForTesting
-    SparseArray<TetheringRequest> getActiveTetheringRequests() {
-        return mActiveTetheringRequests;
+    List<TetheringRequest> getPendingTetheringRequests() {
+        return mRequestTracker.getPendingTetheringRequests();
+    }
+
+    @VisibleForTesting
+    List<TetheringRequest> getServingTetheringRequests() {
+        return mRequestTracker.getServingTetheringRequests();
     }
 
     @VisibleForTesting
@@ -1442,7 +1679,7 @@
                 mNotificationUpdater.notifyTetheringDisabledByRestriction();
 
                 // Untether from all downstreams since tethering is disallowed.
-                mTethering.untetherAll();
+                mTethering.stopAllTethering();
             }
 
             return true;
@@ -1450,21 +1687,20 @@
         }
     }
 
-    private void enableIpServing(int tetheringType, String ifname, int ipServingMode) {
-        enableIpServing(tetheringType, ifname, ipServingMode, false /* isNcm */);
+    private void enableIpServing(@NonNull TetheringRequest request, String ifname) {
+        enableIpServing(request, ifname, false /* isNcm */);
     }
 
-    private void enableIpServing(int tetheringType, String ifname, int ipServingMode,
-            boolean isNcm) {
-        ensureIpServerStarted(ifname, tetheringType, isNcm);
-        if (tether(ifname, ipServingMode) != TETHER_ERROR_NO_ERROR) {
+    private void enableIpServing(@NonNull TetheringRequest request, String ifname, boolean isNcm) {
+        ensureIpServerStartedForType(ifname, request.getTetheringType(), isNcm);
+        if (tetherInternal(request, ifname) != TETHER_ERROR_NO_ERROR) {
             Log.e(TAG, "unable start tethering on iface " + ifname);
         }
     }
 
     private void disableWifiIpServingCommon(int tetheringType, String ifname) {
         if (!TextUtils.isEmpty(ifname) && mTetherStates.containsKey(ifname)) {
-            mTetherStates.get(ifname).ipServer.unwanted();
+            ensureIpServerUnwanted(mTetherStates.get(ifname).ipServer);
             return;
         }
 
@@ -1481,7 +1717,7 @@
         for (int i = 0; i < mTetherStates.size(); i++) {
             final IpServer ipServer = mTetherStates.valueAt(i).ipServer;
             if (ipServer.interfaceType() == tetheringType) {
-                ipServer.unwanted();
+                ensureIpServerUnwanted(ipServer);
                 return;
             }
         }
@@ -1509,7 +1745,10 @@
             mLog.e(ifname + " is not a tetherable iface, ignoring");
             return;
         }
-        enableIpServing(type, ifname, IpServer.STATE_LOCAL_ONLY);
+        // No need to call getOrCreatePendingRequest. There can never be explicit requests for
+        // TETHERING_WIFI_P2P because enableTetheringInternal ignores that type.
+        final TetheringRequest request = createImplicitLocalOnlyTetheringRequest(type);
+        enableIpServing(request, ifname);
     }
 
     private void disableWifiP2pIpServingIfNeeded(String ifname) {
@@ -1519,17 +1758,34 @@
         disableWifiIpServingCommon(TETHERING_WIFI_P2P, ifname);
     }
 
+    // TODO: fold this in to enableWifiIpServing.  We cannot do this at the moment because there
+    // are tests that send wifi AP broadcasts with a null interface. But if this can't happen on
+    // real devices, we should fix those tests to always pass in an interface.
+    private int maybeInferWifiTetheringType(String ifname) {
+        return SdkLevel.isAtLeastT() ? TETHERING_WIFI : ifaceNameToType(ifname);
+    }
+
     private void enableWifiIpServing(String ifname, int wifiIpMode) {
         mLog.log("request WiFi tethering - interface=" + ifname + " state=" + wifiIpMode);
 
         // Map wifiIpMode values to IpServer.Callback serving states.
-        final int ipServingMode;
+        TetheringRequest request;
+        final int type;
         switch (wifiIpMode) {
             case IFACE_IP_MODE_TETHERED:
-                ipServingMode = IpServer.STATE_TETHERED;
+                type = maybeInferWifiTetheringType(ifname);
+                request = mRequestTracker.getOrCreatePendingRequest(type);
+                // Wifi requests will always have CONNECTIVITY_SCOPE_GLOBAL, because
+                // TetheringRequest.Builder will not allow callers to set CONNECTIVITY_SCOPE_LOCAL
+                // for TETHERING_WIFI. However, if maybeInferWifiTetheringType returns a non-Wifi
+                // type (which could happen on a pre-T implementation of Wi-Fi if the regexps are
+                // misconfigured), then force the connectivity scope to global in order to match the
+                // historical behavior.
+                request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
                 break;
             case IFACE_IP_MODE_LOCAL_ONLY:
-                ipServingMode = IpServer.STATE_LOCAL_ONLY;
+                type = maybeInferWifiTetheringType(ifname);
+                request = createImplicitLocalOnlyTetheringRequest(type);
                 break;
             default:
                 mLog.e("Cannot enable IP serving in unknown WiFi mode: " + wifiIpMode);
@@ -1538,14 +1794,13 @@
 
         // After T, tethering always trust the iface pass by state change intent. This allow
         // tethering to deprecate tetherable wifi regexs after T.
-        final int type = SdkLevel.isAtLeastT() ? TETHERING_WIFI : ifaceNameToType(ifname);
         if (!checkTetherableType(type)) {
             mLog.e(ifname + " is not a tetherable iface, ignoring");
             return;
         }
 
         if (!TextUtils.isEmpty(ifname)) {
-            enableIpServing(type, ifname, ipServingMode);
+            enableIpServing(request, ifname);
         } else {
             mLog.e("Cannot enable IP serving on missing interface name");
         }
@@ -1566,7 +1821,6 @@
         // for both TETHERING_USB and TETHERING_NCM, so the local-only NCM interface will be
         // stopped immediately.
         final int tetheringType = getServedUsbType(forNcmFunction);
-        final int requestedState = getRequestedState(tetheringType);
         String[] ifaces = null;
         try {
             ifaces = mNetd.interfaceGetList();
@@ -1575,10 +1829,11 @@
             return;
         }
 
+        final TetheringRequest request = mRequestTracker.getOrCreatePendingRequest(tetheringType);
         if (ifaces != null) {
             for (String iface : ifaces) {
                 if (ifaceNameToType(iface) == tetheringType) {
-                    enableIpServing(tetheringType, iface, requestedState, forNcmFunction);
+                    enableIpServing(request, iface, forNcmFunction);
                     return;
                 }
             }
@@ -1609,9 +1864,7 @@
 
     void setUsbTethering(boolean enable, IIntResultListener listener) {
         mHandler.post(() -> {
-            try {
-                listener.onResult(setUsbTethering(enable));
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, setUsbTethering(enable));
         });
     }
 
@@ -2089,7 +2342,7 @@
                 }
 
                 mRoutingCoordinator.maybeRemoveDeprecatedUpstreams();
-                mUpstreamNetworkMonitor.startObserveAllNetworks();
+                mUpstreamNetworkMonitor.startObserveUpstreamNetworks();
 
                 // TODO: De-duplicate with updateUpstreamWanted() below.
                 if (upstreamWanted()) {
@@ -2207,9 +2460,14 @@
                         break;
                     }
                     case EVENT_REQUEST_CHANGE_DOWNSTREAM: {
-                        final boolean enabled = message.arg1 == 1;
-                        final TetheringRequest request = (TetheringRequest) message.obj;
-                        enableTetheringInternal(request.getTetheringType(), enabled, null, null);
+                        final int type = message.arg1;
+                        final Boolean enabled = (Boolean) message.obj;
+                        // Using a placeholder here is ok since we just need to the type of
+                        // tethering to poke the link layer. When the link layer comes up, we won't
+                        // have a pending request to use, but this matches the historical behavior.
+                        // TODO: Get the TetheringRequest from IpServer and make sure to put it in
+                        //       the pending list too.
+                        enableTetheringInternal(enabled, createPlaceholderRequest(type), null);
                         break;
                     }
                     default:
@@ -2733,6 +2991,9 @@
                 tetherState.lastState = state;
                 tetherState.lastError = lastError;
             } else {
+                // Note: Even if an IpServer exists for this iface, it may be different from "who"
+                // if a new IpServer fills the gap before the IpServer.STATE_UNAVAILABLE transition.
+                // TODO: remove this comment once the sync state machine is enabled everywhere.
                 if (DBG) Log.d(TAG, "got notification from stale iface " + iface);
             }
 
@@ -2747,7 +3008,19 @@
             int which;
             switch (state) {
                 case IpServer.STATE_UNAVAILABLE:
+                    which = TetherMainSM.EVENT_IFACE_SERVING_STATE_INACTIVE;
+                    break;
                 case IpServer.STATE_AVAILABLE:
+                    if (lastError != TETHER_ERROR_NO_ERROR) {
+                        // IpServer transitioned from an enabled state (STATE_TETHERED or
+                        // STATE_LOCAL_ONLY) back to STATE_AVAILABLE due to an error, so make sure
+                        // we remove the serving request from RequestTracker.
+                        // TODO: don't continue to use IpServers after they have hit an error, and
+                        // instead move them to STATE_UNAVAILABLE. This code can then
+                        // unconditionally remove the serving request whenever the IpServer enters
+                        // STATE_UNAVAILABLE.
+                        mRequestTracker.removeServingRequest(who);
+                    }
                     which = TetherMainSM.EVENT_IFACE_SERVING_STATE_INACTIVE;
                     break;
                 case IpServer.STATE_TETHERED:
@@ -2787,9 +3060,9 @@
         }
 
         @Override
-        public void requestEnableTethering(TetheringRequest request, boolean enabled) {
+        public void requestEnableTethering(int tetheringType, boolean enabled) {
             mTetherMainSM.sendMessage(TetherMainSM.EVENT_REQUEST_CHANGE_DOWNSTREAM,
-                    enabled ? 1 : 0, 0, request);
+                    tetheringType, 0, enabled ? Boolean.TRUE : Boolean.FALSE);
         }
     }
 
@@ -2810,7 +3083,7 @@
         return type != TETHERING_INVALID;
     }
 
-    private void ensureIpServerStarted(final String iface) {
+    private void ensureIpServerStartedForInterface(final String iface) {
         // If we don't care about this type of interface, ignore.
         final int interfaceType = ifaceNameToType(iface);
         if (!checkTetherableType(interfaceType)) {
@@ -2819,10 +3092,11 @@
             return;
         }
 
-        ensureIpServerStarted(iface, interfaceType, false /* isNcm */);
+        ensureIpServerStartedForType(iface, interfaceType, false /* isNcm */);
     }
 
-    private void ensureIpServerStarted(final String iface, int interfaceType, boolean isNcm) {
+    private void ensureIpServerStartedForType(final String iface, int interfaceType,
+            boolean isNcm) {
         // If we have already started a TISM for this interface, skip.
         if (mTetherStates.containsKey(iface)) {
             mLog.log("active iface (" + iface + ") reported as added, ignoring");
@@ -2831,7 +3105,7 @@
 
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
-                new IpServer(iface, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
+                new IpServer(iface, mContext, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
                         mRoutingCoordinator, new ControlCallback(), mConfig, mTetheringMetrics,
                         mDeps.makeIpServerDependencies()), isNcm);
         mTetherStates.put(iface, tetherState);
@@ -2842,11 +3116,18 @@
         final TetherState tetherState = mTetherStates.get(iface);
         if (tetherState == null) return;
 
+        mRequestTracker.removeServingRequest(tetherState.ipServer);
         tetherState.ipServer.stop();
         mLog.i("removing IpServer for: " + iface);
         mTetherStates.remove(iface);
     }
 
+    private void ensureIpServerUnwanted(final IpServer ipServer) {
+        mLog.i("unrequesting IpServer: " + ipServer);
+        mRequestTracker.removeServingRequest(ipServer);
+        ipServer.unwanted();
+    }
+
     private static String[] copy(String[] strarray) {
         return Arrays.copyOf(strarray, strarray.length);
     }
@@ -2854,9 +3135,7 @@
     void setPreferTestNetworks(final boolean prefer, IIntResultListener listener) {
         mHandler.post(() -> {
             mUpstreamNetworkMonitor.setPreferTestNetworks(prefer);
-            try {
-                listener.onResult(TETHER_ERROR_NO_ERROR);
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, TETHER_ERROR_NO_ERROR);
         });
     }
 
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index b3e9c1b..e2609e7 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -24,6 +24,7 @@
 import static android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY;
 
 import static com.android.networkstack.apishim.ConstantsShim.KEY_CARRIER_SUPPORTS_TETHERING_BOOL;
+import static com.android.net.module.util.SdkUtil.isAtLeast25Q2;
 
 import android.content.ContentResolver;
 import android.content.Context;
@@ -130,9 +131,6 @@
     public static final String TETHER_ENABLE_WEAR_TETHERING =
             "tether_enable_wear_tethering";
 
-    public static final String TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION =
-            "tether_force_random_prefix_base_selection";
-
     public static final String TETHER_ENABLE_SYNC_SM = "tether_enable_sync_sm";
 
     /**
@@ -142,7 +140,7 @@
     public static final int DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS = 5000;
 
     /** A flag for using synchronous or asynchronous state machine. */
-    public static boolean USE_SYNC_SM = false;
+    public static boolean USE_SYNC_SM = true;
 
     /**
      * A feature flag to control whether the active sessions metrics should be enabled.
@@ -150,6 +148,12 @@
      */
     public static final String TETHER_ACTIVE_SESSIONS_METRICS = "tether_active_sessions_metrics";
 
+    /**
+     * A feature flag to control whether the tethering local network agent should be enabled.
+     * Disabled by default.
+     */
+    public static final String TETHERING_LOCAL_NETWORK_AGENT = "tethering_local_network_agent";
+
     public final String[] tetherableUsbRegexs;
     public final String[] tetherableWifiRegexs;
     public final String[] tetherableWigigRegexs;
@@ -195,6 +199,10 @@
             return DeviceConfigUtils.isTetheringFeatureEnabled(context, name);
         }
 
+        boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context, name);
+        }
+
         boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                 boolean defaultValue) {
             return DeviceConfig.getBoolean(namespace, name, defaultValue);
@@ -394,7 +402,7 @@
      * use the async state machine.
      */
     public void readEnableSyncSM(final Context ctx) {
-        USE_SYNC_SM = mDeps.isFeatureEnabled(ctx, TETHER_ENABLE_SYNC_SM);
+        USE_SYNC_SM = isAtLeast25Q2() || mDeps.isFeatureNotChickenedOut(ctx, TETHER_ENABLE_SYNC_SM);
     }
 
     /** Does the dumping.*/
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index d89bf4d..00a7f09 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -208,4 +208,11 @@
     public int getBinderCallingUid() {
         return Binder.getCallingUid();
     }
+
+    /**
+     * Returns true if the tethering with soft ap config feature is enabled.
+     */
+    public boolean isTetheringWithSoftApConfigEnabled() {
+        return SdkLevel.isAtLeastB();
+    }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index 6485ffd..737041e 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -22,6 +22,7 @@
 import static android.Manifest.permission.TETHER_PRIVILEGED;
 import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
+import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION;
 import static android.net.TetheringManager.TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
@@ -58,6 +59,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.networkstack.apishim.SettingsShimImpl;
 import com.android.networkstack.apishim.common.SettingsShim;
+import com.android.networkstack.tethering.util.TetheringPermissionsUtils;
 
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
@@ -72,6 +74,7 @@
 
     private TetheringConnector mConnector;
     private SettingsShim mSettingsShim;
+    private TetheringPermissionsUtils mTetheringPermissionsUtils;
 
     @Override
     public void onCreate() {
@@ -81,6 +84,7 @@
         mConnector = new TetheringConnector(makeTethering(deps), TetheringService.this);
 
         mSettingsShim = SettingsShimImpl.newInstance();
+        mTetheringPermissionsUtils = new TetheringPermissionsUtils(deps.getContext());
     }
 
     /**
@@ -109,48 +113,69 @@
         @Override
         public void tether(String iface, String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
-            mTethering.tether(iface, IpServer.STATE_TETHERED, listener);
+            mTethering.legacyTether(iface, listener);
         }
 
         @Override
         public void untether(String iface, String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
-            mTethering.untether(iface, listener);
+            mTethering.legacyUntether(iface, listener);
         }
 
         @Override
         public void setUsbTethering(boolean enable, String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
             mTethering.setUsbTethering(enable, listener);
         }
 
+        private boolean isRequestAllowedForDOOrCarrierApp(@NonNull TetheringRequest request) {
+            return request.getTetheringType() == TETHERING_WIFI
+                    && request.getSoftApConfiguration() != null;
+        }
+
         @Override
-        public void startTethering(TetheringRequestParcel request, String callerPkg,
+        public void startTethering(TetheringRequestParcel requestParcel, String callerPkg,
                 String callingAttributionTag, IIntResultListener listener) {
-            boolean onlyAllowPrivileged = request.exemptFromEntitlementCheck
-                    || request.interfaceName != null;
-            if (checkAndNotifyCommonError(callerPkg,
-                    callingAttributionTag,
-                    onlyAllowPrivileged,
-                    listener)) {
+            TetheringRequest request = new TetheringRequest(requestParcel);
+            request.setUid(getBinderCallingUid());
+            request.setPackageName(callerPkg);
+            boolean onlyAllowPrivileged = request.isExemptFromEntitlementCheck()
+                    || request.getInterfaceName() != null;
+            boolean isDOOrCarrierAppAllowed = mTethering.isTetheringWithSoftApConfigEnabled()
+                    && isRequestAllowedForDOOrCarrierApp(request);
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, onlyAllowPrivileged,
+                    isDOOrCarrierAppAllowed, listener)) {
                 return;
             }
-            TetheringRequest external = new TetheringRequest(request);
-            external.setUid(getBinderCallingUid());
-            external.setPackageName(callerPkg);
-            mTethering.startTethering(external, callerPkg, listener);
+            mTethering.startTethering(request, callerPkg, listener);
         }
 
         @Override
         public void stopTethering(int type, String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
             try {
                 mTethering.stopTethering(type);
@@ -159,9 +184,46 @@
         }
 
         @Override
+        public void stopTetheringRequest(TetheringRequest request,
+                String callerPkg, String callingAttributionTag,
+                IIntResultListener listener) {
+            if (request == null) return;
+            if (listener == null) return;
+            request.setUid(getBinderCallingUid());
+            request.setPackageName(callerPkg);
+            boolean isDOOrCarrierAppAllowed = mTethering.isTetheringWithSoftApConfigEnabled()
+                    && isRequestAllowedForDOOrCarrierApp(request);
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, isDOOrCarrierAppAllowed, listener)) {
+                return;
+            }
+            // Note: Whether tethering is actually stopped or not will depend on whether the request
+            // matches an active one with the same UID (see RequestTracker#findFuzzyMatchedRequest).
+            mTethering.stopTetheringRequest(request, listener);
+        }
+
+        @Override
         public void requestLatestTetheringEntitlementResult(int type, ResultReceiver receiver,
                 boolean showEntitlementUi, String callerPkg, String callingAttributionTag) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, receiver)) return;
+            // Wrap the app-provided ResultReceiver in an IIntResultListener in order to call
+            // checkAndNotifyCommonError with it.
+            IIntResultListener listener = new IIntResultListener() {
+                @Override
+                public void onResult(int i) {
+                    receiver.send(i, null);
+                }
+
+                @Override
+                public IBinder asBinder() {
+                    throw new UnsupportedOperationException("asBinder unexpectedly called on"
+                            + " internal-only listener");
+                }
+            };
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
             mTethering.requestLatestTetheringEntitlementResult(type, receiver, showEntitlementUi);
         }
@@ -197,10 +259,14 @@
         @Override
         public void stopAllTethering(String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, false /* isDeviceOwnerAppAllowed */,
+                    listener)) {
+                return;
+            }
 
             try {
-                mTethering.untetherAll();
+                mTethering.stopAllTethering();
                 listener.onResult(TETHER_ERROR_NO_ERROR);
             } catch (RemoteException e) { }
         }
@@ -208,8 +274,11 @@
         @Override
         public void isTetheringSupported(String callerPkg, String callingAttributionTag,
                 IIntResultListener listener) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
-
+            boolean isDOOrCarrierAppAllowed = mTethering.isTetheringWithSoftApConfigEnabled();
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag,
+                    false /* onlyAllowPrivileged */, isDOOrCarrierAppAllowed, listener)) {
+                return;
+            }
             try {
                 listener.onResult(TETHER_ERROR_NO_ERROR);
             } catch (RemoteException e) { }
@@ -234,23 +303,17 @@
         }
 
         private boolean checkAndNotifyCommonError(final String callerPkg,
-                final String callingAttributionTag, final IIntResultListener listener) {
-            return checkAndNotifyCommonError(callerPkg, callingAttributionTag,
-                    false /* onlyAllowPrivileged */, listener);
-        }
-
-        private boolean checkAndNotifyCommonError(final String callerPkg,
                 final String callingAttributionTag, final boolean onlyAllowPrivileged,
-                final IIntResultListener listener) {
+                final boolean isDOOrCarrierAppAllowed, final IIntResultListener listener) {
             try {
-                if (!checkPackageNameMatchesUid(getBinderCallingUid(), callerPkg)) {
-                    Log.e(TAG, "Package name " + callerPkg + " does not match UID "
-                            + getBinderCallingUid());
+                final int uid = getBinderCallingUid();
+                if (!checkPackageNameMatchesUid(uid, callerPkg)) {
+                    Log.e(TAG, "Package name " + callerPkg + " does not match UID " + uid);
                     listener.onResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
                     return true;
                 }
-                if (!hasTetherChangePermission(callerPkg, callingAttributionTag,
-                        onlyAllowPrivileged)) {
+                if (!hasTetherChangePermission(uid, callerPkg, callingAttributionTag,
+                        onlyAllowPrivileged, isDOOrCarrierAppAllowed)) {
                     listener.onResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
                     return true;
                 }
@@ -265,27 +328,6 @@
             return false;
         }
 
-        private boolean checkAndNotifyCommonError(final String callerPkg,
-                final String callingAttributionTag, final ResultReceiver receiver) {
-            if (!checkPackageNameMatchesUid(getBinderCallingUid(), callerPkg)) {
-                Log.e(TAG, "Package name " + callerPkg + " does not match UID "
-                        + getBinderCallingUid());
-                receiver.send(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION, null);
-                return true;
-            }
-            if (!hasTetherChangePermission(callerPkg, callingAttributionTag,
-                    false /* onlyAllowPrivileged */)) {
-                receiver.send(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION, null);
-                return true;
-            }
-            if (!mTethering.isTetheringSupported() || !mTethering.isTetheringAllowed()) {
-                receiver.send(TETHER_ERROR_UNSUPPORTED, null);
-                return true;
-            }
-
-            return false;
-        }
-
         private boolean hasNetworkSettingsPermission() {
             return checkCallingOrSelfPermission(NETWORK_SETTINGS);
         }
@@ -303,16 +345,28 @@
             return mService.checkCallingOrSelfPermission(permission) == PERMISSION_GRANTED;
         }
 
-        private boolean hasTetherChangePermission(final String callerPkg,
-                final String callingAttributionTag, final boolean onlyAllowPrivileged) {
+        private boolean hasTetherChangePermission(final int uid, final String callerPkg,
+                final String callingAttributionTag, final boolean onlyAllowPrivileged,
+                final boolean isDOOrCarrierAppAllowed) {
             if (onlyAllowPrivileged && !hasNetworkStackPermission()
                     && !hasNetworkSettingsPermission()) return false;
 
             if (hasTetherPrivilegedPermission()) return true;
 
-            if (mTethering.isTetherProvisioningRequired()) return false;
+            // Allow DO and carrier-privileged apps to change tethering even if they don't have
+            // TETHER_PRIVILEGED.
+            // TODO: Stop tethering if the app loses DO status or carrier-privileges.
+            if (isDOOrCarrierAppAllowed
+                    && (mService.isDeviceOwner(uid, callerPkg)
+                            || mService.isCarrierPrivileged(callerPkg))) {
+                return true;
+            }
 
-            int uid = getBinderCallingUid();
+            // After TetheringManager moves to public API, prevent third-party apps from being able
+            // to change tethering with only WRITE_SETTINGS permission.
+            if (mTethering.isTetheringWithSoftApConfigEnabled()) return false;
+
+            if (mTethering.isTetherProvisioningRequired()) return false;
 
             // If callerPkg's uid is not same as getBinderCallingUid(),
             // checkAndNoteWriteSettingsOperation will return false and the operation will be
@@ -378,6 +432,22 @@
     }
 
     /**
+     * Wrapper for {@link TetheringPermissionsUtils#isDeviceOwner(int, String)}, used for mocks.
+     */
+    @VisibleForTesting
+    boolean isDeviceOwner(final int uid, final String callerPkg) {
+        return mTetheringPermissionsUtils.isDeviceOwner(uid, callerPkg);
+    }
+
+    /**
+     * Wrapper for {@link TetheringPermissionsUtils#isCarrierPrivileged(String)}, used for mocks.
+     */
+    @VisibleForTesting
+    boolean isCarrierPrivileged(final String callerPkg) {
+        return mTetheringPermissionsUtils.isCarrierPrivileged(callerPkg);
+    }
+
+    /**
      * An injection method for testing.
      */
     @VisibleForTesting
diff --git a/Tethering/src/com/android/networkstack/tethering/UpstreamNetworkMonitor.java b/Tethering/src/com/android/networkstack/tethering/UpstreamNetworkMonitor.java
index 7a05d74..9705d84 100644
--- a/Tethering/src/com/android/networkstack/tethering/UpstreamNetworkMonitor.java
+++ b/Tethering/src/com/android/networkstack/tethering/UpstreamNetworkMonitor.java
@@ -24,6 +24,7 @@
 import static android.net.ConnectivityManager.TYPE_WIFI;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
@@ -44,6 +45,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.ConnectivityManagerShimImpl;
 import com.android.networkstack.apishim.common.ConnectivityManagerShim;
@@ -62,9 +64,10 @@
  * The owner of UNM gets it to register network callbacks by calling the
  * following methods :
  * Calling #startTrackDefaultNetwork() to track the system default network.
- * Calling #startObserveAllNetworks() to observe all networks. Listening all
- * networks is necessary while the expression of preferred upstreams remains
- * a list of legacy connectivity types.  In future, this can be revisited.
+ * Calling #startObserveUpstreamNetworks() to observe upstream networks.
+ * Listening all upstream networks is necessary while the expression of
+ * preferred upstreams remains a list of legacy connectivity types.
+ * In future, this can be revisited.
  * Calling #setTryCell() to request bringing up mobile DUN or HIPRI.
  *
  * The methods and data members of this class are only to be accessed and
@@ -94,7 +97,7 @@
     @VisibleForTesting
     public static final int TYPE_NONE = -1;
 
-    private static final int CALLBACK_LISTEN_ALL = 1;
+    private static final int CALLBACK_LISTEN_UPSTREAM = 1;
     private static final int CALLBACK_DEFAULT_INTERNET = 2;
     private static final int CALLBACK_MOBILE_REQUEST = 3;
 
@@ -116,7 +119,7 @@
     private HashSet<IpPrefix> mLocalPrefixes;
     private ConnectivityManager mCM;
     private EntitlementManager mEntitlementMgr;
-    private NetworkCallback mListenAllCallback;
+    private NetworkCallback mListenUpstreamCallback;
     private NetworkCallback mDefaultNetworkCallback;
     private NetworkCallback mMobileNetworkCallback;
 
@@ -157,20 +160,29 @@
         }
         ConnectivityManagerShim mCmShim = ConnectivityManagerShimImpl.newInstance(mContext);
         mDefaultNetworkCallback = new UpstreamNetworkCallback(CALLBACK_DEFAULT_INTERNET);
+        // TODO (b/382413665): By definition, a local network cannot be the system default,
+        //  because it does not provide internet capability. Figure out whether this
+        //  is enforced in ConnectivityService. Or what will happen for tethering if it happens.
         mCmShim.registerSystemDefaultNetworkCallback(mDefaultNetworkCallback, mHandler);
         if (mEntitlementMgr == null) {
             mEntitlementMgr = entitle;
         }
     }
 
-    /** Listen all networks. */
-    public void startObserveAllNetworks() {
+    /** Listen upstream networks. */
+    public void startObserveUpstreamNetworks() {
         stop();
 
-        final NetworkRequest listenAllRequest = new NetworkRequest.Builder()
-                .clearCapabilities().build();
-        mListenAllCallback = new UpstreamNetworkCallback(CALLBACK_LISTEN_ALL);
-        cm().registerNetworkCallback(listenAllRequest, mListenAllCallback, mHandler);
+        final NetworkRequest listenUpstreamRequest;
+        // Before V, only TV supports local agent on U, which doesn't support tethering.
+        if (SdkLevel.isAtLeastV()) {
+            listenUpstreamRequest = new NetworkRequest.Builder().clearCapabilities()
+                    .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK).build();
+        }  else {
+            listenUpstreamRequest = new NetworkRequest.Builder().clearCapabilities().build();
+        }
+        mListenUpstreamCallback = new UpstreamNetworkCallback(CALLBACK_LISTEN_UPSTREAM);
+        cm().registerNetworkCallback(listenUpstreamRequest, mListenUpstreamCallback, mHandler);
     }
 
     /**
@@ -183,8 +195,8 @@
     public void stop() {
         setTryCell(false);
 
-        releaseCallback(mListenAllCallback);
-        mListenAllCallback = null;
+        releaseCallback(mListenUpstreamCallback);
+        mListenUpstreamCallback = null;
 
         mNetworkMap.clear();
     }
@@ -535,10 +547,10 @@
                 return;
             }
 
-            // Any non-LISTEN_ALL callback will necessarily concern a network that will
-            // also match the LISTEN_ALL callback by construction of the LISTEN_ALL callback.
-            // So it's not useful to do this work for non-LISTEN_ALL callbacks.
-            if (mCallbackType == CALLBACK_LISTEN_ALL) {
+            // Any non-LISTEN_UPSTREAM callback will necessarily concern a network that will
+            // also match the LISTEN_UPSTREAM callback by construction of the LISTEN_UPSTREAM
+            // callback. So it's not useful to do this work for non-LISTEN_UPSTREAM callbacks.
+            if (mCallbackType == CALLBACK_LISTEN_UPSTREAM) {
                 recomputeLocalPrefixes();
             }
         }
@@ -555,10 +567,11 @@
             }
 
             handleLost(network);
-            // Any non-LISTEN_ALL callback will necessarily concern a network that will
-            // also match the LISTEN_ALL callback by construction of the LISTEN_ALL callback.
-            // So it's not useful to do this work for non-LISTEN_ALL callbacks.
-            if (mCallbackType == CALLBACK_LISTEN_ALL) {
+            // Any non-LISTEN_UPSTREAM callback will necessarily concern a network that will
+            // also match the LISTEN_UPSTREAM callback by construction of the
+            // LISTEN_UPSTREAM callback. So it's not useful to do this work for
+            // non-LISTEN_UPSTREAM callbacks.
+            if (mCallbackType == CALLBACK_LISTEN_UPSTREAM) {
                 recomputeLocalPrefixes();
             }
         }
diff --git a/Tethering/src/com/android/networkstack/tethering/util/TetheringPermissionsUtils.java b/Tethering/src/com/android/networkstack/tethering/util/TetheringPermissionsUtils.java
new file mode 100644
index 0000000..603fa9c
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/util/TetheringPermissionsUtils.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2025 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.networkstack.tethering.util;
+
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.UserHandle;
+import android.telephony.TelephonyManager;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Utils class for checking permissions related to Tethering APIs.
+ */
+public class TetheringPermissionsUtils {
+    private static final String TAG = "TetherPermUtils";
+
+    @NonNull private final Context mContext;
+
+    public TetheringPermissionsUtils(@NonNull Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Checks if the package name is a Device Owner.
+     */
+    public boolean isDeviceOwner(final int uid, @NonNull final String packageName) {
+        Context userContext;
+        try {
+            // There is no safe way to invoke this method since tethering package might not be
+            // installed for a certain user on the OEM devices, refer to b/382628161.
+            userContext = mContext.createContextAsUser(UserHandle.getUserHandleForUid(uid),
+                    0 /* flags */);
+        } catch (IllegalStateException e) {
+            // TODO: Add a terrible error metric for this case.
+            Log.e(TAG, "createContextAsUser failed, skipping Device Owner check", e);
+            return false;
+        }
+        DevicePolicyManager devicePolicyManager =
+                retrieveDevicePolicyManagerFromContext(userContext);
+        if (devicePolicyManager == null) return false;
+        return devicePolicyManager.isDeviceOwnerApp(packageName);
+    }
+
+    private DevicePolicyManager retrieveDevicePolicyManagerFromContext(
+            @NonNull final Context context) {
+        DevicePolicyManager devicePolicyManager =
+                context.getSystemService(DevicePolicyManager.class);
+        if (devicePolicyManager == null
+                && context.getPackageManager().hasSystemFeature(
+                PackageManager.FEATURE_DEVICE_ADMIN)) {
+            Log.w(TAG, "Error retrieving DPM service");
+        }
+        return devicePolicyManager;
+    }
+
+    /**
+     * Checks if the package name has carrier privileges.
+     */
+    public boolean isCarrierPrivileged(@NonNull final String packageName) {
+        TelephonyManager telephonyManager = mContext.getSystemService(TelephonyManager.class);
+        if (telephonyManager == null) return false;
+
+        long ident = Binder.clearCallingIdentity();
+        try {
+            return telephonyManager.checkCarrierPrivilegesForPackageAnyPhone(packageName)
+                    == TelephonyManager.CARRIER_PRIVILEGE_STATUS_HAS_ACCESS;
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+}
diff --git a/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java b/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
index 76c2f0d..9392ae8 100644
--- a/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
+++ b/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
@@ -15,7 +15,23 @@
  */
 package com.android.networkstack.tethering.util;
 
+import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
+import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.NetworkCapabilities.TRANSPORT_USB;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
+import static android.net.TetheringManager.TETHERING_BLUETOOTH;
+import static android.net.TetheringManager.TETHERING_ETHERNET;
+import static android.net.TetheringManager.TETHERING_NCM;
+import static android.net.TetheringManager.TETHERING_USB;
+import static android.net.TetheringManager.TETHERING_VIRTUAL;
+import static android.net.TetheringManager.TETHERING_WIFI;
+import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHERING_WIGIG;
+
 import android.net.TetherStatsParcel;
+import android.net.TetheringManager.TetheringRequest;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -166,4 +182,67 @@
             return null;
         }
     }
+
+    /**
+     * Create a legacy tethering request for calls to the legacy tether() API, which doesn't take an
+     * explicit request. These are always CONNECTIVITY_SCOPE_GLOBAL, per historical behavior.
+     */
+    @NonNull
+    public static TetheringRequest createLegacyGlobalScopeTetheringRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_LEGACY;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
+        return request;
+    }
+
+    /**
+     * Create a local-only implicit tethering request. This is used for Wifi local-only hotspot and
+     * Wifi P2P, which start tethering based on the WIFI_(AP/P2P)_STATE_CHANGED broadcasts.
+     */
+    @NonNull
+    public static TetheringRequest createImplicitLocalOnlyTetheringRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_IMPLICIT;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_LOCAL;
+        return request;
+    }
+
+    /**
+     * Create a placeholder request. This is used in case we try to find a pending request but there
+     * is none (e.g. stopTethering removed a pending request), or for cases where we only have the
+     * tethering type (e.g. stopTethering(int)).
+     */
+    @NonNull
+    public static TetheringRequest createPlaceholderRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_PLACEHOLDER;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
+        return request;
+    }
+
+    /**
+     * Returns the transport type for the given interface type.
+     *
+     * @param interfaceType The interface type.
+     * @return The transport type.
+     * @throws IllegalArgumentException if the interface type is invalid.
+     */
+    public static int getTransportTypeForTetherableType(int interfaceType) {
+        switch (interfaceType) {
+            case TETHERING_WIFI:
+            case TETHERING_WIGIG:
+            case TETHERING_WIFI_P2P:
+                return TRANSPORT_WIFI;
+            case TETHERING_USB:
+            case TETHERING_NCM:
+                return TRANSPORT_USB;
+            case TETHERING_BLUETOOTH:
+                return TRANSPORT_BLUETOOTH;
+            case TETHERING_ETHERNET:
+            case TETHERING_VIRTUAL: // For virtual machines.
+                return TRANSPORT_ETHERNET;
+            default:
+                throw new IllegalArgumentException("Invalid interface type: " + interfaceType);
+        }
+    }
 }
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 5c8d347..d47f4b3 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -17,7 +17,11 @@
 package android.net;
 
 import static android.Manifest.permission.DUMP;
+import static android.Manifest.permission.LOG_COMPAT_CHANGE;
+import static android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG;
 import static android.net.InetAddresses.parseNumericAddress;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
+import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
 import static android.net.TetheringManager.TETHERING_ETHERNET;
@@ -27,6 +31,8 @@
 import static android.net.TetheringTester.buildUdpPacket;
 import static android.net.TetheringTester.isExpectedIcmpPacket;
 import static android.net.TetheringTester.isExpectedUdpDnsPacket;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS;
+import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 import static android.system.OsConstants.ICMP_ECHO;
 import static android.system.OsConstants.ICMP_ECHOREPLY;
 import static android.system.OsConstants.IPPROTO_UDP;
@@ -49,6 +55,7 @@
 import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
+import android.app.compat.CompatChanges;
 import android.content.Context;
 import android.net.TetheringManager.TetheringRequest;
 import android.net.TetheringTester.TetheredDevice;
@@ -64,6 +71,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BpfDump;
 import com.android.net.module.util.Ipv6Utils;
 import com.android.net.module.util.Struct;
@@ -77,18 +85,23 @@
 import com.android.net.module.util.bpf.TetherStatsValue;
 import com.android.net.module.util.structs.Ipv4Header;
 import com.android.net.module.util.structs.UdpHeader;
+import com.android.testutils.AutoReleaseNetworkCallbackRule;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DeviceConfigRule;
 import com.android.testutils.DeviceInfoUtils;
 import com.android.testutils.DumpTestUtils;
 import com.android.testutils.NetworkStackModuleTest;
 import com.android.testutils.PollPacketReader;
+import com.android.testutils.RecorderCallback.CallbackEntry;
+import com.android.testutils.TestableNetworkCallback;
 
 import org.junit.After;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.File;
 import java.io.FileDescriptor;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -109,6 +122,12 @@
 public class EthernetTetheringTest extends EthernetTetheringTestBase {
     @Rule
     public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+    // For manipulating feature flag before and after testing.
+    @Rule
+    public final DeviceConfigRule mDeviceConfigRule = new DeviceConfigRule();
+    @Rule
+    public final AutoReleaseNetworkCallbackRule
+            mNetworkCallbackRule = new AutoReleaseNetworkCallbackRule();
 
     private static final String TAG = EthernetTetheringTest.class.getSimpleName();
 
@@ -198,6 +217,9 @@
             (byte) 0x01, (byte) 0x02, (byte) 0x03, (byte) 0x04  /* Address: 1.2.3.4 */
     };
 
+    // Shamelessly copied from TetheringConfiguration.
+    private static final String TETHERING_LOCAL_NETWORK_AGENT = "tethering_local_network_agent";
+
     @After
     public void tearDown() throws Exception {
         super.tearDown();
@@ -1051,6 +1073,16 @@
         assertEquals(0, statsValue.txErrors);
     }
 
+    // on S/Sv2 without a new enough DnsResolver apex, NetBpfLoad does not
+    // get triggered, and thus no mainline programs get loaded.
+    private boolean isNetBpfLoadEnabled() {
+        if (SdkLevel.isAtLeastT()) return true;
+        if (!SdkLevel.isAtLeastS()) return false;
+
+        File f = new File("/apex/com.android.resolv/NetBpfLoad-S.flag");
+        return f.isFile();
+    }
+
     /**
      * BPF offload IPv4 UDP tethering test. Verify that UDP tethered packets are offloaded by BPF.
      * Minimum test requirement:
@@ -1065,6 +1097,7 @@
     public void testTetherBpfOffloadUdpV4() throws Exception {
         assumeTrue("Tethering config disabled BPF offload", isTetherConfigBpfOffloadEnabled());
         assumeKernelSupportBpfOffloadUdpV4();
+        assumeTrue("Mainline NetBpfLoad not available", isNetBpfLoadEnabled());
 
         runUdp4Test();
     }
@@ -1211,4 +1244,49 @@
             maybeUnregisterTetheringEventCallback(tetheringEventCallback);
         }
     }
+
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testLocalAgent_networkCallbacks() throws Exception {
+        final boolean isMatchNonThreadLocalNetworksEnabled = runAsShell(
+                READ_COMPAT_CHANGE_CONFIG, LOG_COMPAT_CHANGE,
+                () -> CompatChanges.isChangeEnabled(ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS));
+        assumeTrue(isMatchNonThreadLocalNetworksEnabled);
+
+        mDeviceConfigRule.setConfig(NAMESPACE_TETHERING, TETHERING_LOCAL_NETWORK_AGENT, "1");
+        assumeFalse(isInterfaceForTetheringAvailable());
+        setIncludeTestInterfaces(true);
+
+        TestNetworkInterface downstreamIface = null;
+        MyTetheringEventCallback tetheringEventCallback = null;
+
+        final TestableNetworkCallback networkCallback = new TestableNetworkCallback();
+        final NetworkRequest networkRequest = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK).build();
+        mNetworkCallbackRule.registerNetworkCallback(networkRequest, networkCallback);
+
+        try {
+            downstreamIface = createTestInterface();
+
+            final String iface = mTetheredInterfaceRequester.getInterface();
+            assertEquals("TetheredInterfaceCallback for unexpected interface",
+                    downstreamIface.getInterfaceName(), iface);
+
+            final TetheringRequest request = new TetheringRequest.Builder(TETHERING_ETHERNET)
+                    .setConnectivityScope(CONNECTIVITY_SCOPE_GLOBAL).build();
+            tetheringEventCallback = enableTethering(iface, request, null /* any upstream */);
+            tetheringEventCallback.awaitInterfaceTethered();
+
+            // Verify NetworkCallback works accordingly.
+            final Network network = networkCallback.expect(CallbackEntry.AVAILABLE).getNetwork();
+            final CallbackEntry.CapabilitiesChanged capEvent =
+                    networkCallback.eventuallyExpect(CallbackEntry.NETWORK_CAPS_UPDATED);
+            assertEquals(network, capEvent.getNetwork());
+            assertTrue(capEvent.getCaps().hasTransport(TRANSPORT_ETHERNET));
+            assertTrue(capEvent.getCaps().hasCapability(NET_CAPABILITY_LOCAL_NETWORK));
+        } finally {
+            stopEthernetTethering(tetheringEventCallback);
+            maybeCloseTestInterface(downstreamIface);
+        }
+    }
 }
diff --git a/Tethering/tests/unit/Android.bp b/Tethering/tests/unit/Android.bp
index d0d23ac..c282618 100644
--- a/Tethering/tests/unit/Android.bp
+++ b/Tethering/tests/unit/Android.bp
@@ -51,12 +51,15 @@
         "src/**/*.kt",
     ],
     static_libs: [
+        // Include mockito extended first so it takes precedence, as other libraries like
+        // TetheringCommonTests bundle non-extended mockito.
+        // TODO: use non-extended mockito in tethering tests instead
+        "mockito-target-extended-minus-junit4",
         "TetheringCommonTests",
         "androidx.test.rules",
         "frameworks-base-testutils",
-        "mockito-target-extended-minus-junit4",
-        "net-tests-utils",
         "testables",
+        "truth",
     ],
     // TODO(b/147200698) change sdk_version to module-current and
     // remove framework-minus-apex, ext, and framework-res
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 680e81d..dc90d68 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -28,6 +28,7 @@
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
 import static android.net.TetheringManager.TETHER_ERROR_ENABLE_FORWARDING_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
+import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
 import static android.net.TetheringManager.TETHER_ERROR_TETHER_IFACE_ERROR;
 import static android.net.dhcp.IDhcpServer.STATUS_SUCCESS;
 import static android.net.ip.IpServer.STATE_AVAILABLE;
@@ -37,7 +38,9 @@
 import static android.net.ip.IpServer.getTetherableIpv6Prefixes;
 
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
+import static com.android.modules.utils.build.SdkLevel.isAtLeastV;
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHERING_LOCAL_NETWORK_AGENT;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -47,13 +50,13 @@
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.argThat;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyBoolean;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
@@ -65,7 +68,7 @@
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 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.InterfaceConfigurationParcel;
@@ -73,7 +76,10 @@
 import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.MacAddress;
+import android.net.Network;
+import android.net.NetworkAgent;
 import android.net.RouteInfo;
+import android.net.TetheringManager.TetheringRequest;
 import android.net.dhcp.DhcpServerCallbacks;
 import android.net.dhcp.DhcpServingParamsParcel;
 import android.net.dhcp.IDhcpEventCallbacks;
@@ -83,8 +89,10 @@
 import android.os.Build;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.os.ServiceSpecificException;
 import android.os.test.TestLooper;
 import android.text.TextUtils;
+import android.util.ArrayMap;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
@@ -100,6 +108,7 @@
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule;
 
 import org.junit.Before;
 import org.junit.Rule;
@@ -122,6 +131,16 @@
     @Rule
     public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
 
+    final ArrayMap<String, Boolean> mFeatureFlags = new ArrayMap<>();
+    // 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 String IFACE_NAME = "testnet1";
     private static final String UPSTREAM_IFACE = "upstream0";
     private static final String UPSTREAM_IFACE2 = "upstream1";
@@ -164,6 +183,7 @@
             new LinkAddress("2001:db8:0:abcd::168/64"));
     private static final Set<IpPrefix> UPSTREAM_PREFIXES2 = Set.of(
             new IpPrefix("2001:db8:0:1234::/64"), new IpPrefix("2001:db8:0:abcd::/64"));
+    private static final int TEST_NET_ID = 123;
 
     @Mock private INetd mNetd;
     @Mock private IpServer.Callback mCallback;
@@ -173,10 +193,11 @@
     @Mock private RouterAdvertisementDaemon mRaDaemon;
     @Mock private IpServer.Dependencies mDependencies;
     @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
-    @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
     @Mock private TetheringMetrics mTetheringMetrics;
     @Mock private BpfCoordinator mBpfCoordinator;
+    @Mock private Context mContext;
+    @Mock private NetworkAgent mNetworkAgent;
 
     @Captor private ArgumentCaptor<DhcpServingParamsParcel> mDhcpParamsCaptor;
 
@@ -205,6 +226,18 @@
         when(mDependencies.getInterfaceParams(UPSTREAM_IFACE)).thenReturn(UPSTREAM_IFACE_PARAMS);
         when(mDependencies.getInterfaceParams(UPSTREAM_IFACE2)).thenReturn(UPSTREAM_IFACE_PARAMS2);
         when(mDependencies.getInterfaceParams(IPSEC_IFACE)).thenReturn(IPSEC_IFACE_PARAMS);
+        doAnswer(
+                invocation -> mFeatureFlags.getOrDefault((String) invocation.getArgument(1), false)
+        ).when(mDependencies).isFeatureEnabled(any(), anyString());
+        if (isAtLeastV()) {
+            when(mDependencies.makeNetworkAgent(any(), any(), anyString(), anyInt(), any()))
+                    .thenReturn(mNetworkAgent);
+            // Mock the returned network and modifying the status.
+            final Network network = mock(Network.class);
+            doReturn(TEST_NET_ID).when(network).getNetId();
+            doReturn(network).when(mNetworkAgent).register();
+            doReturn(network).when(mNetworkAgent).getNetwork();
+        }
 
         mInterfaceConfiguration = new InterfaceConfigurationParcel();
         mInterfaceConfiguration.flags = new String[0];
@@ -240,7 +273,8 @@
             Set<LinkAddress> upstreamAddresses, boolean usingLegacyDhcp, boolean usingBpfOffload)
             throws Exception {
         initStateMachine(interfaceType, usingLegacyDhcp, usingBpfOffload);
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
         verify(mBpfCoordinator).addIpServer(mIpServer);
         if (upstreamIface != null) {
             InterfaceParams interfaceParams = mDependencies.getInterfaceParams(upstreamIface);
@@ -293,10 +327,9 @@
     private IpServer createIpServer(final int interfaceType) {
         mLooper = new TestLooper();
         mHandler = new Handler(mLooper.getLooper());
-        return new IpServer(IFACE_NAME, mHandler, interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mRoutingCoordinatorManager, mCallback, mTetherConfig,
+        return new IpServer(IFACE_NAME, mContext, mHandler, interfaceType, mSharedLog, mNetd,
+                mBpfCoordinator, mRoutingCoordinatorManager, mCallback, mTetherConfig,
                 mTetheringMetrics, mDependencies);
-
     }
 
     @Test
@@ -341,11 +374,16 @@
         verifyNoMoreInteractions(mNetd, mCallback);
     }
 
+    private boolean isTetheringNetworkAgentFeatureEnabled() {
+        return isAtLeastV() && mFeatureFlags.getOrDefault(TETHERING_LOCAL_NETWORK_AGENT, false);
+    }
+
     @Test
     public void canBeTetheredAsBluetooth() throws Exception {
         initStateMachine(TETHERING_BLUETOOTH);
 
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         if (isAtLeastT()) {
             inOrder.verify(mRoutingCoordinatorManager)
@@ -358,10 +396,16 @@
                     IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         }
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
-        inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
-        // One for ipv4 route, one for ipv6 link local route.
-        inOrder.verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME),
-                any(), any());
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            inOrder.verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            inOrder.verify(mNetd, never())
+                    .networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+            // One for ipv4 route, one for ipv6 link local route.
+            inOrder.verify(mNetd, times(2))
+                    .networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME), any(), any());
+        }
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_TETHERED, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
@@ -377,7 +421,11 @@
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mNetd).tetherInterfaceRemove(IFACE_NAME);
-        inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            inOrder.verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+        }
         // One is ipv4 address clear (set to 0.0.0.0), another is set interface down which only
         // happen after T. Before T, the interface configuration control in bluetooth side.
         if (isAtLeastT()) {
@@ -400,7 +448,8 @@
     public void canBeTetheredAsUsb() throws Exception {
         initStateMachine(TETHERING_USB);
 
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
                 eq(CONNECTIVITY_SCOPE_GLOBAL), any());
@@ -408,9 +457,15 @@
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                 IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
-        inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
-        inOrder.verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME),
-                any(), any());
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            inOrder.verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            inOrder.verify(mNetd, never())
+                    .networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+            inOrder.verify(mNetd, times(2))
+                    .networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME), any(), any());
+        }
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_TETHERED, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
@@ -423,7 +478,8 @@
     public void canBeTetheredAsWifiP2p_NotUsingDedicatedIp() throws Exception {
         initStateMachine(TETHERING_WIFI_P2P);
 
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_LOCAL));
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
                 eq(CONNECTIVITY_SCOPE_LOCAL), any());
@@ -447,7 +503,8 @@
         initStateMachine(TETHERING_WIFI_P2P, false /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD,
                 true /* shouldEnableWifiP2pDedicatedIp */);
 
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_LOCAL));
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         // When using WiFi P2p dedicated IP, the IpServer just picks the IP address without
         // requesting for it at RoutingCoordinatorManager.
@@ -587,7 +644,11 @@
         inOrder.verify(mBpfCoordinator).clearAllIpv6Rules(mIpServer);
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mNetd).tetherInterfaceRemove(IFACE_NAME);
-        inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            inOrder.verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+        }
         inOrder.verify(mNetd, times(isAtLeastT() ? 2 : 1)).interfaceSetCfg(
                 argThat(cfg -> IFACE_NAME.equals(cfg.ifName)));
         inOrder.verify(mRoutingCoordinatorManager).releaseDownstream(any());
@@ -627,7 +688,8 @@
         initStateMachine(TETHERING_USB);
 
         doThrow(RemoteException.class).when(mNetd).tetherInterfaceAdd(IFACE_NAME);
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
         InOrder usbTeardownOrder = inOrder(mNetd, mCallback);
         usbTeardownOrder.verify(mNetd).interfaceSetCfg(
                 argThat(cfg -> IFACE_NAME.equals(cfg.ifName)));
@@ -713,7 +775,8 @@
     @Test
     public void startsDhcpServerOnNcm() throws Exception {
         initStateMachine(TETHERING_NCM);
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_LOCAL));
         dispatchTetherConnectionChanged(UPSTREAM_IFACE);
 
         assertDhcpStarted(new IpPrefix("192.168.42.0/24"));
@@ -722,7 +785,8 @@
     @Test
     public void testOnNewPrefixRequest() throws Exception {
         initStateMachine(TETHERING_NCM);
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_LOCAL));
 
         final IDhcpEventCallbacks eventCallbacks;
         final ArgumentCaptor<IDhcpEventCallbacks> dhcpEventCbsCaptor =
@@ -911,7 +975,8 @@
         doNothing().when(mDependencies).makeDhcpServer(any(), mDhcpParamsCaptor.capture(),
                 cbCaptor.capture());
         initStateMachine(TETHERING_WIFI);
-        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, 0, 0,
+                createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
         verify(mDhcpServer, never()).startWithCallbacks(any(), any());
 
         // No stop dhcp server because dhcp server is not created yet.
@@ -957,14 +1022,22 @@
         assertDhcpServingParams(mDhcpParamsCaptor.getValue(), expectedPrefix);
     }
 
+    private TetheringRequest createMockTetheringRequest(int connectivityScope) {
+        TetheringRequest request = mock(TetheringRequest.class);
+        when(request.getConnectivityScope()).thenReturn(connectivityScope);
+        return request;
+    }
+
     /**
      * Send a command to the state machine under test, and run the event loop to idle.
      *
      * @param command One of the IpServer.CMD_* constants.
-     * @param arg1 An additional argument to pass.
+     * @param arg1    An additional argument to pass.
+     * @param arg2    An additional argument to pass.
+     * @param obj     An additional object to pass.
      */
-    private void dispatchCommand(int command, int arg1) {
-        mIpServer.sendMessage(command, arg1);
+    private void dispatchCommand(int command, int arg1, int arg2, Object obj) {
+        mIpServer.sendMessage(command, arg1, arg2, obj);
         mLooper.dispatchAll();
     }
 
@@ -1041,6 +1114,162 @@
         return true;
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT)
+    @Test
+    public void testTetheringNetworkAgent_tetheringAgentEnabled() throws Exception {
+        doTestTetheringNetworkAgent(CONNECTIVITY_SCOPE_GLOBAL, true);
+    }
+
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT, enabled = false)
+    @Test
+    public void testTetheringNetworkAgent_tetheringAgentDisabled() throws Exception {
+        doTestTetheringNetworkAgent(CONNECTIVITY_SCOPE_GLOBAL, false);
+    }
+
+    // Verify Tethering Network Agent feature doesn't affect Wi-fi P2P Group Owner although
+    // the code is mostly shared.
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT)
+    @Test
+    public void testTetheringNetworkAgent_p2pGroupOwnerAgentDisabled() throws Exception {
+        doTestTetheringNetworkAgent(CONNECTIVITY_SCOPE_LOCAL, false);
+    }
+
+    private void doTestTetheringNetworkAgent(int scope, boolean expectAgentEnabled)
+            throws Exception {
+        initStateMachine(TETHERING_USB);
+
+        final InOrder inOrder = inOrder(mNetworkAgent, mNetd);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED,
+                0, createMockTetheringRequest(scope));
+
+        inOrder.verify(mNetworkAgent, expectAgentEnabled ? times(1) : never()).register();
+        inOrder.verify(mNetd, times(1)).tetherInterfaceAdd(anyString());
+        if (expectAgentEnabled) {
+            inOrder.verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            inOrder.verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
+            inOrder.verify(mNetworkAgent, times(1)).sendLinkProperties(any());
+            inOrder.verify(mNetworkAgent, times(1)).markConnected();
+        } else {
+            inOrder.verify(mNetd, times(1)).networkAddInterface(anyInt(), anyString());
+            inOrder.verify(mNetd, times(2)).networkAddRoute(anyInt(), anyString(), any(), any());
+            inOrder.verify(mNetworkAgent, never()).sendLinkProperties(any());
+            inOrder.verify(mNetworkAgent, never()).markConnected();
+        }
+
+        dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
+        if (expectAgentEnabled) {
+            inOrder.verify(mNetworkAgent, times(1)).unregister();
+            inOrder.verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            inOrder.verify(mNetworkAgent, never()).unregister();
+            inOrder.verify(mNetd, times(1)).networkRemoveInterface(anyInt(), anyString());
+        }
+    }
+
+    // Verify if the registration failed, tethering can be gracefully shutdown.
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT)
+    @Test
+    public void testTetheringNetworkAgent_registerThrows() throws Exception {
+        initStateMachine(TETHERING_USB);
+
+        final InOrder inOrder = inOrder(mNetworkAgent, mNetd, mCallback);
+        doReturn(null).when(mNetworkAgent).getNetwork();
+        doThrow(IllegalStateException.class).when(mNetworkAgent).register();
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED,
+                0, createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
+
+        inOrder.verify(mNetworkAgent).register();
+        inOrder.verify(mNetd, never()).networkCreate(any());
+        inOrder.verify(mNetworkAgent, never()).sendLinkProperties(any());
+        inOrder.verify(mNetworkAgent, never()).markConnected();
+        inOrder.verify(mNetworkAgent, never()).unregister();
+        inOrder.verify(mNetd, never()).networkDestroy(anyInt());
+        inOrder.verify(mCallback).updateInterfaceState(
+                mIpServer, STATE_AVAILABLE, TETHER_ERROR_SERVICE_UNAVAIL);
+    }
+
+    // Verify if the network creation failed, tethering can be gracefully shutdown.
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT)
+    @Test
+    public void testTetheringNetworkAgent_netdThrows() throws Exception {
+        initStateMachine(TETHERING_USB);
+
+        final InOrder inOrder = inOrder(mNetworkAgent, mNetd, mCallback);
+        doThrow(ServiceSpecificException.class).when(mNetd).tetherInterfaceAdd(any());
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED,
+                0, createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
+
+        inOrder.verify(mNetworkAgent).register();
+        inOrder.verify(mNetd, never()).networkCreate(any());
+        inOrder.verify(mNetworkAgent, never()).sendLinkProperties(any());
+        inOrder.verify(mNetworkAgent, never()).markConnected();
+        inOrder.verify(mNetworkAgent).unregister();
+        inOrder.verify(mNetd, never()).networkDestroy(anyInt());
+        inOrder.verify(mCallback).updateInterfaceState(
+                mIpServer, STATE_AVAILABLE, TETHER_ERROR_TETHER_IFACE_ERROR);
+    }
+
+    // Verify when IPv6 address update, set routes accordingly.
+    @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @SetFeatureFlagsRule.FeatureFlag(name = TETHERING_LOCAL_NETWORK_AGENT)
+    @Test
+    public void testTetheringNetworkAgent_ipv6AddressUpdate() throws Exception {
+        initStateMachine(TETHERING_USB);
+
+        final InOrder inOrder = inOrder(mNetworkAgent, mNetd);
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED,
+                0, createMockTetheringRequest(CONNECTIVITY_SCOPE_GLOBAL));
+
+        inOrder.verify(mNetworkAgent).register();
+        inOrder.verify(mNetd, never()).networkCreate(any());
+        inOrder.verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
+
+        // Ipv6 link local route won't show up in the LinkProperties, so just
+        // verify ipv4 route.
+        final ArgumentCaptor<LinkProperties> lpCaptor =
+                ArgumentCaptor.forClass(LinkProperties.class);
+        inOrder.verify(mNetworkAgent).sendLinkProperties(lpCaptor.capture());
+        final RouteInfo expectedIpv4Route = new RouteInfo(PrefixUtils.asIpPrefix(mTestAddress),
+                null, IFACE_NAME, RouteInfo.RTN_UNICAST);
+        assertRoutes(List.of(expectedIpv4Route), lpCaptor.getValue().getRoutes());
+        assertEquals(IFACE_NAME, lpCaptor.getValue().getInterfaceName());
+
+        inOrder.verify(mNetworkAgent).markConnected();
+
+        // Mock ipv4-only upstream show up.
+        dispatchTetherConnectionChanged(UPSTREAM_IFACE);
+        inOrder.verifyNoMoreInteractions();
+
+        // Verify LinkProperties is updated when IPv6 connectivity is available.
+        final LinkProperties lp = new LinkProperties();
+        lp.setInterfaceName(UPSTREAM_IFACE);
+        lp.setLinkAddresses(UPSTREAM_ADDRESSES);
+        dispatchTetherConnectionChanged(UPSTREAM_IFACE, lp, -1);
+        inOrder.verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
+        inOrder.verify(mNetworkAgent).sendLinkProperties(lpCaptor.capture());
+
+        // Expect one Ipv4 route, plus one Ipv6 route.
+        final RouteInfo expectedIpv6Route = new RouteInfo(UPSTREAM_PREFIXES.toArray(
+                new IpPrefix[0])[0], null, IFACE_NAME, RouteInfo.RTN_UNICAST);
+        assertRoutes(List.of(expectedIpv4Route, expectedIpv6Route),
+                lpCaptor.getValue().getRoutes());
+        assertEquals(IFACE_NAME, lpCaptor.getValue().getInterfaceName());
+
+        dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
+        inOrder.verify(mNetworkAgent).unregister();
+        inOrder.verify(mNetd, never()).networkDestroy(anyInt());
+    }
+
+    private void assertRoutes(List<RouteInfo> expectedRoutes, List<RouteInfo> actualRoutes) {
+        assertTrue("Expected Routes: " + expectedRoutes + ", but got: " + actualRoutes,
+                expectedRoutes.equals(actualRoutes));
+    }
+
     @Test @IgnoreUpTo(Build.VERSION_CODES.R)
     public void dadProxyUpdates() throws Exception {
         InOrder inOrder = inOrder(mDadProxy);
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 dd10cc3..b537fb6 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
@@ -71,12 +71,12 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyBoolean;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyLong;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.argThat;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.inOrder;
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
index 16ebbbb..7bfdb22 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
@@ -38,17 +38,19 @@
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 import static com.android.networkstack.apishim.ConstantsShim.KEY_CARRIER_SUPPORTS_TETHERING_BOOL;
+import static com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import static com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 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.mockito.Matchers.anyBoolean;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyLong;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
@@ -159,10 +161,20 @@
             return super.getSystemServiceName(serviceClass);
         }
 
+        @NonNull
         @Override
         public Context createContextAsUser(UserHandle user, int flags) {
+            if (mCreateContextAsUserException != null) {
+                throw mCreateContextAsUserException;
+            }
             return mMockContext; // Return self for easier test injection.
         }
+
+        private RuntimeException mCreateContextAsUserException = null;
+
+        private void setCreateContextAsUserException(RuntimeException e) {
+            mCreateContextAsUserException = e;
+        }
     }
 
     class TestDependencies extends EntitlementManager.Dependencies {
@@ -591,8 +603,24 @@
                 .onTetherProvisioningFailed(TETHERING_WIFI, FAILED_TETHERING_REASON);
     }
 
+    @IgnoreUpTo(SC_V2)
     @Test
-    public void testUiProvisioningMultiUser() {
+    public void testUiProvisioningMultiUser_aboveT_createContextAsUserThrows() {
+        mMockContext.setCreateContextAsUserException(new IllegalStateException());
+        doTestUiProvisioningMultiUser(true, 1);
+        doTestUiProvisioningMultiUser(false, 1);
+    }
+
+    @IgnoreUpTo(SC_V2)
+    @Test
+    public void testUiProvisioningMultiUser_aboveT() {
+        doTestUiProvisioningMultiUser(true, 1);
+        doTestUiProvisioningMultiUser(false, 0);
+    }
+
+    @IgnoreAfter(SC_V2)
+    @Test
+    public void testUiProvisioningMultiUser_belowT() {
         doTestUiProvisioningMultiUser(true, 1);
         doTestUiProvisioningMultiUser(false, 1);
     }
@@ -630,6 +658,7 @@
         doReturn(isAdminUser).when(mUserManager).isAdminUser();
 
         mDeps.reset();
+        clearInvocations(mTetherProvisioningFailedListener);
         mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
index 087be26..c97fa3d 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
@@ -33,6 +33,11 @@
             }
 
             @Override
+            boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+                return true;
+            }
+
+            @Override
             boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                     boolean defaultValue) {
                 return defaultValue;
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java
index 7fcc5f1..01d7198 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java
@@ -25,14 +25,19 @@
 import android.os.Binder;
 import android.os.IBinder;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import java.util.Set;
+
 public class MockTetheringService extends TetheringService {
     private final Tethering mTethering = mock(Tethering.class);
     private final ArrayMap<String, Integer> mMockedPermissions = new ArrayMap<>();
     private final ArrayMap<String, Integer> mMockedPackageUids = new ArrayMap<>();
+    private final Set<String> mMockedDeviceOwnerPackages = new ArraySet<>();
+    private final Set<String> mMockedCarrierPrivilegedPackages = new ArraySet<>();
     private int mMockCallingUid;
 
     @Override
@@ -74,6 +79,16 @@
         return mMockCallingUid;
     }
 
+    @Override
+    boolean isDeviceOwner(final int uid, final String callerPkg) {
+        return mMockedDeviceOwnerPackages.contains(callerPkg);
+    }
+
+    @Override
+    boolean isCarrierPrivileged(final String callerPkg) {
+        return mMockedCarrierPrivilegedPackages.contains(callerPkg);
+    }
+
     public Tethering getTethering() {
         return mTethering;
     }
@@ -118,5 +133,33 @@
         public void setCallingUid(int uid) {
             mMockCallingUid = uid;
         }
+
+        /**
+         * Add a mocked carrier privileges package
+         */
+        public void addDeviceOwnerPackage(final String packageName) {
+            mMockedDeviceOwnerPackages.add(packageName);
+        }
+
+        /**
+         * Remove a mocked carrier privileges package
+         */
+        public void removeDeviceOwnerPackage(final String packageName) {
+            mMockedDeviceOwnerPackages.remove(packageName);
+        }
+
+        /**
+         * Add a mocked carrier privileges package
+         */
+        public void addCarrierPrivilegedPackage(final String packageName) {
+            mMockedCarrierPrivilegedPackages.add(packageName);
+        }
+
+        /**
+         * Remove a mocked carrier privileges package
+         */
+        public void removeCarrierPrivilegedPackage(final String packageName) {
+            mMockedCarrierPrivilegedPackages.remove(packageName);
+        }
     }
 }
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/OffloadControllerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/OffloadControllerTest.java
index 36c15a7..ce56860 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/OffloadControllerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/OffloadControllerTest.java
@@ -42,11 +42,10 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyLong;
-import static org.mockito.Matchers.anyObject;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
@@ -282,7 +281,7 @@
         // This LinkProperties value does not differ from the default upstream.
         // There should be no extraneous call to setUpstreamParameters().
         inOrder.verify(mHardware, never()).setUpstreamParameters(
-                anyObject(), anyObject(), anyObject(), anyObject());
+                any(), any(), any(), any());
         inOrder.verifyNoMoreInteractions();
 
         final LinkProperties lp = new LinkProperties();
@@ -410,7 +409,7 @@
         // This LinkProperties value does not differ from the default upstream.
         // There should be no extraneous call to setUpstreamParameters().
         inOrder.verify(mHardware, never()).setUpstreamParameters(
-                anyObject(), anyObject(), anyObject(), anyObject());
+                any(), any(), any(), any());
         inOrder.verifyNoMoreInteractions();
     }
 
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
index 1608e1a..ada88fb 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -26,7 +26,6 @@
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
 import static android.net.ip.IpServer.CMD_NOTIFY_PREFIX_CONFLICT;
 
-import static com.android.net.module.util.PrivateAddressCoordinator.TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 
 import static org.junit.Assert.assertEquals;
@@ -51,6 +50,7 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.ip.IpServer;
+import android.os.Build;
 import android.os.IBinder;
 
 import androidx.test.filters.SmallTest;
@@ -58,8 +58,10 @@
 
 import com.android.net.module.util.IIpv4PrefixRequest;
 import com.android.net.module.util.PrivateAddressCoordinator;
+import com.android.testutils.DevSdkIgnoreRule;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
@@ -71,6 +73,9 @@
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public final class PrivateAddressCoordinatorTest {
+    @Rule
+    public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+
     private static final String TEST_IFNAME = "test0";
 
     @Mock private IpServer mHotspotIpServer;
@@ -186,7 +191,8 @@
         // - Test bluetooth prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(mBluetoothAddress.getAddress().getAddress()));
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer);
+        final LinkAddress hotspotAddress = requestStickyDownstreamAddress(mHotspotIpServer,
+                CONNECTIVITY_SCOPE_GLOBAL);
         final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
         assertNotEquals(asIpPrefix(mBluetoothAddress), hotspotPrefix);
         releaseDownstream(mHotspotIpServer);
@@ -230,11 +236,9 @@
         assertEquals(usbAddress, newUsbAddress);
 
         final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.88.23/16"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
+                hotspotAddress, null, makeNetworkCapabilities(TRANSPORT_WIFI));
         updateUpstreamPrefix(wifiUpstream);
         verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        verify(mUsbIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
     }
 
     private UpstreamNetworkState buildUpstreamNetworkState(final Network network,
@@ -322,10 +326,9 @@
         assertFalse(localHotspotPrefix.containsPrefix(hotspotPrefix));
     }
 
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     @Test
     public void testStartedPrefixRange() throws Exception {
-        when(mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)).thenReturn(true);
-
         startedPrefixBaseTest("192.168.0.0/16", 0);
 
         startedPrefixBaseTest("192.168.0.0/16", 1);
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
new file mode 100644
index 0000000..086f2d2
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2025 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.networkstack.tethering;
+
+import static android.net.TetheringManager.TETHERING_USB;
+import static android.net.TetheringManager.TETHERING_WIFI;
+import static android.net.TetheringManager.TETHERING_VIRTUAL;
+
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+
+import android.net.TetheringManager.TetheringRequest;
+import android.net.ip.IpServer;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.networkstack.tethering.RequestTracker.AddResult;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RequestTrackerTest {
+    private RequestTracker mRequestTracker;
+
+    @Test
+    public void testNoRequestsAdded_noPendingRequests() {
+        mRequestTracker = new RequestTracker(false);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testAddRequest_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+
+        final AddResult result = mRequestTracker.addPendingRequest(request);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_equalRequestExists_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final TetheringRequest equalRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final AddResult result = mRequestTracker.addPendingRequest(equalRequest);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_equalButDifferentUidRequest_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request.setUid(1000);
+        request.setPackageName("package");
+        final TetheringRequest differentUid = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        differentUid.setUid(2000);
+        differentUid.setPackageName("package2");
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(differentUid);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(differentUid);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(differentUid);
+    }
+
+    @Test
+    public void testAddRequest_conflictingPendingRequest_returnsFailureConflictingRequestRestart() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_RESTART);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_noExistingRequestsFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+
+        final AddResult result = mRequestTracker.addPendingRequest(request);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_conflictingPendingRequestFuzzyMatching_returnsFailure() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_conflictingServingRequestFuzzyMatching_returnsFailure() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testAddRequest_nonMatchingPendingRequestFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest nonFuzzyMatched = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(nonFuzzyMatched);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        // Next request is still the first, but verify RequestTracker contains the second request by
+        // seeing if it rejects anything matching the second request
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(request);
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(nonFuzzyMatched))
+                .isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+    }
+
+    @Test
+    public void testAddRequest_nonMatchingServingRequestFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest nonFuzzyMatched = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(nonFuzzyMatched);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(nonFuzzyMatched);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(nonFuzzyMatched);
+    }
+
+    @Test
+    public void testRemovePendingRequest_removesAllPendingRequestsOfType() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request1 = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request1.setUid(1000);
+        request1.setPackageName("package");
+        mRequestTracker.addPendingRequest(request1);
+        final TetheringRequest request2 = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request2.setUid(2000);
+        request2.setPackageName("package2");
+
+        mRequestTracker.removePendingRequest(request2);
+
+        // Verify request1 isn't pending even though we tried to remove a different request
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemovePendingRequest_fuzzyMatching_onlyTheEqualRequestIsRemoved() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request1 = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest request2 = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.addPendingRequest(request1);
+        mRequestTracker.addPendingRequest(request2);
+
+        mRequestTracker.removePendingRequest(request2);
+
+        // Verify request1 is still pending.
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL)).isEqualTo(request1);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(request1);
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(request1))
+                .isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        // Verify we've removed request2 by checking if it can be added back without
+        // FAILURE_CONFLICTING_REQUEST_FAIL.
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(request2))
+                .isEqualTo(AddResult.SUCCESS);
+    }
+
+    @Test
+    public void testRemoveAllPendingRequests_noPendingRequestsLeft() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest firstRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        firstRequest.setUid(1000);
+        firstRequest.setPackageName("package");
+        mRequestTracker.addPendingRequest(firstRequest);
+        final TetheringRequest secondRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        secondRequest.setUid(2000);
+        secondRequest.setPackageName("package2");
+        mRequestTracker.addPendingRequest(secondRequest);
+
+        mRequestTracker.removeAllPendingRequests(TETHERING_WIFI);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemoveAllPendingRequests_differentTypeExists_doesNotRemoveDifferentType() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest differentType = new TetheringRequest.Builder(TETHERING_USB).build();
+        mRequestTracker.addPendingRequest(differentType);
+
+        mRequestTracker.removeAllPendingRequests(TETHERING_WIFI);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_USB)).isEqualTo(differentType);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_USB))
+                .isEqualTo(differentType);
+    }
+
+    @Test
+    public void testPromoteRequestToServing_requestIsntPendingAnymore() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testPromoteRequestToServing_fuzzyMatching_requestIsntPendingAnymore() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemoveServingRequest_fuzzyMatching_requestCanBeAddedAgain() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+        IpServer ipServer = mock(IpServer.class);
+        mRequestTracker.promoteRequestToServing(ipServer, request);
+
+        mRequestTracker.removeServingRequest(ipServer);
+
+        AddResult result = mRequestTracker.addPendingRequest(request);
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testRemoveAllServingRequests_fuzzyMatching_requestCanBeAddedAgain() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        mRequestTracker.removeAllServingRequests(TETHERING_WIFI);
+
+        AddResult result = mRequestTracker.addPendingRequest(request);
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+}
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TestConnectivityManager.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TestConnectivityManager.java
index b2cbf75..51ba140 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TestConnectivityManager.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TestConnectivityManager.java
@@ -18,6 +18,7 @@
 
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 
 import static com.android.networkstack.apishim.common.ShimUtils.isAtLeastS;
@@ -41,6 +42,8 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import java.util.Map;
 import java.util.Objects;
 
@@ -119,12 +122,15 @@
                 && mLegacyTypeMap.isEmpty();
     }
 
-    boolean isListeningForAll() {
-        final NetworkCapabilities empty = new NetworkCapabilities();
-        empty.clearAll();
+    boolean isListeningForUpstream() {
+        final NetworkCapabilities upstreamNc = new NetworkCapabilities();
+        upstreamNc.clearAll();
+        if (SdkLevel.isAtLeastV()) {
+            upstreamNc.addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        }
 
         for (NetworkRequestInfo nri : mListening.values()) {
-            if (nri.request.networkCapabilities.equalRequestableCapabilities(empty)) {
+            if (nri.request.networkCapabilities.equalRequestableCapabilities(upstreamNc)) {
                 return true;
             }
         }
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
index dd51c7a..42882c7 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
@@ -37,7 +37,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.when;
 
@@ -63,6 +63,7 @@
 import com.android.internal.util.test.FakeSettingsProvider;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.SdkUtil;
 import com.android.net.module.util.SharedLog;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
@@ -160,6 +161,11 @@
         }
 
         @Override
+        boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+            return isMockFlagEnabled(name, true /* defaultEnabled */);
+        }
+
+        @Override
         boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                 boolean defaultValue) {
             // Flags should use isFeatureEnabled instead of getBoolean; see comments in
@@ -767,14 +773,17 @@
 
     @Test
     public void testEnableSyncSMFlag() throws Exception {
-        // Test default disabled
+        // Test default enabled
         setTetherEnableSyncSMFlagEnabled(null);
-        assertEnableSyncSM(false);
+        assertEnableSyncSM(true);
 
         setTetherEnableSyncSMFlagEnabled(true);
         assertEnableSyncSM(true);
 
-        setTetherEnableSyncSMFlagEnabled(false);
-        assertEnableSyncSM(false);
+        // Feature is enabled by default after 25Q2 release.
+        if (!SdkUtil.isAtLeast25Q2()) {
+            setTetherEnableSyncSMFlagEnabled(false);
+            assertEnableSyncSM(false);
+        }
     }
 }
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringNotificationUpdaterTest.kt b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringNotificationUpdaterTest.kt
index ac3d713..19a858a 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringNotificationUpdaterTest.kt
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringNotificationUpdaterTest.kt
@@ -67,7 +67,7 @@
 import org.mockito.Mockito.reset
 import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
-import org.mockito.Mockito.verifyZeroInteractions
+import org.mockito.Mockito.verifyNoMoreInteractions
 import org.mockito.MockitoAnnotations
 
 const val TEST_SUBID = 1
@@ -246,7 +246,7 @@
 
         // No downstream.
         notificationUpdater.onDownstreamChanged(DOWNSTREAM_NONE)
-        verifyZeroInteractions(notificationManager)
+        verifyNoMoreInteractions(notificationManager)
 
         // User restrictions on again. Show restricted notification.
         notificationUpdater.notifyTetheringDisabledByRestriction()
@@ -308,7 +308,7 @@
 
         // Same capabilities changed. Nothing happened.
         notificationUpdater.onUpstreamCapabilitiesChanged(null)
-        verifyZeroInteractions(notificationManager)
+        verifyNoMoreInteractions(notificationManager)
 
         // Upstream come back. Clear no upstream notification.
         notificationUpdater.onUpstreamCapabilitiesChanged(HOME_CAPABILITIES)
@@ -384,7 +384,7 @@
 
         // Same capabilities change. Nothing happened.
         notificationUpdater.onUpstreamCapabilitiesChanged(ROAMING_CAPABILITIES)
-        verifyZeroInteractions(notificationManager)
+        verifyNoMoreInteractions(notificationManager)
 
         // Upstream capabilities changed to home state. Clear roaming notification.
         notificationUpdater.onUpstreamCapabilitiesChanged(HOME_CAPABILITIES)
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
index d94852e..b58fa14 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
@@ -21,6 +21,7 @@
 import static android.Manifest.permission.TETHER_PRIVILEGED;
 import static android.Manifest.permission.WRITE_SETTINGS;
 import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.net.TetheringManager.TETHERING_USB;
 import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION;
@@ -36,6 +37,7 @@
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
@@ -53,7 +55,7 @@
 import android.net.TetheringManager;
 import android.net.TetheringManager.TetheringRequest;
 import android.net.TetheringRequestParcel;
-import android.net.ip.IpServer;
+import android.net.wifi.SoftApConfiguration;
 import android.os.Bundle;
 import android.os.ConditionVariable;
 import android.os.Handler;
@@ -88,6 +90,15 @@
     private static final int TEST_CALLER_UID = 1234;
     private static final String TEST_ATTRIBUTION_TAG = null;
     private static final String TEST_WRONG_PACKAGE = "wrong.package";
+    private static final int NO_RESULT = -1;
+    private static final TetheringRequest USB_REQUEST =
+            new TetheringRequest.Builder(TETHERING_USB).build();
+    private static final TetheringRequest WIFI_REQUEST_NO_CONFIG =
+            new TetheringRequest.Builder(TETHERING_WIFI).build();
+    private static final TetheringRequest WIFI_REQUEST_WITH_CONFIG =
+            new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(new SoftApConfiguration.Builder().build())
+                    .build();
     @Mock private ITetheringEventCallback mITetheringEventCallback;
     @Rule public ServiceTestRule mServiceTestRule;
     private Tethering mTethering;
@@ -98,7 +109,7 @@
     @Mock private AppOpsManager mAppOps;
 
     private class TestTetheringResult extends IIntResultListener.Stub {
-        private int mResult = -1; // Default value that does not match any result code.
+        private int mResult = NO_RESULT;
         @Override
         public void onResult(final int resultCode) {
             mResult = resultCode;
@@ -113,7 +124,7 @@
         MyResultReceiver(Handler handler) {
             super(handler);
         }
-        private int mResult = -1; // Default value that does not match any result code.
+        private int mResult = NO_RESULT;
         @Override
         protected void onReceiveResult(int resultCode, Bundle resultData) {
             mResult = resultCode;
@@ -154,31 +165,73 @@
     }
 
     private void runAsNoPermission(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, new String[0]);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, new String[0]);
     }
 
     private void runAsTetherPrivileged(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, TETHER_PRIVILEGED);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, TETHER_PRIVILEGED);
     }
 
     private void runAsAccessNetworkState(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, ACCESS_NETWORK_STATE);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, ACCESS_NETWORK_STATE);
     }
 
     private void runAsWriteSettings(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, WRITE_SETTINGS);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, WRITE_SETTINGS);
+    }
+
+    private void runAsWriteSettingsWhenWriteSettingsAllowed(
+            final TestTetheringCall test) throws Exception {
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                false /* isTetheringWithSoftApConfigEnabled */, WRITE_SETTINGS);
     }
 
     private void runAsTetheringDisallowed(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, false /* isTetheringAllowed */, TETHER_PRIVILEGED);
+        runTetheringCall(test, false /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, TETHER_PRIVILEGED);
     }
 
     private void runAsNetworkSettings(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, NETWORK_SETTINGS, TETHER_PRIVILEGED);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, NETWORK_SETTINGS, TETHER_PRIVILEGED);
+    }
+
+    private void runAsDeviceOwner(final TestTetheringCall test) throws Exception {
+        mMockConnector.addDeviceOwnerPackage(TEST_CALLER_PKG);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, new String[0]);
+        mMockConnector.removeDeviceOwnerPackage(TEST_CALLER_PKG);
+    }
+
+    private void runAsDeviceOwnerWhenDeviceOwnerBypassNotEnabled(final TestTetheringCall test)
+            throws Exception {
+        mMockConnector.addDeviceOwnerPackage(TEST_CALLER_PKG);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                false /* isTetheringWithSoftApConfigEnabled */, new String[0]);
+        mMockConnector.removeDeviceOwnerPackage(TEST_CALLER_PKG);
+    }
+
+    private void runAsCarrierPrivileged(final TestTetheringCall test) throws Exception {
+        mMockConnector.addCarrierPrivilegedPackage(TEST_CALLER_PKG);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                true /* isTetheringWithSoftApConfigEnabled */, new String[0]);
+        mMockConnector.removeCarrierPrivilegedPackage(TEST_CALLER_PKG);
+    }
+
+    private void runAsCarrierPrivilegedWhenCarrierPrivilegeBypassNotEnabled(
+            final TestTetheringCall test) throws Exception {
+        mMockConnector.addCarrierPrivilegedPackage(TEST_CALLER_PKG);
+        runTetheringCall(test, true /* isTetheringAllowed */,
+                false /* isTetheringWithSoftApConfigEnabled */, new String[0]);
+        mMockConnector.removeCarrierPrivilegedPackage(TEST_CALLER_PKG);
     }
 
     private void runTetheringCall(final TestTetheringCall test, boolean isTetheringAllowed,
-            String... permissions) throws Exception {
+            boolean isTetheringWithSoftApConfigEnabled, String... permissions) throws Exception {
         // Allow the test to run even if ACCESS_NETWORK_STATE was granted at the APK level
         if (!CollectionUtils.contains(permissions, ACCESS_NETWORK_STATE)) {
             mMockConnector.setPermission(ACCESS_NETWORK_STATE, PERMISSION_DENIED);
@@ -188,6 +241,8 @@
         try {
             when(mTethering.isTetheringSupported()).thenReturn(true);
             when(mTethering.isTetheringAllowed()).thenReturn(isTetheringAllowed);
+            when(mTethering.isTetheringWithSoftApConfigEnabled())
+                    .thenReturn(isTetheringWithSoftApConfigEnabled);
             test.runTetheringCall(new TestTetheringResult());
         } finally {
             mUiAutomation.dropShellPermissionIdentity();
@@ -205,7 +260,7 @@
         mTetheringConnector.tether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
         verify(mTethering).isTetheringSupported();
         verify(mTethering).isTetheringAllowed();
-        verify(mTethering).tether(TEST_IFACE_NAME, IpServer.STATE_TETHERED, result);
+        verify(mTethering).legacyTether(TEST_IFACE_NAME, result);
     }
 
     @Test
@@ -213,7 +268,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.tether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -224,7 +279,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.tether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runTether(result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -244,7 +308,7 @@
                 result);
         verify(mTethering).isTetheringSupported();
         verify(mTethering).isTetheringAllowed();
-        verify(mTethering).untether(eq(TEST_IFACE_NAME), eq(result));
+        verify(mTethering).legacyUntether(eq(TEST_IFACE_NAME), eq(result));
     }
 
     @Test
@@ -252,7 +316,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.untether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -263,7 +327,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.untether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runUnTether(result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -297,7 +370,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.setUsbTethering(true /* enable */, TEST_CALLER_PKG,
                     TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -308,7 +381,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.setUsbTethering(true /* enable */, TEST_CALLER_PKG,
+                    TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runSetUsbTethering(result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -323,67 +405,114 @@
         });
     }
 
-    private void runStartTethering(final TestTetheringResult result,
-            final TetheringRequestParcel request) throws Exception {
-        mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
-                result);
-        verify(mTethering).isTetheringSupported();
-        verify(mTethering).isTetheringAllowed();
-        verify(mTethering).startTethering(
-                eq(new TetheringRequest(request)), eq(TEST_CALLER_PKG), eq(result));
+    private void verifyStartTetheringRequestSucceeds(final TetheringRequest request,
+            final TestTetheringResult result) throws Exception {
+        mTetheringConnector.startTethering(request.getParcel(), TEST_CALLER_PKG,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering).startTethering(eq(request), eq(TEST_CALLER_PKG), eq(result));
+        reset(mTethering);
+        result.assertResult(NO_RESULT);
+    }
+
+    private void verifyStartTetheringRequestFails(final TetheringRequest request,
+            final TestTetheringResult result, final int resultCode) throws Exception {
+        mTetheringConnector.startTethering(request.getParcel(), TEST_CALLER_PKG,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering, never()).startTethering(any(), any(), any());
+        reset(mTethering);
+        result.assertResult(resultCode);
+    }
+
+    private void verifyStartTetheringRequestWithWrongPackageFails(final TetheringRequest request,
+            final TestTetheringResult result) throws Exception {
+        mTetheringConnector.startTethering(request.getParcel(), TEST_WRONG_PACKAGE,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering, never()).startTethering(any(), any(), any());
+        reset(mTethering);
+        result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
     }
 
     @Test
     public void testStartTethering() throws Exception {
-        final TetheringRequestParcel request = new TetheringRequestParcel();
-        request.tetheringType = TETHERING_WIFI;
-
         runAsNoPermission((result) -> {
-            mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
-                    result);
-            verify(mTethering).isTetherProvisioningRequired();
-            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
-            verifyNoMoreInteractionsForTethering();
+            verifyStartTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
         });
 
         runAsTetherPrivileged((result) -> {
-            mTetheringConnector.startTethering(request, TEST_WRONG_PACKAGE,
-                    TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering, never()).startTethering(
-                    eq(new TetheringRequest(request)), eq(TEST_WRONG_PACKAGE), eq(result));
-            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
-            verifyNoMoreInteractionsForTethering();
+            verifyStartTetheringRequestSucceeds(WIFI_REQUEST_NO_CONFIG, result);
         });
 
         runAsTetherPrivileged((result) -> {
-            runStartTethering(result, request);
-            verifyNoMoreInteractionsForTethering();
+            verifyStartTetheringRequestWithWrongPackageFails(WIFI_REQUEST_NO_CONFIG, result);
         });
 
         runAsWriteSettings((result) -> {
-            runStartTethering(result, request);
-            verify(mTethering).isTetherProvisioningRequired();
-            verifyNoMoreInteractionsForTethering();
+            verifyStartTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
+            verifyStartTetheringRequestSucceeds(WIFI_REQUEST_NO_CONFIG, result);
         });
 
         runAsTetheringDisallowed((result) -> {
-            mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
-                    result);
-            verify(mTethering).isTetheringSupported();
-            verify(mTethering).isTetheringAllowed();
-            result.assertResult(TETHER_ERROR_UNSUPPORTED);
-            verifyNoMoreInteractionsForTethering();
+            verifyStartTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_UNSUPPORTED);
+        });
+
+        // Not wifi -> fail
+        runAsDeviceOwner((result) -> {
+            verifyStartTetheringRequestFails(USB_REQUEST, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // No SoftApConfiguration -> fail
+        runAsDeviceOwner((result) -> {
+            verifyStartTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // With SoftApConfiguration -> success
+        runAsDeviceOwner((result) -> {
+            verifyStartTetheringRequestSucceeds(WIFI_REQUEST_WITH_CONFIG, result);
+        });
+
+        runAsDeviceOwnerWhenDeviceOwnerBypassNotEnabled((result) -> {
+            verifyStartTetheringRequestFails(WIFI_REQUEST_WITH_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // Not wifi -> fail
+        runAsCarrierPrivileged((result) -> {
+            verifyStartTetheringRequestFails(USB_REQUEST, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // No SoftApConfiguration -> fail
+        runAsCarrierPrivileged((result) -> {
+            verifyStartTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // With SoftApConfiguration -> success
+        runAsCarrierPrivileged((result) -> {
+            verifyStartTetheringRequestSucceeds(WIFI_REQUEST_WITH_CONFIG, result);
+        });
+
+        runAsCarrierPrivilegedWhenCarrierPrivilegeBypassNotEnabled((result) -> {
+            verifyStartTetheringRequestFails(WIFI_REQUEST_WITH_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
         });
     }
 
     @Test
     public void testStartTetheringWithInterfaceSucceeds() throws Exception {
-        final TetheringRequestParcel request = new TetheringRequestParcel();
-        request.tetheringType = TETHERING_VIRTUAL;
-        request.interfaceName = "avf_tap_fixed";
-
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("avf_tap_fixed")
+                .build();
         runAsNetworkSettings((result) -> {
-            runStartTethering(result, request);
+            verifyStartTetheringRequestSucceeds(request, result);
             verifyNoMoreInteractionsForTethering();
         });
     }
@@ -398,6 +527,7 @@
             mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verifyNoMoreInteractionsForTethering();
         });
     }
@@ -410,6 +540,7 @@
         mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                 result);
         result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
         verifyNoMoreInteractionsForTethering();
     }
 
@@ -446,7 +577,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.stopTethering(TETHERING_WIFI, TEST_CALLER_PKG,
                     TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -457,7 +588,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.stopTethering(TETHERING_WIFI, TEST_CALLER_PKG,
+                    TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runStopTethering(result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -472,6 +612,113 @@
         });
     }
 
+    private void verifyStopTetheringRequestSucceeds(final TetheringRequest request,
+            final TestTetheringResult result) throws Exception {
+        mTetheringConnector.stopTetheringRequest(request, TEST_CALLER_PKG,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering).stopTetheringRequest(any(), any());
+        reset(mTethering);
+        result.assertResult(NO_RESULT);
+    }
+
+    private void verifyStopTetheringRequestFails(final TetheringRequest request,
+            final TestTetheringResult result, int resultCode) throws Exception {
+        mTetheringConnector.stopTetheringRequest(request, TEST_CALLER_PKG,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering, never()).stopTetheringRequest(any(), any());
+        reset(mTethering);
+        result.assertResult(resultCode);
+    }
+
+    private void verifyStopTetheringRequestWithWrongPackageFails(final TetheringRequest request,
+            final TestTetheringResult result) throws Exception {
+        mTetheringConnector.stopTetheringRequest(request, TEST_WRONG_PACKAGE,
+                TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering, never()).stopTetheringRequest(any(), any());
+        reset(mTethering);
+        result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+    }
+
+    @Test
+    public void testStopTetheringRequest() throws Exception {
+        runAsNoPermission((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        runAsTetherPrivileged((result) -> {
+            verifyStopTetheringRequestSucceeds(WIFI_REQUEST_NO_CONFIG, result);
+        });
+
+        runAsTetherPrivileged((result) -> {
+            verifyStopTetheringRequestWithWrongPackageFails(WIFI_REQUEST_NO_CONFIG, result);
+        });
+
+        runAsWriteSettings((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
+            // Note: This can't happen in practice since WRITE_SETTINGS is only allowed on V- and
+            // stopTetheringRequest is only allowed on B+, but we test here for completeness.
+            verifyStopTetheringRequestSucceeds(WIFI_REQUEST_NO_CONFIG, result);
+        });
+
+        runAsTetheringDisallowed((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_UNSUPPORTED);
+        });
+
+        runAsNetworkSettings((result) -> {
+            verifyStopTetheringRequestSucceeds(WIFI_REQUEST_NO_CONFIG, result);
+        });
+
+        // Not wifi -> fail
+        runAsDeviceOwner((result) -> {
+            verifyStopTetheringRequestFails(USB_REQUEST, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // No SoftApConfiguration -> fail
+        runAsDeviceOwner((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // With SoftApConfiguration -> success
+        runAsDeviceOwner((result) -> {
+            verifyStopTetheringRequestSucceeds(WIFI_REQUEST_WITH_CONFIG, result);
+        });
+
+        runAsDeviceOwnerWhenDeviceOwnerBypassNotEnabled((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_WITH_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // Not wifi -> fail
+        runAsCarrierPrivileged((result) -> {
+            verifyStopTetheringRequestFails(USB_REQUEST, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // No SoftApConfiguration -> fail
+        runAsCarrierPrivileged((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_NO_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+
+        // With SoftApConfiguration -> success
+        runAsCarrierPrivileged((result) -> {
+            verifyStopTetheringRequestSucceeds(WIFI_REQUEST_WITH_CONFIG, result);
+        });
+
+        runAsCarrierPrivilegedWhenCarrierPrivilegeBypassNotEnabled((result) -> {
+            verifyStopTetheringRequestFails(WIFI_REQUEST_WITH_CONFIG, result,
+                    TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        });
+    }
+
     private void runRequestLatestTetheringEntitlementResult() throws Exception {
         final MyResultReceiver result = new MyResultReceiver(null);
         mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
@@ -486,11 +733,13 @@
     public void testRequestLatestTetheringEntitlementResult() throws Exception {
         // Run as no permission.
         final MyResultReceiver result = new MyResultReceiver(null);
-        mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
-                true /* showEntitlementUi */, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG);
-        verify(mTethering).isTetherProvisioningRequired();
-        result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
-        verifyNoMoreInteractions(mTethering);
+        runAsNoPermission((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
 
         runAsTetherPrivileged((none) -> {
             runRequestLatestTetheringEntitlementResult();
@@ -501,22 +750,30 @@
             mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
                     true /* showEntitlementUi */, TEST_WRONG_PACKAGE, TEST_ATTRIBUTION_TAG);
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
-            verifyNoMoreInteractions(mTethering);
+            verifyNoMoreInteractionsForTethering();
         });
 
         runAsWriteSettings((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((none) -> {
             runRequestLatestTetheringEntitlementResult();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
 
         runAsTetheringDisallowed((none) -> {
-            final MyResultReceiver receiver = new MyResultReceiver(null);
-            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver,
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
                     true /* showEntitlementUi */, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG);
             verify(mTethering).isTetheringSupported();
             verify(mTethering).isTetheringAllowed();
-            receiver.assertResult(TETHER_ERROR_UNSUPPORTED);
+            result.assertResult(TETHER_ERROR_UNSUPPORTED);
             verifyNoMoreInteractionsForTethering();
         });
     }
@@ -592,7 +849,7 @@
         mTetheringConnector.stopAllTethering(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
         verify(mTethering).isTetheringSupported();
         verify(mTethering).isTetheringAllowed();
-        verify(mTethering).untetherAll();
+        verify(mTethering).stopAllTethering();
         result.assertResult(TETHER_ERROR_NO_ERROR);
     }
 
@@ -600,7 +857,7 @@
     public void testStopAllTethering() throws Exception {
         runAsNoPermission((result) -> {
             mTetheringConnector.stopAllTethering(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -611,7 +868,15 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.stopAllTethering(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runStopAllTethering(result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -627,6 +892,7 @@
 
     private void runIsTetheringSupported(final TestTetheringResult result) throws Exception {
         mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
+        verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
         verify(mTethering).isTetheringSupported();
         verify(mTethering).isTetheringAllowed();
         result.assertResult(TETHER_ERROR_NO_ERROR);
@@ -637,7 +903,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -648,6 +914,14 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runIsTetheringSupported(result);
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
@@ -656,6 +930,7 @@
         runAsTetheringDisallowed((result) -> {
             mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
+            verify(mTethering, atLeastOnce()).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetheringSupported();
             verify(mTethering).isTetheringAllowed();
             result.assertResult(TETHER_ERROR_UNSUPPORTED);
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index 0c6a95d..bbe15b4 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -50,15 +50,18 @@
 import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_IFACE_CFG_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_INTERNAL_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_IFACE;
+import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_FAILED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STARTED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STOPPED;
 import static android.net.dhcp.IDhcpServer.STATUS_SUCCESS;
+import static android.net.dhcp.IDhcpServer.STATUS_UNKNOWN_ERROR;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_INTERFACE_NAME;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_MODE;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_STATE;
@@ -66,11 +69,13 @@
 import static android.net.wifi.WifiManager.IFACE_IP_MODE_TETHERED;
 import static android.net.wifi.WifiManager.WIFI_AP_STATE_DISABLED;
 import static android.net.wifi.WifiManager.WIFI_AP_STATE_ENABLED;
+import static android.net.wifi.WifiManager.WIFI_AP_STATE_FAILED;
 import static android.system.OsConstants.RT_SCOPE_UNIVERSE;
 import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
 
 import static com.android.modules.utils.build.SdkLevel.isAtLeastS;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
+import static com.android.modules.utils.build.SdkLevel.isAtLeastV;
 import static com.android.net.module.util.Inet4AddressUtils.inet4AddressToIntHTH;
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
@@ -79,6 +84,7 @@
 import static com.android.networkstack.tethering.TestConnectivityManager.BROADCAST_FIRST;
 import static com.android.networkstack.tethering.TestConnectivityManager.CALLBACKS_FIRST;
 import static com.android.networkstack.tethering.Tethering.UserRestrictionActionListener;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHERING_LOCAL_NETWORK_AGENT;
 import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_FORCE_USB_FUNCTIONS;
 import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_USB_NCM_FUNCTION;
 import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_USB_RNDIS_FUNCTION;
@@ -93,15 +99,15 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
-import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.notNull;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
@@ -166,6 +172,7 @@
 import android.net.ip.IpServer;
 import android.net.ip.RouterAdvertisementDaemon;
 import android.net.wifi.SoftApConfiguration;
+import android.net.wifi.SoftApState;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
@@ -187,6 +194,7 @@
 import android.telephony.PhoneStateListener;
 import android.telephony.TelephonyManager;
 import android.test.mock.MockContentResolver;
+import android.util.ArrayMap;
 import android.util.ArraySet;
 
 import androidx.annotation.NonNull;
@@ -214,6 +222,7 @@
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.MiscAsserts;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule;
 
 import org.junit.After;
 import org.junit.Before;
@@ -238,12 +247,23 @@
 import java.util.List;
 import java.util.Set;
 import java.util.Vector;
+import java.util.concurrent.Executor;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public class TetheringTest {
     @Rule public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
 
+    final ArrayMap<String, Boolean> mFeatureFlags = new ArrayMap<>();
+    // 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 int IFINDEX_OFFSET = 100;
 
     private static final String TEST_MOBILE_IFNAME = "test_rmnet_data0";
@@ -267,8 +287,9 @@
     private static final String TEST_P2P_REGEX = "test_p2p-p2p\\d-.*";
     private static final String TEST_BT_REGEX = "test_pan\\d";
     private static final int TEST_CALLER_UID = 1000;
+    private static final int TEST_CALLER_UID_2 = 2000;
     private static final String TEST_CALLER_PKG = "com.test.tethering";
-
+    private static final String TEST_CALLER_PKG_2 = "com.test.tethering2";
     private static final int CELLULAR_NETID = 100;
     private static final int WIFI_NETID = 101;
     private static final int DUN_NETID = 102;
@@ -307,8 +328,7 @@
     @Mock private TetheringMetrics mTetheringMetrics;
     @Mock private PrivateAddressCoordinator.Dependencies mPrivateAddressCoordinatorDependencies;
 
-    private final MockIpServerDependencies mIpServerDependencies =
-            spy(new MockIpServerDependencies());
+    private MockIpServerDependencies mIpServerDependencies;
     private final MockTetheringDependencies mTetheringDependencies =
             new MockTetheringDependencies();
 
@@ -339,6 +359,7 @@
     private TestConnectivityManager mCm;
     private boolean mForceEthernetServiceUnavailable = false;
     private int mBinderCallingUid = TEST_CALLER_UID;
+    private boolean mTetheringWithSoftApConfigEnabled = SdkLevel.isAtLeastB();
 
     private class TestContext extends BroadcastInterceptingContext {
         TestContext(Context base) {
@@ -395,6 +416,9 @@
     }
 
     public class MockIpServerDependencies extends IpServer.Dependencies {
+
+        private int mOnDhcpServerCreatedResult = STATUS_SUCCESS;
+
         @Override
         public DadProxy getDadProxy(
                 Handler handler, InterfaceParams ifParams) {
@@ -436,7 +460,7 @@
                 DhcpServerCallbacks cb) {
             new Thread(() -> {
                 try {
-                    cb.onDhcpServerCreated(STATUS_SUCCESS, mDhcpServer);
+                    cb.onDhcpServerCreated(mOnDhcpServerCreatedResult, mDhcpServer);
                 } catch (RemoteException e) {
                     fail(e.getMessage());
                 }
@@ -447,6 +471,15 @@
                 IpNeighborMonitor.NeighborEventConsumer c) {
             return mIpNeighborMonitor;
         }
+
+        public void setOnDhcpServerCreatedResult(final int result) {
+            mOnDhcpServerCreatedResult = result;
+        }
+
+        @Override
+        public boolean isFeatureEnabled(Context context, String name) {
+            return mFeatureFlags.getOrDefault(name, false);
+        }
     }
 
     public class MockTetheringDependencies extends TetheringDependencies {
@@ -572,6 +605,11 @@
         public int getBinderCallingUid() {
             return mBinderCallingUid;
         }
+
+        @Override
+        public boolean isTetheringWithSoftApConfigEnabled() {
+            return mTetheringWithSoftApConfigEnabled;
+        }
     }
 
     private static LinkProperties buildUpstreamLinkProperties(String interfaceName,
@@ -707,6 +745,9 @@
 
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
+        mIpServerDependencies = spy(new MockIpServerDependencies());
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+        mTetheringWithSoftApConfigEnabled = SdkLevel.isAtLeastB();
     }
 
     // In order to interact with syncSM from the test, tethering must be created in test thread.
@@ -785,7 +826,10 @@
         if (interfaceName != null) {
             builder.setInterfaceName(interfaceName);
         }
-        return builder.build();
+        TetheringRequest request = builder.build();
+        request.setUid(TEST_CALLER_UID);
+        request.setPackageName(TEST_CALLER_PKG);
+        return request;
     }
 
     @NonNull
@@ -820,6 +864,38 @@
         mLooper.dispatchAll();
     }
 
+    private void sendStartTetheringSoftApCallback(int state, TetheringRequest request,
+            String ifname) {
+        ArgumentCaptor<SoftApCallback> callbackCaptor =
+                ArgumentCaptor.forClass(SoftApCallback.class);
+        verify(mWifiManager, atLeastOnce()).startTetheredHotspot(any(TetheringRequest.class),
+                any(Executor.class), callbackCaptor.capture());
+        SoftApState softApState = mock(SoftApState.class);
+        when(softApState.getState()).thenReturn(state);
+        when(softApState.getTetheringRequest()).thenReturn(request);
+        when(softApState.getIface()).thenReturn(ifname);
+        callbackCaptor.getValue().onStateChanged(softApState);
+        mLooper.dispatchAll();
+    }
+
+    private void verifyWifiTetheringRequested() {
+        if (mTetheringDependencies.isTetheringWithSoftApConfigEnabled()) {
+            verify(mWifiManager).startTetheredHotspot(any(), any(), any());
+        } else {
+            verify(mWifiManager).startTetheredHotspot(null);
+        }
+        verify(mWifiManager, never()).stopSoftAp();
+        verifyNoMoreInteractions(mWifiManager);
+    }
+
+    private void sendSoftApEvent(int state, TetheringRequest request, String ifname) {
+        if (mTetheringDependencies.isTetheringWithSoftApConfigEnabled()) {
+            sendStartTetheringSoftApCallback(state, request, ifname);
+        } else {
+            sendWifiApStateChanged(state, ifname, IFACE_IP_MODE_TETHERED);
+        }
+    }
+
     private static final String[] P2P_RECEIVER_PERMISSIONS_FOR_BROADCAST = {
             android.Manifest.permission.ACCESS_FINE_LOCATION,
             android.Manifest.permission.ACCESS_WIFI_STATE
@@ -897,12 +973,18 @@
         verifyNoMoreInteractions(mCm);
     }
 
-    private void verifyInterfaceServingModeStarted(String ifname) throws Exception {
+    private void verifyInterfaceServingModeStarted(String ifname, boolean expectAgentEnabled)
+            throws Exception {
         verify(mNetd).interfaceSetCfg(any(InterfaceConfigurationParcel.class));
         verify(mNetd).tetherInterfaceAdd(ifname);
-        verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, ifname);
-        verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(ifname),
-                anyString(), anyString());
+        if (expectAgentEnabled) {
+            verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, ifname);
+            verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(ifname),
+                    anyString(), anyString());
+        }
     }
 
     private void verifyTetheringBroadcast(String ifname, String whichExtra) {
@@ -929,11 +1011,18 @@
         // it creates a IpServer and sends out a broadcast indicating that the
         // interface is "available".
         if (emulateInterfaceStatusChanged) {
-            // There is 1 IpServer state change event: STATE_AVAILABLE
-            verify(mNotificationUpdater, times(1)).onDownstreamChanged(DOWNSTREAM_NONE);
-            verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
-            verify(mWifiManager).updateInterfaceIpState(
-                    TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+            if (!SdkLevel.isAtLeastB()) {
+                // There is 1 IpServer state change event: STATE_AVAILABLE
+                verify(mNotificationUpdater, times(1)).onDownstreamChanged(DOWNSTREAM_NONE);
+                verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
+                verify(mWifiManager).updateInterfaceIpState(
+                        TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+            } else {
+                // Starting in B, ignore the interfaceStatusChanged
+                verify(mNotificationUpdater, never()).onDownstreamChanged(DOWNSTREAM_NONE);
+                verify(mWifiManager, never()).updateInterfaceIpState(
+                        TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+            }
         }
         verifyNoMoreInteractions(mNetd);
         verifyNoMoreInteractions(mWifiManager);
@@ -953,8 +1042,8 @@
         mTethering.startTethering(request, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
 
-        assertEquals(1, mTethering.getActiveTetheringRequests().size());
-        assertEquals(request, mTethering.getActiveTetheringRequests().get(TETHERING_USB));
+        assertEquals(1, mTethering.getPendingTetheringRequests().size());
+        assertTrue(mTethering.getPendingTetheringRequests().get(0).equals(request));
 
         if (mTethering.getTetheringConfiguration().isUsingNcm()) {
             verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_NCM);
@@ -997,10 +1086,18 @@
         failingLocalOnlyHotspotLegacyApBroadcast(false);
     }
 
-    private void verifyStopHotpot() throws Exception {
+    private boolean isTetheringNetworkAgentFeatureEnabled() {
+        return isAtLeastV() && mFeatureFlags.getOrDefault(TETHERING_LOCAL_NETWORK_AGENT, false);
+    }
+
+    private void verifyStopHotpot(boolean isLocalOnly) throws Exception {
         verify(mNetd).tetherApplyDnsInterfaces();
         verify(mNetd).tetherInterfaceRemove(TEST_WLAN_IFNAME);
-        verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
+        if (!isLocalOnly && isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
+        }
         // interfaceSetCfg() called once for enabling and twice disabling IPv4.
         verify(mNetd, times(3)).interfaceSetCfg(any(InterfaceConfigurationParcel.class));
         verify(mNetd).tetherStop();
@@ -1019,7 +1116,8 @@
     }
 
     private void verifyStartHotspot(boolean isLocalOnly) throws Exception {
-        verifyInterfaceServingModeStarted(TEST_WLAN_IFNAME);
+        final boolean expectAgentEnabled = !isLocalOnly && isTetheringNetworkAgentFeatureEnabled();
+        verifyInterfaceServingModeStarted(TEST_WLAN_IFNAME, expectAgentEnabled);
         verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
         verify(mWifiManager).updateInterfaceIpState(
                 TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
@@ -1032,7 +1130,7 @@
         verify(mWifiManager).updateInterfaceIpState(TEST_WLAN_IFNAME, expectedState);
         verifyNoMoreInteractions(mWifiManager);
 
-        verify(mUpstreamNetworkMonitor).startObserveAllNetworks();
+        verify(mUpstreamNetworkMonitor).startObserveUpstreamNetworks();
         if (isLocalOnly) {
             // There are 2 IpServer state change events: STATE_AVAILABLE -> STATE_LOCAL_ONLY.
             verify(mNotificationUpdater, times(2)).onDownstreamChanged(DOWNSTREAM_NONE);
@@ -1063,7 +1161,7 @@
         mTethering.interfaceRemoved(TEST_WLAN_IFNAME);
         mLooper.dispatchAll();
 
-        verifyStopHotpot();
+        verifyStopHotpot(true /* isLocalOnly */);
     }
 
     /**
@@ -1260,7 +1358,7 @@
         // Start USB tethering with no current upstream.
         prepareUsbTethering();
         sendUsbBroadcast(true, true, TETHER_USB_RNDIS_FUNCTION);
-        inOrder.verify(mUpstreamNetworkMonitor).startObserveAllNetworks();
+        inOrder.verify(mUpstreamNetworkMonitor).startObserveUpstreamNetworks();
         inOrder.verify(mUpstreamNetworkMonitor).setTryCell(true);
 
         // Pretend cellular connected and expect the upstream to be set.
@@ -1859,7 +1957,7 @@
         // Start USB tethering with no current upstream.
         prepareUsbTethering();
         sendUsbBroadcast(true, true, TETHER_USB_RNDIS_FUNCTION);
-        inOrder.verify(mUpstreamNetworkMonitor).startObserveAllNetworks();
+        inOrder.verify(mUpstreamNetworkMonitor).startObserveUpstreamNetworks();
         inOrder.verify(mUpstreamNetworkMonitor).setTryCell(true);
         ArgumentCaptor<NetworkCallback> captor = ArgumentCaptor.forClass(NetworkCallback.class);
         inOrder.verify(mCm).requestNetwork(any(), eq(0), eq(TYPE_MOBILE_DUN), any(),
@@ -1930,18 +2028,14 @@
         workingLocalOnlyHotspotEnrichedApBroadcast(false);
     }
 
-    // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void failingWifiTetheringLegacyApBroadcast() throws Exception {
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
 
         // Emulate externally-visible WifiManager effects, causing the
@@ -1949,12 +2043,20 @@
         // tethering mode is to be started.
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         sendWifiApStateChanged(WIFI_AP_STATE_ENABLED);
+        mLooper.dispatchAll();
 
-        // There is 1 IpServer state change event: STATE_AVAILABLE
-        verify(mNotificationUpdater, times(1)).onDownstreamChanged(DOWNSTREAM_NONE);
-        verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
-        verify(mWifiManager).updateInterfaceIpState(
-                TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+        if (!SdkLevel.isAtLeastB()) {
+            // There is 1 IpServer state change event: STATE_AVAILABLE from interfaceStatusChanged
+            verify(mNotificationUpdater, times(1)).onDownstreamChanged(DOWNSTREAM_NONE);
+            verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
+            verify(mWifiManager).updateInterfaceIpState(
+                    TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+        } else {
+            // Starting in B, ignore the interfaceStatusChanged
+            verify(mNotificationUpdater, never()).onDownstreamChanged(DOWNSTREAM_NONE);
+            verify(mWifiManager, never()).updateInterfaceIpState(
+                    TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+        }
         verifyNoMoreInteractions(mNetd);
         verifyNoMoreInteractions(mWifiManager);
     }
@@ -1962,15 +2064,15 @@
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void workingWifiTetheringEnrichedApBroadcast() throws Exception {
+        // B+ uses SoftApCallback instead of WIFI_AP_STATE_CHANGED for tethered hotspot.
+        mTetheringWithSoftApConfigEnabled = false;
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
         mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
                 null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
 
         // Emulate externally-visible WifiManager effects, causing the
@@ -2005,22 +2107,136 @@
         mTethering.interfaceRemoved(TEST_WLAN_IFNAME);
         mLooper.dispatchAll();
 
-        verifyStopHotpot();
+        verifyStopHotpot(false /* isLocalOnly */);
+    }
+
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationSuccess() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+        verifyNoMoreInteractions(mNetd);
+        verify(startResultListener, never()).onResult(anyInt());
+        // Emulate Wifi iface enabled
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+
+        verifyStartHotspot();
+        verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_ACTIVE_TETHER);
+        verify(startResultListener).onResult(TETHER_ERROR_NO_ERROR);
+    }
+
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationFailure() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+        verify(startResultListener, never()).onResult(anyInt());
+        // Emulate Wifi iface failure
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_FAILED, request, TEST_WLAN_IFNAME);
+
+        verify(startResultListener).onResult(TETHER_ERROR_INTERNAL_ERROR);
+    }
+
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationRestartAfterStarting() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                .build();
+        final TetheringInterface wifiIface = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME);
+        final TetheringInterface wifiIfaceWithConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, softApConfig);
+
+        // 1. Register one callback before running any tethering.
+        mTethering.registerTetheringEventCallback(callback);
+        mLooper.dispatchAll();
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig)
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+
+        // Wifi success
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+        verifyStartHotspot();
+        TetherStatesParcel tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.tetheredList, new TetheringInterface[] {wifiIfaceWithConfig});
+        verify(startResultListener).onResult(TETHER_ERROR_NO_ERROR);
+
+        // Restart Wifi
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, request, TEST_WLAN_IFNAME);
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+
+        // Verify we go from TETHERED -> AVAILABLE -> TETHERED with the same config.
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.tetheredList, new TetheringInterface[] {wifiIfaceWithConfig});
+    }
+
+    @Test
+    public void startWifiApBroadcastDoesNotStartIpServing() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Call startTethering for wifi
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+
+        // WIFI_AP_STATE_CHANGED broadcast should be ignored since we should only be using
+        // SoftApCallback for tethered AP.
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        verify(mNetd, never()).tetherStartWithConfiguration(any());
+        verify(mNotificationUpdater, never()).onDownstreamChanged(DOWNSTREAM_NONE);
+        verify(mWifiManager, never()).updateInterfaceIpState(
+                TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+        assertTrue(mTethering.getServingTetheringRequests().isEmpty());
     }
 
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void failureEnablingIpForwarding() throws Exception {
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
         doThrow(new RemoteException()).when(mNetd).ipfwdEnableForwarding(TETHERING_NAME);
 
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
         verify(mTetheringMetrics).createBuilder(eq(TETHERING_WIFI), anyString());
 
@@ -2028,7 +2244,7 @@
         // per-interface state machine to start up, and telling us that
         // tethering mode is to be started.
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         // We verify get/set called three times here: twice for setup and once during
         // teardown because all events happen over the course of the single
@@ -2036,9 +2252,14 @@
         // code is refactored the two calls during shutdown will revert to one.
         verify(mNetd, times(3)).interfaceSetCfg(argThat(p -> TEST_WLAN_IFNAME.equals(p.ifName)));
         verify(mNetd, times(1)).tetherInterfaceAdd(TEST_WLAN_IFNAME);
-        verify(mNetd, times(1)).networkAddInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
-        verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_WLAN_IFNAME),
-                anyString(), anyString());
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            verify(mNetd, times(1)).networkAddInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
+            verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_WLAN_IFNAME),
+                    anyString(), anyString());
+        }
         verify(mWifiManager).updateInterfaceIpState(
                 TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
         verify(mWifiManager).updateInterfaceIpState(
@@ -2057,14 +2278,18 @@
         // so it can take down AP mode.
         verify(mNetd, times(1)).tetherApplyDnsInterfaces();
         verify(mNetd, times(1)).tetherInterfaceRemove(TEST_WLAN_IFNAME);
-        verify(mNetd, times(1)).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            verify(mNetd, times(1)).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_WLAN_IFNAME);
+        }
         verify(mWifiManager).updateInterfaceIpState(
                 TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_CONFIGURATION_ERROR);
 
         verify(mTetheringMetrics, times(0)).maybeUpdateUpstreamType(any());
-        verify(mTetheringMetrics, times(2)).updateErrorCode(eq(TETHERING_WIFI),
+        verify(mTetheringMetrics, times(1)).updateErrorCode(eq(TETHERING_WIFI),
                 eq(TETHER_ERROR_INTERNAL_ERROR));
-        verify(mTetheringMetrics, times(2)).sendReport(eq(TETHERING_WIFI));
+        verify(mTetheringMetrics, times(1)).sendReport(eq(TETHERING_WIFI));
 
         verifyNoMoreInteractions(mWifiManager);
         verifyNoMoreInteractions(mNetd);
@@ -2094,7 +2319,7 @@
 
         verify(mNotificationUpdater, times(expectedInteractionsWithShowNotification))
                 .notifyTetheringDisabledByRestriction();
-        verify(mockTethering, times(expectedInteractionsWithShowNotification)).untetherAll();
+        verify(mockTethering, times(expectedInteractionsWithShowNotification)).stopAllTethering();
     }
 
     @Test
@@ -2166,7 +2391,7 @@
         runUsbTethering(upstreamState);
         assertContains(Arrays.asList(mTethering.getTetheredIfaces()), TEST_RNDIS_IFNAME);
         assertTrue(mTethering.isTetheringActive());
-        assertEquals(0, mTethering.getActiveTetheringRequests().size());
+        assertEquals(0, mTethering.getPendingTetheringRequests().size());
 
         final Tethering.UserRestrictionActionListener ural = makeUserRestrictionActionListener(
                 mTethering, false /* currentDisallow */, true /* nextDisallow */);
@@ -2356,15 +2581,20 @@
         // 2. Enable wifi tethering.
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG,
+                null);
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
+        if (SdkLevel.isAtLeastB()) {
+            // Starting in B, ignore the interfaceStatusChanged
+            callback.assertNoStateChangeCallback();
+        }
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+        mLooper.dispatchAll();
         tetherState = callback.pollTetherStatesChanged();
         assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
-
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
         tetherState = callback.pollTetherStatesChanged();
         assertArrayEquals(tetherState.tetheredList, new TetheringInterface[] {wifiIface});
         callback.expectUpstreamChanged(upstreamState.network);
@@ -2389,8 +2619,7 @@
         if (isAtLeastT()) {
             // After T, tethering doesn't support WIFI_AP_STATE_DISABLED with null interface name.
             callback2.assertNoStateChangeCallback();
-            sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
-                    IFACE_IP_MODE_TETHERED);
+            sendSoftApEvent(WIFI_AP_STATE_DISABLED, request, TEST_WLAN_IFNAME);
         }
         tetherState = callback2.pollTetherStatesChanged();
         assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
@@ -2402,7 +2631,7 @@
 
     @Test
     public void testSoftApConfigInTetheringEventCallback() throws Exception {
-        assumeTrue(SdkLevel.isAtLeastV());
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
         when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
                 .thenReturn(PERMISSION_DENIED);
         when(mContext.checkCallingOrSelfPermission(NETWORK_STACK))
@@ -2454,20 +2683,24 @@
         callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+
+        // Enable wifi tethering
+        mBinderCallingUid = TEST_CALLER_UID;
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
+        // Netd "up" event should not trigger a state change callback in B+.
+        callback.assertNoStateChangeCallback();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+        // Verify we see  Available -> Tethered states
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 callback.pollTetherStatesChanged().availableList);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 differentCallback.pollTetherStatesChanged().availableList);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 settingsCallback.pollTetherStatesChanged().availableList);
-
-        // Enable wifi tethering
-        mBinderCallingUid = TEST_CALLER_UID;
-        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
                 callback.pollTetherStatesChanged().tetheredList);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
@@ -2478,20 +2711,15 @@
         callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STARTED);
 
         // Disable wifi tethering
-        mLooper.dispatchAll();
         mTethering.stopTethering(TETHERING_WIFI);
-        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED);
-        if (isAtLeastT()) {
-            // After T, tethering doesn't support WIFI_AP_STATE_DISABLED with null interface name.
-            callback.assertNoStateChangeCallback();
-            sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
-                    IFACE_IP_MODE_TETHERED);
-        }
-        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 callback.pollTetherStatesChanged().availableList);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 differentCallback.pollTetherStatesChanged().availableList);
-        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 settingsCallback.pollTetherStatesChanged().availableList);
         mLooper.dispatchAll();
         callback.expectUpstreamChanged(NULL_NETWORK);
@@ -2500,6 +2728,340 @@
     }
 
     @Test
+    public void testFuzzyMatchedWifiCannotBeAdded() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringInterface wifiIfaceWithoutConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, null);
+        final TetheringInterface wifiIfaceWithConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, softApConfig);
+
+        // Register callback before running any tethering.
+        mTethering.registerTetheringEventCallback(callback);
+        mLooper.dispatchAll();
+        callback.expectTetheredClientChanged(Collections.emptyList());
+        callback.expectUpstreamChanged(NULL_NETWORK);
+        callback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Start wifi tethering but don't trigger the link layer event yet.
+        mBinderCallingUid = TEST_CALLER_UID;
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        successListener.assertDoesNotHaveResult();
+
+        // Try starting wifi tethering with various fuzzy-matching requests and verify we get
+        // TETHER_ERROR_DUPLICATE_REQUEST.
+
+        // Different static IP addresses
+        final TetheringRequest differentIpAddr = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig)
+                .setStaticIpv4Addresses(new LinkAddress("192.168.0.123/24"),
+                        new LinkAddress("192.168.0.42/24"))
+                .build();
+        differentIpAddr.setUid(TEST_CALLER_UID);
+        ResultListener differentIpAddrListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentIpAddr, TEST_CALLER_PKG, differentIpAddrListener);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
+        differentIpAddrListener.assertHasResult();
+
+        // Different UID
+        final TetheringRequest differentUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        differentUid.setUid(TEST_CALLER_UID + 1);
+        ResultListener differentUidListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentUid, TEST_CALLER_PKG, differentUidListener);
+        mLooper.dispatchAll();
+        differentUidListener.assertHasResult();
+        verifyWifiTetheringRequested();
+
+        // Mock the link layer event to start IP serving and verify
+        // 1) The original request's result listener is called.
+        // 2) We still get TETHER_ERROR_DUPLICATE_REQUEST for new requests.
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                callback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                callback.pollTetherStatesChanged().tetheredList);
+        callback.expectUpstreamChanged(upstreamState.network);
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STARTED);
+        differentIpAddrListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        differentUidListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentIpAddr, TEST_CALLER_PKG, differentIpAddrListener);
+        mTethering.startTethering(differentUid, TEST_CALLER_PKG, differentUidListener);
+        mLooper.dispatchAll();
+        differentIpAddrListener.assertHasResult();
+        differentUidListener.assertHasResult();
+        verify(mWifiManager, times(1)).startTetheredHotspot(any(), any(), any());
+        verify(mWifiManager, never()).stopSoftAp();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerStopped() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Trigger Netd callback to stop the IpServer
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, false);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerUnwanted() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Trigger wifi ap state change to tell IpServer it's unwanted.
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerError() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Set up the DHCP server to fail creation.
+        mIpServerDependencies.setOnDhcpServerCreatedResult(STATUS_UNKNOWN_ERROR);
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // We should be able to request the same Wifi again since the DHCP server transitioned the
+        // IpServer back to InitialState.
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterStoppingPendingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering but keep the request pending by not sending the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        ArgumentCaptor<SoftApCallback> callbackCaptor =
+                ArgumentCaptor.forClass(SoftApCallback.class);
+        verify(mWifiManager, atLeastOnce()).startTetheredHotspot(any(TetheringRequest.class),
+                any(Executor.class), callbackCaptor.capture());
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Stop Wifi tethering.
+        mTethering.stopTethering(TETHERING_WIFI);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+
+        // Mock the first request going up and then down from the stop request.
+        SoftApState softApState = mock(SoftApState.class);
+        when(softApState.getState()).thenReturn(WIFI_AP_STATE_ENABLED);
+        when(softApState.getTetheringRequest()).thenReturn(tetheringRequest);
+        when(softApState.getIface()).thenReturn(TEST_WLAN_IFNAME);
+        callbackCaptor.getValue().onStateChanged(softApState);
+        mLooper.dispatchAll();
+        successListener.assertHasResult();
+
+        // Mock the second request going up
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterStoppingServingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Stop Wifi tethering.
+        mTethering.stopTethering(TETHERING_WIFI);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testStopTetheringWithMatchingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS)).thenReturn(PERMISSION_DENIED);
+        initTetheringOnTestThread();
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Enable wifi tethering.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig")
+                .build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+
+        // Stop tethering with non-matching config. Should fail with TETHER_ERROR_UNKNOWN_REQUEST.
+        SoftApConfiguration softApConfig2 = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig2")
+                .build();
+        IIntResultListener differentConfigListener = mock(IIntResultListener.class);
+        mTethering.stopTetheringRequest(new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig2).build(), differentConfigListener);
+        mLooper.dispatchAll();
+        verify(differentConfigListener).onResult(eq(TETHER_ERROR_UNKNOWN_REQUEST));
+        verify(mWifiManager, never()).stopSoftAp();
+
+        // Stop tethering with non-matching UID. Should fail with TETHER_ERROR_UNKNOWN_REQUEST.
+        final TetheringRequest nonMatchingUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        IIntResultListener nonMatchingUidListener = mock(IIntResultListener.class);
+        nonMatchingUid.setUid(TEST_CALLER_UID_2);
+        mTethering.stopTetheringRequest(nonMatchingUid, nonMatchingUidListener);
+        mLooper.dispatchAll();
+        verify(nonMatchingUidListener).onResult(eq(TETHER_ERROR_UNKNOWN_REQUEST));
+        verify(mWifiManager, never()).stopSoftAp();
+
+        // Stop tethering with matching request. Should succeed now.
+        IIntResultListener matchingListener = mock(IIntResultListener.class);
+        mTethering.stopTetheringRequest(tetheringRequest, matchingListener);
+        mLooper.dispatchAll();
+        verify(matchingListener).onResult(eq(TETHER_ERROR_NO_ERROR));
+        verify(mWifiManager).stopSoftAp();
+    }
+
+    @Test
+    public void testStopTetheringWithSettingsPermission() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Enable wifi tethering.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig")
+                .build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+
+        // Stop tethering with non-matching UID and Settings permission. Should succeed.
+        final TetheringRequest nonMatchingUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        IIntResultListener nonMatchingUidListener = mock(IIntResultListener.class);
+        nonMatchingUid.setUid(TEST_CALLER_UID_2);
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_GRANTED);
+        mTethering.stopTetheringRequest(nonMatchingUid, nonMatchingUidListener);
+        mLooper.dispatchAll();
+        verify(nonMatchingUidListener).onResult(eq(TETHER_ERROR_NO_ERROR));
+        verify(mWifiManager).stopSoftAp();
+    }
+
+    @Test
     public void testReportFailCallbackIfOffloadNotSupported() throws Exception {
         initTetheringOnTestThread();
         final UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
@@ -2581,13 +3143,13 @@
         }
         sendWifiP2pConnectionChanged(true, true, TEST_P2P_IFNAME);
 
-        verifyInterfaceServingModeStarted(TEST_P2P_IFNAME);
+        verifyInterfaceServingModeStarted(TEST_P2P_IFNAME, false);
         verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_AVAILABLE_TETHER);
         verify(mNetd, times(1)).ipfwdEnableForwarding(TETHERING_NAME);
         verify(mNetd, times(1)).tetherStartWithConfiguration(any());
         verifyNoMoreInteractions(mNetd);
         verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_ACTIVE_LOCAL_ONLY);
-        verify(mUpstreamNetworkMonitor, times(1)).startObserveAllNetworks();
+        verify(mUpstreamNetworkMonitor, times(1)).startObserveUpstreamNetworks();
         // There are 2 IpServer state change events: STATE_AVAILABLE -> STATE_LOCAL_ONLY
         verify(mNotificationUpdater, times(2)).onDownstreamChanged(DOWNSTREAM_NONE);
 
@@ -2758,10 +3320,15 @@
         public void assertHasResult() {
             if (!mHasResult) fail("No callback result");
         }
+
+        public void assertDoesNotHaveResult() {
+            if (mHasResult) fail("Has callback result");
+        }
     }
 
     @Test
-    public void testMultipleStartTethering() throws Exception {
+    public void testMultipleStartTetheringLegacy() throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
         initTetheringOnTestThread();
         final LinkAddress serverLinkAddr = new LinkAddress("192.168.20.1/24");
         final LinkAddress clientLinkAddr = new LinkAddress("192.168.20.42/24");
@@ -2786,6 +3353,17 @@
         verify(mUsbManager, never()).setCurrentFunctions(UsbManager.FUNCTION_NONE);
         reset(mUsbManager);
 
+        // Enable USB tethering again with the same request but different uid/package and expect no
+        // change to USB.
+        TetheringRequest differentUidPackage = createTetheringRequest(TETHERING_USB);
+        differentUidPackage.setUid(TEST_CALLER_UID_2);
+        differentUidPackage.setPackageName(TEST_CALLER_PKG_2);
+        mTethering.startTethering(differentUidPackage, TEST_CALLER_PKG_2, secondResult);
+        mLooper.dispatchAll();
+        secondResult.assertHasResult();
+        verify(mUsbManager, never()).setCurrentFunctions(UsbManager.FUNCTION_NONE);
+        reset(mUsbManager);
+
         // Enable USB tethering with a different request and expect that USB is stopped and
         // started.
         mTethering.startTethering(createTetheringRequest(TETHERING_USB,
@@ -2836,6 +3414,43 @@
     }
 
     @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRequestStaticIpLegacyTether() throws Exception {
+        initTetheringOnTestThread();
+
+        // Call startTethering with static ip
+        final LinkAddress serverLinkAddr = new LinkAddress("192.168.0.123/24");
+        final LinkAddress clientLinkAddr = new LinkAddress("192.168.0.42/24");
+        final String serverAddr = "192.168.0.123";
+        final int clientAddrParceled = 0xc0a8002a;
+        final ArgumentCaptor<DhcpServingParamsParcel> dhcpParamsCaptor =
+                ArgumentCaptor.forClass(DhcpServingParamsParcel.class);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI,
+                        serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL, null),
+                TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
+
+        // Call legacyTether on the interface before the link layer event comes back.
+        // This happens, for example, in pre-T bluetooth tethering: Settings calls startTethering,
+        // and then the bluetooth code calls the tether() API.
+        final ResultListener tetherResult = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.legacyTether(TEST_WLAN_IFNAME, tetherResult);
+        mLooper.dispatchAll();
+        tetherResult.assertHasResult();
+
+        // Verify that the static ip set in startTethering is used
+        verify(mNetd).interfaceSetCfg(argThat(cfg -> serverAddr.equals(cfg.ipv4Addr)));
+        verify(mIpServerDependencies, times(1)).makeDhcpServer(any(), dhcpParamsCaptor.capture(),
+                any());
+        final DhcpServingParamsParcel params = dhcpParamsCaptor.getValue();
+        assertEquals(serverAddr, intToInet4AddressHTH(params.serverAddr).getHostAddress());
+        assertEquals(24, params.serverAddrPrefixLength);
+        assertEquals(clientAddrParceled, params.singleClientAddr);
+    }
+
+    @Test
     public void testUpstreamNetworkChanged() throws Exception {
         initTetheringOnTestThread();
         final InOrder inOrder = inOrder(mNotificationUpdater);
@@ -2973,26 +3588,38 @@
         mLooper.dispatchAll();
         verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
         reset(mEntitleMgr);
+    }
 
-        // If one app enables tethering without provisioning check first, then another app enables
-        // tethering of the same type but does not disable the provisioning check.
+    @Test
+    public void testNonExemptRequestAddedAfterExemptRequestOfSameType() throws Exception {
+        // Note: When fuzzy-matching is enabled, it is not possible yet to have two concurrent
+        // requests of the same type that are subject to carrier entitlement due to fuzzy-matching.
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
         setupForRequiredProvisioning();
+        final TetheringRequest wifiExemptRequest =
+                createTetheringRequest(TETHERING_WIFI, null, null, true,
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
         verify(mEntitleMgr, never()).startProvisioningIfNeeded(TETHERING_WIFI, false);
         verify(mEntitleMgr).setExemptedDownstreamType(TETHERING_WIFI);
         assertTrue(mEntitleMgr.isCellularUpstreamPermitted());
         reset(mEntitleMgr);
+
         setupForRequiredProvisioning();
+        final TetheringRequest wifiNotExemptRequest =
+                createTetheringRequest(TETHERING_WIFI, null, null, false,
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiNotExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
+        verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
         verify(mEntitleMgr).startProvisioningIfNeeded(TETHERING_WIFI, false);
         verify(mEntitleMgr, never()).setExemptedDownstreamType(TETHERING_WIFI);
         assertFalse(mEntitleMgr.isCellularUpstreamPermitted());
         mTethering.stopTethering(TETHERING_WIFI);
         mLooper.dispatchAll();
-        verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
-        reset(mEntitleMgr);
+        verify(mEntitleMgr, times(2)).stopProvisioningIfNeeded(TETHERING_WIFI);
     }
 
     private void setupForRequiredProvisioning() {
@@ -3174,8 +3801,11 @@
         reset(mDhcpServer);
 
         // Run wifi tethering.
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS)).startWithCallbacks(
                 any(), dhcpEventCbsCaptor.capture());
         eventCallbacks = dhcpEventCbsCaptor.getValue();
@@ -3238,8 +3868,12 @@
         });
         callback.expectTetheredClientChanged(Collections.emptyList());
 
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
         final ArgumentCaptor<IDhcpEventCallbacks> dhcpEventCbsCaptor =
                  ArgumentCaptor.forClass(IDhcpEventCallbacks.class);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS)).startWithCallbacks(
@@ -3355,10 +3989,9 @@
     }
 
     @Test
+    @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testBluetoothTethering() throws Exception {
         initTetheringOnTestThread();
-        // Switch to @IgnoreUpTo(Build.VERSION_CODES.S_V2) when it is available for AOSP.
-        assumeTrue(isAtLeastT());
 
         final ResultListener result = new ResultListener(TETHER_ERROR_NO_ERROR);
         mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
@@ -3392,10 +4025,9 @@
     }
 
     @Test
+    @IgnoreAfter(Build.VERSION_CODES.S_V2)
     public void testBluetoothTetheringBeforeT() throws Exception {
         initTetheringOnTestThread();
-        // Switch to @IgnoreAfter(Build.VERSION_CODES.S_V2) when it is available for AOSP.
-        assumeFalse(isAtLeastT());
 
         final ResultListener result = new ResultListener(TETHER_ERROR_NO_ERROR);
         mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
@@ -3411,7 +4043,7 @@
         mTethering.interfaceStatusChanged(TEST_BT_IFNAME, false);
         mTethering.interfaceStatusChanged(TEST_BT_IFNAME, true);
         final ResultListener tetherResult = new ResultListener(TETHER_ERROR_NO_ERROR);
-        mTethering.tether(TEST_BT_IFNAME, IpServer.STATE_TETHERED, tetherResult);
+        mTethering.legacyTether(TEST_BT_IFNAME, tetherResult);
         mLooper.dispatchAll();
         tetherResult.assertHasResult();
 
@@ -3431,7 +4063,7 @@
         mTethering.stopTethering(TETHERING_BLUETOOTH);
         mLooper.dispatchAll();
         final ResultListener untetherResult = new ResultListener(TETHER_ERROR_NO_ERROR);
-        mTethering.untether(TEST_BT_IFNAME, untetherResult);
+        mTethering.legacyUntether(TEST_BT_IFNAME, untetherResult);
         mLooper.dispatchAll();
         untetherResult.assertHasResult();
         verifySetBluetoothTethering(false /* enable */, false /* bindToPanService */);
@@ -3461,7 +4093,7 @@
             mTethering.interfaceStatusChanged(TEST_BT_IFNAME, false);
             mTethering.interfaceStatusChanged(TEST_BT_IFNAME, true);
             final ResultListener tetherResult = new ResultListener(TETHER_ERROR_NO_ERROR);
-            mTethering.tether(TEST_BT_IFNAME, IpServer.STATE_TETHERED, tetherResult);
+            mTethering.legacyTether(TEST_BT_IFNAME, tetherResult);
             mLooper.dispatchAll();
             tetherResult.assertHasResult();
         }
@@ -3475,6 +4107,50 @@
         verifyNetdCommandForBtTearDown();
     }
 
+    @Test
+    public void testPendingPanEnableRequestFailedUponDisableRequest() throws Exception {
+        initTetheringOnTestThread();
+
+        mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
+        final ResultListener failedEnable = new ResultListener(TETHER_ERROR_SERVICE_UNAVAIL);
+        mTethering.startTethering(createTetheringRequest(TETHERING_BLUETOOTH),
+                TEST_CALLER_PKG, failedEnable);
+        mLooper.dispatchAll();
+        failedEnable.assertDoesNotHaveResult();
+
+        // Stop tethering before the pan service connects. This should fail the enable request.
+        mTethering.stopTethering(TETHERING_BLUETOOTH);
+        mLooper.dispatchAll();
+        failedEnable.assertHasResult();
+    }
+
+    @Test
+    public void testStartBluetoothTetheringFailsWhenTheresAnExistingRequestWaitingForPanService()
+            throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
+
+        mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
+        final ResultListener firstResult = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(createTetheringRequest(TETHERING_BLUETOOTH),
+                TEST_CALLER_PKG, firstResult);
+        mLooper.dispatchAll();
+        firstResult.assertDoesNotHaveResult();
+
+        // Second request should fail.
+        final ResultListener secondResult = new ResultListener(TETHER_ERROR_SERVICE_UNAVAIL);
+        mTethering.startTethering(createTetheringRequest(TETHERING_BLUETOOTH),
+                TEST_CALLER_PKG, secondResult);
+        mLooper.dispatchAll();
+        secondResult.assertHasResult();
+        firstResult.assertDoesNotHaveResult();
+
+        // Bind to PAN service should succeed for first listener only. If the second result is
+        // called with TETHER_ERROR_NO_ERROR, ResultListener will fail an assertion.
+        verifySetBluetoothTethering(true /* enable */, true /* bindToPanService */);
+        firstResult.assertHasResult();
+    }
+
     private void mockBluetoothSettings(boolean bluetoothOn, boolean tetheringOn) {
         when(mBluetoothAdapter.isEnabled()).thenReturn(bluetoothOn);
         when(mBluetoothPan.isTetheringOn()).thenReturn(tetheringOn);
@@ -3486,13 +4162,22 @@
                     && assertContainsFlag(cfg.flags, INetd.IF_STATE_UP)));
         }
         verify(mNetd).tetherInterfaceAdd(TEST_BT_IFNAME);
-        verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, TEST_BT_IFNAME);
-        verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_BT_IFNAME),
-                anyString(), anyString());
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkAddInterface(anyInt(), anyString());
+            verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, TEST_BT_IFNAME);
+            verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_BT_IFNAME),
+                    anyString(), anyString());
+        }
         verify(mNetd).ipfwdEnableForwarding(TETHERING_NAME);
         verify(mNetd).tetherStartWithConfiguration(any());
-        verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_BT_IFNAME),
-                anyString(), anyString());
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), anyString(), anyString());
+        } else {
+            verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(TEST_BT_IFNAME),
+                    anyString(), anyString());
+        }
         verifyNoMoreInteractions(mNetd);
         reset(mNetd);
     }
@@ -3507,7 +4192,11 @@
     private void verifyNetdCommandForBtTearDown() throws Exception {
         verify(mNetd).tetherApplyDnsInterfaces();
         verify(mNetd).tetherInterfaceRemove(TEST_BT_IFNAME);
-        verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_BT_IFNAME);
+        if (isTetheringNetworkAgentFeatureEnabled()) {
+            verify(mNetd, never()).networkRemoveInterface(anyInt(), anyString());
+        } else {
+            verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, TEST_BT_IFNAME);
+        }
         // One is ipv4 address clear (set to 0.0.0.0), another is set interface down which only
         // happen after T. Before T, the interface configuration control in bluetooth side.
         verify(mNetd, times(isAtLeastT() ? 2 : 1)).interfaceSetCfg(
@@ -3522,7 +4211,7 @@
     private ServiceListener verifySetBluetoothTethering(final boolean enable,
             final boolean bindToPanService) throws Exception {
         ServiceListener listener = null;
-        verify(mBluetoothAdapter).isEnabled();
+        verify(mBluetoothAdapter, atLeastOnce()).isEnabled();
         if (bindToPanService) {
             final ArgumentCaptor<ServiceListener> listenerCaptor =
                     ArgumentCaptor.forClass(ServiceListener.class);
@@ -3728,8 +4417,11 @@
     @Test
     public void testIpv4AddressForSapAndLohsConcurrency() throws Exception {
         initTetheringOnTestThread();
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         ArgumentCaptor<InterfaceConfigurationParcel> ifaceConfigCaptor =
                 ArgumentCaptor.forClass(InterfaceConfigurationParcel.class);
@@ -3750,28 +4442,41 @@
     }
 
     @Test
+    public void testFailStartTetheredHotspotWithoutRequest() throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(false);
+
+        ResultListener result = new ResultListener(TETHER_ERROR_INTERNAL_ERROR);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG, result);
+        mLooper.dispatchAll();
+        verify(mWifiManager).startTetheredHotspot(null);
+        verifyNoMoreInteractions(mWifiManager);
+        result.assertHasResult();
+        assertTrue(mTethering.getPendingTetheringRequests().isEmpty());
+    }
+
+    @Test
     public void testWifiTetheringWhenP2pActive() throws Exception {
         initTetheringOnTestThread();
         // Enable wifi P2P.
         sendWifiP2pConnectionChanged(true, true, TEST_P2P_IFNAME);
-        verifyInterfaceServingModeStarted(TEST_P2P_IFNAME);
+        verifyInterfaceServingModeStarted(TEST_P2P_IFNAME, false);
         verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_AVAILABLE_TETHER);
         verifyTetheringBroadcast(TEST_P2P_IFNAME, EXTRA_ACTIVE_LOCAL_ONLY);
-        verify(mUpstreamNetworkMonitor).startObserveAllNetworks();
+        verify(mUpstreamNetworkMonitor).startObserveUpstreamNetworks();
         // Verify never enable upstream if only P2P active.
         verify(mUpstreamNetworkMonitor, never()).setTryCell(true);
         assertEquals(TETHER_ERROR_NO_ERROR, mTethering.getLastErrorForTest(TEST_P2P_IFNAME));
 
-        when(mWifiManager.startTetheredHotspot(any())).thenReturn(true);
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
 
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
         verify(mWifiManager).updateInterfaceIpState(
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/UpstreamNetworkMonitorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/UpstreamNetworkMonitorTest.java
index 90fd709..f192492 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/UpstreamNetworkMonitorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/UpstreamNetworkMonitorTest.java
@@ -36,7 +36,6 @@
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.inOrder;
-import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -141,7 +140,7 @@
         assertTrue(mCM.hasNoCallbacks());
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
 
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         assertEquals(1, mCM.mTrackingDefault.size());
 
         mUNM.stop();
@@ -149,13 +148,13 @@
     }
 
     @Test
-    public void testListensForAllNetworks() throws Exception {
+    public void testListensForUpstreamNetworks() throws Exception {
         assertTrue(mCM.mListening.isEmpty());
 
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         assertFalse(mCM.mListening.isEmpty());
-        assertTrue(mCM.isListeningForAll());
+        assertTrue(mCM.isListeningForUpstream());
 
         mUNM.stop();
         assertTrue(mCM.onlyHasDefaultCallbacks());
@@ -179,7 +178,7 @@
             assertTrue(TestConnectivityManager.looksLikeDefaultRequest(requestCaptor.getValue()));
         }
 
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         verify(mCM, times(1)).registerNetworkCallback(
                 any(NetworkRequest.class), any(NetworkCallback.class), any(Handler.class));
 
@@ -192,7 +191,7 @@
         assertFalse(mUNM.mobileNetworkRequested());
         assertEquals(0, mCM.mRequested.size());
 
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         assertFalse(mUNM.mobileNetworkRequested());
         assertEquals(0, mCM.mRequested.size());
 
@@ -215,7 +214,7 @@
         assertFalse(mUNM.mobileNetworkRequested());
         assertEquals(0, mCM.mRequested.size());
 
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         verify(mCM, times(1)).registerNetworkCallback(
                 any(NetworkRequest.class), any(NetworkCallback.class), any(Handler.class));
         assertFalse(mUNM.mobileNetworkRequested());
@@ -251,7 +250,7 @@
         assertFalse(mUNM.mobileNetworkRequested());
         assertEquals(0, mCM.mRequested.size());
 
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         assertFalse(mUNM.mobileNetworkRequested());
         assertEquals(0, mCM.mRequested.size());
 
@@ -271,7 +270,7 @@
 
     @Test
     public void testUpdateMobileRequiresDun() throws Exception {
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
 
         // Test going from no-DUN to DUN correctly re-registers callbacks.
         mUNM.setUpstreamConfig(false /* autoUpstream */, false /* dunRequired */);
@@ -301,7 +300,7 @@
         preferredTypes.add(TYPE_WIFI);
 
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         // There are no networks, so there is nothing to select.
         assertSatisfiesLegacyType(TYPE_NONE, mUNM.selectPreferredUpstreamType(preferredTypes));
 
@@ -374,7 +373,7 @@
     @Test
     public void testGetCurrentPreferredUpstream() throws Exception {
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         mUNM.setUpstreamConfig(true /* autoUpstream */, false /* dunRequired */);
         mUNM.setTryCell(true);
 
@@ -446,7 +445,7 @@
     @Test
     public void testLocalPrefixes() throws Exception {
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
 
         // [0] Test minimum set of local prefixes.
         Set<IpPrefix> local = mUNM.getLocalPrefixes();
@@ -558,7 +557,7 @@
         preferredTypes.add(TYPE_MOBILE_HIPRI);
         preferredTypes.add(TYPE_WIFI);
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         // Setup wifi and make wifi as default network.
         final TestNetworkAgent wifiAgent = new TestNetworkAgent(mCM, WIFI_CAPABILITIES);
         wifiAgent.fakeConnect();
@@ -579,7 +578,7 @@
         final String ipv6Addr1 = "2001:db8:4:fd00:827a:bfff:fe6f:374d/64";
         final String ipv6Addr2 = "2003:aa8:3::123/64";
         mUNM.startTrackDefaultNetwork(mEntitleMgr);
-        mUNM.startObserveAllNetworks();
+        mUNM.startObserveUpstreamNetworks();
         mUNM.setUpstreamConfig(true /* autoUpstream */, false /* dunRequired */);
         mUNM.setTryCell(true);
 
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/util/TetheringPermissionsUtilsTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/TetheringPermissionsUtilsTest.java
new file mode 100644
index 0000000..2b70e39
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/TetheringPermissionsUtilsTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.networkstack.tethering.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.app.admin.DevicePolicyManager;
+import android.content.Context;
+import android.os.UserHandle;
+import android.telephony.TelephonyManager;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class TetheringPermissionsUtilsTest {
+    private static final int TEST_UID = 12345;
+    private static final String TEST_PACKAGE = "test.package";
+
+    @Mock Context mContext;
+    @Mock Context mUserContext;
+    @Mock DevicePolicyManager mDevicePolicyManager;
+    @Mock TelephonyManager mTelephonyManager;
+
+    TetheringPermissionsUtils mTetheringPermissionsUtils;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        when(mContext.createContextAsUser(UserHandle.getUserHandleForUid(TEST_UID), 0))
+                .thenReturn(mUserContext);
+        when(mUserContext.getSystemService(DevicePolicyManager.class))
+                .thenReturn(mDevicePolicyManager);
+        when(mContext.getSystemService(TelephonyManager.class)).thenReturn(mTelephonyManager);
+        mTetheringPermissionsUtils = new TetheringPermissionsUtils(mContext);
+    }
+
+    @Test
+    public void testIsDeviceOwner() {
+        when(mDevicePolicyManager.isDeviceOwnerApp(TEST_PACKAGE)).thenReturn(false);
+        assertThat(mTetheringPermissionsUtils.isDeviceOwner(TEST_UID, TEST_PACKAGE)).isFalse();
+
+        when(mDevicePolicyManager.isDeviceOwnerApp(TEST_PACKAGE)).thenReturn(true);
+        assertThat(mTetheringPermissionsUtils.isDeviceOwner(TEST_UID, TEST_PACKAGE)).isTrue();
+    }
+
+    @Test
+    public void testHasCarrierPrivilege() {
+        when(mTelephonyManager.checkCarrierPrivilegesForPackageAnyPhone(TEST_PACKAGE))
+                .thenReturn(TelephonyManager.CARRIER_PRIVILEGE_STATUS_NO_ACCESS);
+        assertThat(mTetheringPermissionsUtils.isCarrierPrivileged(TEST_PACKAGE)).isFalse();
+
+        when(mTelephonyManager.checkCarrierPrivilegesForPackageAnyPhone(TEST_PACKAGE))
+                .thenReturn(TelephonyManager.CARRIER_PRIVILEGE_STATUS_HAS_ACCESS);
+        assertThat(mTetheringPermissionsUtils.isCarrierPrivileged(TEST_PACKAGE)).isTrue();
+    }
+}
diff --git a/bpf/dns_helper/DnsBpfHelper.cpp b/bpf/dns_helper/DnsBpfHelper.cpp
index 0719ade..cf2fa2b 100644
--- a/bpf/dns_helper/DnsBpfHelper.cpp
+++ b/bpf/dns_helper/DnsBpfHelper.cpp
@@ -32,12 +32,44 @@
     }                                                                                              \
   } while (0)
 
+// copied from BpfHandler.cpp
+static bool mainlineNetBpfLoadDone() {
+  return !access("/sys/fs/bpf/netd_shared/mainline_done", F_OK);
+}
+
+// copied from BpfHandler.cpp
+static inline void waitForNetProgsLoaded() {
+  // infinite loop until success with 5/10/20/40/60/60/60... delay
+  for (int delay = 5;; delay *= 2) {
+    if (delay > 60) delay = 60;
+    if (base::WaitForProperty("init.svc.mdnsd_netbpfload", "stopped", std::chrono::seconds(delay))
+      && mainlineNetBpfLoadDone()) return;
+    LOG(WARNING) << "Waited " << delay << "s for init.svc.mdnsd_netbpfload=stopped, still waiting.";
+  }
+}
+
 base::Result<void> DnsBpfHelper::init() {
-  if (!android::modules::sdklevel::IsAtLeastT()) {
-    LOG(ERROR) << __func__ << ": Unsupported before Android T.";
+  if (!android::modules::sdklevel::IsAtLeastS()) {
+    LOG(ERROR) << __func__ << ": Unsupported before Android S.";
     return base::Error(EOPNOTSUPP);
   }
 
+  if (!android::modules::sdklevel::IsAtLeastT()) {
+    LOG(INFO) << "performing Android S mainline NetBpfload magic!";
+    if (!mainlineNetBpfLoadDone()) {
+      // We're on S/Sv2 & it's the first time netd is starting up (unless crashlooping)
+      if (!base::SetProperty("ctl.start", "mdnsd_netbpfload")) {
+        LOG(ERROR) << "Failed to set property ctl.start=mdnsd_netbpfload, see dmesg for reason.";
+        return base::Error(ENOEXEC);
+      }
+
+      LOG(INFO) << "Waiting for Networking BPF programs";
+      waitForNetProgsLoaded();
+      LOG(INFO) << "Networking BPF programs are loaded";
+    }
+    return {};
+  }
+
   RETURN_IF_RESULT_NOT_OK(mConfigurationMap.init(CONFIGURATION_MAP_PATH));
   RETURN_IF_RESULT_NOT_OK(mUidOwnerMap.init(UID_OWNER_MAP_PATH));
   RETURN_IF_RESULT_NOT_OK(mDataSaverEnabledMap.init(DATA_SAVER_ENABLED_MAP_PATH));
diff --git a/bpf/headers/BpfMapTest.cpp b/bpf/headers/BpfMapTest.cpp
index 862114d..33b88fa 100644
--- a/bpf/headers/BpfMapTest.cpp
+++ b/bpf/headers/BpfMapTest.cpp
@@ -250,5 +250,10 @@
     expectMapEmpty(testMap);
 }
 
+TEST_F(BpfMapTest, testGTSbitmapTestOpen) {
+    BpfMap<int, uint64_t> bitmap;
+    ASSERT_RESULT_OK(bitmap.init("/sys/fs/bpf/tethering/map_test_bitmap"));
+}
+
 }  // namespace bpf
 }  // namespace android
diff --git a/bpf/headers/include/bpf/BpfClassic.h b/bpf/headers/include/bpf/BpfClassic.h
index e6cef89..26d8ad5 100644
--- a/bpf/headers/include/bpf/BpfClassic.h
+++ b/bpf/headers/include/bpf/BpfClassic.h
@@ -170,6 +170,9 @@
 // IPv6 extension headers (HOPOPTS, DSTOPS, FRAG) begin with a u8 nexthdr
 #define BPF_LOAD_NETX_RELATIVE_V6EXTHDR_NEXTHDR BPF_LOAD_NETX_RELATIVE_L4_U8(0)
 
+// IPv6 MLD start with u8 type
+#define BPF_LOAD_NETX_RELATIVE_MLD_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
+
 // IPv6 fragment header is always exactly 8 bytes long
 #define BPF_LOAD_CONSTANT_V6FRAGHDR_LEN \
     BPF_STMT(BPF_LD | BPF_IMM, 8)
diff --git a/bpf/headers/include/bpf/BpfMap.h b/bpf/headers/include/bpf/BpfMap.h
index 1037beb..576cca6 100644
--- a/bpf/headers/include/bpf/BpfMap.h
+++ b/bpf/headers/include/bpf/BpfMap.h
@@ -26,6 +26,7 @@
 #include "BpfSyscallWrappers.h"
 #include "bpf/BpfUtils.h"
 
+#include <cstdio>
 #include <functional>
 
 namespace android {
@@ -35,6 +36,30 @@
 using base::unique_fd;
 using std::function;
 
+#ifdef BPF_MAP_MAKE_VISIBLE_FOR_TESTING
+#undef BPFMAP_VERBOSE_ABORT
+#define BPFMAP_VERBOSE_ABORT
+#endif
+
+[[noreturn]] __attribute__((__format__(__printf__, 2, 3))) static inline
+void Abort(int __unused error, const char* __unused fmt, ...) {
+#ifdef BPFMAP_VERBOSE_ABORT
+    va_list va;
+    va_start(va, fmt);
+
+    fflush(stdout);
+    vfprintf(stderr, fmt, va);
+    if (error) fprintf(stderr, "; errno=%d [%s]", error, strerror(error));
+    putc('\n', stderr);
+    fflush(stderr);
+
+    va_end(va);
+#endif
+
+    abort();
+}
+
+
 // This is a class wrapper for eBPF maps. The eBPF map is a special in-kernel
 // data structure that stores data in <Key, Value> pairs. It can be read/write
 // from userspace by passing syscalls with the map file descriptor. This class
@@ -60,14 +85,21 @@
 
   protected:
     void abortOnMismatch(bool writable) const {
-        if (!mMapFd.ok()) abort();
+        if (!mMapFd.ok()) Abort(errno, "mMapFd %d is not valid", mMapFd.get());
         if (isAtLeastKernelVersion(4, 14, 0)) {
             int flags = bpfGetFdMapFlags(mMapFd);
-            if (flags < 0) abort();
-            if (flags & BPF_F_WRONLY) abort();
-            if (writable && (flags & BPF_F_RDONLY)) abort();
-            if (bpfGetFdKeySize(mMapFd) != sizeof(Key)) abort();
-            if (bpfGetFdValueSize(mMapFd) != sizeof(Value)) abort();
+            if (flags < 0) Abort(errno, "bpfGetFdMapFlags fail: flags=%d", flags);
+            if (flags & BPF_F_WRONLY) Abort(0, "map is write-only (flags=0x%X)", flags);
+            if (writable && (flags & BPF_F_RDONLY))
+                Abort(0, "writable map is actually read-only (flags=0x%X)", flags);
+            int keySize = bpfGetFdKeySize(mMapFd);
+            if (keySize != sizeof(Key))
+                Abort(errno, "map key size mismatch (expected=%zu, actual=%d)",
+                      sizeof(Key), keySize);
+            int valueSize = bpfGetFdValueSize(mMapFd);
+            if (valueSize != sizeof(Value))
+                Abort(errno, "map value size mismatch (expected=%zu, actual=%d)",
+                      sizeof(Value), valueSize);
         }
     }
 
@@ -278,8 +310,8 @@
     [[clang::reinitializes]] Result<void> resetMap(bpf_map_type map_type,
                                                    uint32_t max_entries,
                                                    uint32_t map_flags = 0) {
-        if (map_flags & BPF_F_WRONLY) abort();
-        if (map_flags & BPF_F_RDONLY) abort();
+        if (map_flags & BPF_F_WRONLY) Abort(0, "map_flags is write-only");
+        if (map_flags & BPF_F_RDONLY) Abort(0, "map_flags is read-only");
         mMapFd.reset(createMap(map_type, sizeof(Key), sizeof(Value), max_entries,
                                map_flags));
         if (!mMapFd.ok()) return ErrnoErrorf("BpfMap::resetMap() failed");
diff --git a/bpf/headers/include/bpf/BpfUtils.h b/bpf/headers/include/bpf/BpfUtils.h
index 9dd5822..ed08e1a 100644
--- a/bpf/headers/include/bpf/BpfUtils.h
+++ b/bpf/headers/include/bpf/BpfUtils.h
@@ -26,6 +26,7 @@
 #include <sys/socket.h>
 #include <sys/utsname.h>
 
+#include <android-base/properties.h>
 #include <log/log.h>
 
 #include "KernelUtils.h"
@@ -33,6 +34,16 @@
 namespace android {
 namespace bpf {
 
+const bool unreleased = (base::GetProperty("ro.build.version.codename", "REL") != "REL");
+const int api_level = unreleased ? 10000 : android_get_device_api_level();
+const bool isAtLeastR = (api_level >= 30);
+const bool isAtLeastS = (api_level >= 31);
+// Sv2 is 32
+const bool isAtLeastT = (api_level >= 33);
+const bool isAtLeastU = (api_level >= 34);
+const bool isAtLeastV = (api_level >= 35);
+const bool isAtLeast25Q2 = (api_level >= 36);
+
 // See kernel's net/core/sock_diag.c __sock_gen_cookie()
 // the implementation of which guarantees 0 will never be returned,
 // primarily because 0 is used to mean not yet initialized,
@@ -63,9 +74,9 @@
     // 4.9 kernels. The kernel code of socket release on pf_key socket will
     // explicitly call synchronize_rcu() which is exactly what we need.
     //
-    // Linux 4.14/4.19/5.4/5.10/5.15/6.1 (and 6.3-rc5) still have this same behaviour.
+    // Linux 4.14/4.19/5.4/5.10/5.15/6.1/6.6/6.12 (& 6.13) have this behaviour.
     // see net/key/af_key.c: pfkey_release() -> synchronize_rcu()
-    // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/key/af_key.c?h=v6.3-rc5#n185
+    // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/key/af_key.c?h=v6.13#n185
     const int pfSocket = socket(AF_KEY, SOCK_RAW | SOCK_CLOEXEC, PF_KEY_V2);
 
     if (pfSocket < 0) {
diff --git a/bpf/headers/include/bpf/KernelUtils.h b/bpf/headers/include/bpf/KernelUtils.h
index 68bc607..a36085a 100644
--- a/bpf/headers/include/bpf/KernelUtils.h
+++ b/bpf/headers/include/bpf/KernelUtils.h
@@ -55,12 +55,12 @@
            isKernelVersion(4,  9) ||  // minimum for Android S & T
            isKernelVersion(4, 14) ||  // minimum for Android U
            isKernelVersion(4, 19) ||  // minimum for Android V
-           isKernelVersion(5,  4) ||  // first supported in Android R, min for W
+           isKernelVersion(5,  4) ||  // first supported in Android R, min for 25Q2
            isKernelVersion(5, 10) ||  // first supported in Android S
            isKernelVersion(5, 15) ||  // first supported in Android T
            isKernelVersion(6,  1) ||  // first supported in Android U
            isKernelVersion(6,  6) ||  // first supported in Android V
-           isKernelVersion(6, 12);    // first supported in Android W
+           isKernelVersion(6, 12);    // first supported in Android 25Q2
 }
 
 // Figure out the bitness of userspace.
diff --git a/bpf/headers/include/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
index b994a9f..9d6b6f6 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -46,12 +46,12 @@
 #define BPFLOADER_U_QPR2_VERSION 41u
 #define BPFLOADER_PLATFORM_VERSION BPFLOADER_U_QPR2_VERSION
 
-// Android Mainline - this bpfloader should eventually go back to T (or even S)
+// Android Mainline BpfLoader when running on Android S (sdk=31)
 // Note: this value (and the following +1u's) are hardcoded in NetBpfLoad.cpp
-#define BPFLOADER_MAINLINE_VERSION 42u
+#define BPFLOADER_MAINLINE_S_VERSION 42u
 
 // Android Mainline BpfLoader when running on Android T (sdk=33)
-#define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_VERSION + 1u)
+#define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_S_VERSION + 1u)
 
 // Android Mainline BpfLoader when running on Android U (sdk=34)
 #define BPFLOADER_MAINLINE_U_VERSION (BPFLOADER_MAINLINE_T_VERSION + 1u)
@@ -62,8 +62,8 @@
 // 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)
+// Android Mainline BpfLoader when running on Android 25Q2 (sdk=36)
+#define BPFLOADER_MAINLINE_25Q2_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
@@ -112,7 +112,7 @@
     unsigned int _bpfloader_max_ver SECTION("bpfloader_max_ver") = BPFLOADER_MAX_VER;              \
     size_t _size_of_bpf_map_def SECTION("size_of_bpf_map_def") = sizeof(struct bpf_map_def);       \
     size_t _size_of_bpf_prog_def SECTION("size_of_bpf_prog_def") = sizeof(struct bpf_prog_def);    \
-    unsigned _btf_min_bpfloader_ver SECTION("btf_min_bpfloader_ver") = BPFLOADER_MAINLINE_VERSION; \
+    unsigned _btf_min_bpfloader_ver SECTION("btf_min_bpfloader_ver") = BPFLOADER_MAINLINE_S_VERSION; \
     unsigned _btf_user_min_bpfloader_ver SECTION("btf_user_min_bpfloader_ver") = 0xFFFFFFFFu;      \
     char _license[] SECTION("license") = (NAME)
 
@@ -122,28 +122,46 @@
  */
 #define CRITICAL(REASON) char _critical[] SECTION("critical") = (REASON)
 
-/*
- * Helper functions called from eBPF programs written in C. These are
- * implemented in the kernel sources.
- */
+// Helpers for writing kernel version specific bpf programs
 
 struct kver_uint { unsigned int kver; };
 #define KVER_(v) ((struct kver_uint){ .kver = (v) })
 #define KVER(a, b, c) KVER_(((a) << 24) + ((b) << 16) + (c))
 #define KVER_NONE KVER_(0)
+#define KVER_4_9  KVER(4, 9, 0)
 #define KVER_4_14 KVER(4, 14, 0)
 #define KVER_4_19 KVER(4, 19, 0)
 #define KVER_5_4  KVER(5, 4, 0)
-#define KVER_5_8  KVER(5, 8, 0)
-#define KVER_5_9  KVER(5, 9, 0)
 #define KVER_5_10 KVER(5, 10, 0)
 #define KVER_5_15 KVER(5, 15, 0)
 #define KVER_6_1  KVER(6, 1, 0)
 #define KVER_6_6  KVER(6, 6, 0)
+#define KVER_6_12 KVER(6, 12, 0)
 #define KVER_INF KVER_(0xFFFFFFFFu)
 
 #define KVER_IS_AT_LEAST(kver, a, b, c) ((kver).kver >= KVER(a, b, c).kver)
 
+// Helpers for writing sdk level specific bpf programs
+//
+// Note: we choose to follow sdk api level values, but there is no real need for this:
+// These just need to be monotonically increasing.  We could also use values ten or even
+// a hundred times larger to leave room for quarters or months.  We may also just use
+// dates or something (2502 or 202506 for 25Q2) or even the mainline bpfloader version...
+// For now this easily suffices for our use case.
+
+struct sdk_level_uint { unsigned int sdk_level; };
+#define SDK_LEVEL_(v) ((struct sdk_level_uint){ .sdk_level = (v) })
+#define SDK_LEVEL_NONE SDK_LEVEL_(0)
+#define SDK_LEVEL_S    SDK_LEVEL_(31) // Android 12
+#define SDK_LEVEL_Sv2  SDK_LEVEL_(32) // Android 12L
+#define SDK_LEVEL_T    SDK_LEVEL_(33) // Android 13
+#define SDK_LEVEL_U    SDK_LEVEL_(34) // Android 14
+#define SDK_LEVEL_V    SDK_LEVEL_(35) // Android 15
+#define SDK_LEVEL_24Q3 SDK_LEVEL_V
+#define SDK_LEVEL_25Q2 SDK_LEVEL_(36) // Android 16
+
+#define SDK_LEVEL_IS_AT_LEAST(lvl, v) ((lvl).sdk_level >= (SDK_LEVEL_##v).sdk_level)
+
 /*
  * BPFFS (ie. /sys/fs/bpf) labelling is as follows:
  *   subdirectory   selinux context      mainline  usecase / usable by
@@ -168,6 +186,11 @@
  * See cs/p:aosp-master%20-file:prebuilts/%20file:genfs_contexts%20"genfscon%20bpf"
  */
 
+/*
+ * Helper functions called from eBPF programs written in C. These are
+ * implemented in the kernel sources.
+ */
+
 /* generic functions */
 
 /*
@@ -231,16 +254,24 @@
               (ignore_userdebug).ignore_on_userdebug),                                   \
         "bpfloader min version must be >= 0.33 in order to use ignored_on");
 
+#define ABSOLUTE(x) ((x) < 0 ? -(x) : (x))
+
+#define DEFAULT_BPF_MAP_FLAGS(type, num_entries, mapflags)    \
+    ( (mapflags) |                                            \
+      ((num_entries) < 0 ? BPF_F_NO_PREALLOC : 0) |           \
+      (type == BPF_MAP_TYPE_LPM_TRIE ? BPF_F_NO_PREALLOC : 0) \
+    )
+
 #define DEFINE_BPF_MAP_BASE(the_map, TYPE, keysize, valuesize, num_entries, \
                             usr, grp, md, selinux, pindir, share, minkver,  \
                             maxkver, minloader, maxloader, ignore_eng,      \
-                            ignore_user, ignore_userdebug)                  \
+                            ignore_user, ignore_userdebug, mapflags)        \
     const struct bpf_map_def SECTION("maps") the_map = {                    \
         .type = BPF_MAP_TYPE_##TYPE,                                        \
         .key_size = (keysize),                                              \
         .value_size = (valuesize),                                          \
-        .max_entries = (num_entries),                                       \
-        .map_flags = 0,                                                     \
+        .max_entries = ABSOLUTE(num_entries),                               \
+        .map_flags = DEFAULT_BPF_MAP_FLAGS(BPF_MAP_TYPE_##TYPE, num_entries, mapflags), \
         .uid = (usr),                                                       \
         .gid = (grp),                                                       \
         .mode = (md),                                                       \
@@ -260,16 +291,17 @@
 // Type safe macro to declare a ring buffer and related output functions.
 // Compatibility:
 // * BPF ring buffers are only available kernels 5.8 and above. Any program
-//   accessing the ring buffer should set a program level min_kver >= 5.8.
-// * The definition below sets a map min_kver of 5.8 which requires targeting
+//   accessing the ring buffer should set a program level min_kver >= 5.10,
+//   since 5.10 is the next LTS version.
+// * The definition below sets a map min_kver of 5.10 which requires targeting
 //   a BPFLOADER_MIN_VER >= BPFLOADER_S_VERSION.
 #define DEFINE_BPF_RINGBUF_EXT(the_map, ValueType, size_bytes, usr, grp, md,   \
                                selinux, pindir, share, min_loader, max_loader, \
                                ignore_eng, ignore_user, ignore_userdebug)      \
     DEFINE_BPF_MAP_BASE(the_map, RINGBUF, 0, 0, size_bytes, usr, grp, md,      \
-                        selinux, pindir, share, KVER_5_8, KVER_INF,            \
+                        selinux, pindir, share, KVER_5_10, KVER_INF,           \
                         min_loader, max_loader, ignore_eng, ignore_user,       \
-                        ignore_userdebug);                                     \
+                        ignore_userdebug, 0);                                  \
                                                                                \
     _Static_assert((size_bytes) >= 4096, "min 4 kiB ringbuffer size");         \
     _Static_assert((size_bytes) <= 0x10000000, "max 256 MiB ringbuffer size"); \
@@ -317,11 +349,11 @@
 /* type safe macro to declare a map and related accessor functions */
 #define DEFINE_BPF_MAP_EXT(the_map, TYPE, KeyType, ValueType, num_entries, usr, grp, md,         \
                            selinux, pindir, share, min_loader, max_loader, ignore_eng,           \
-                           ignore_user, ignore_userdebug)                                        \
+                           ignore_user, ignore_userdebug, mapFlags)                              \
   DEFINE_BPF_MAP_BASE(the_map, TYPE, sizeof(KeyType), sizeof(ValueType),                         \
                       num_entries, usr, grp, md, selinux, pindir, share,                         \
                       KVER_NONE, KVER_INF, min_loader, max_loader,                               \
-                      ignore_eng, ignore_user, ignore_userdebug);                                \
+                      ignore_eng, ignore_user, ignore_userdebug, mapFlags);                      \
     BPF_MAP_ASSERT_OK(BPF_MAP_TYPE_##TYPE, (num_entries), (md));                                 \
     _Static_assert(sizeof(KeyType) < 1024, "aosp/2370288 requires < 1024 byte keys");            \
     _Static_assert(sizeof(ValueType) < 65536, "aosp/2370288 requires < 65536 byte values");      \
@@ -359,13 +391,13 @@
 #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)
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG, 0)
 
 #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)
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG, 0)
 
 #define DEFINE_BPF_MAP(the_map, TYPE, KeyType, ValueType, num_entries) \
     DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, \
@@ -387,6 +419,22 @@
     DEFINE_BPF_MAP_UGM(the_map, TYPE, KeyType, ValueType, num_entries, \
                        DEFAULT_BPF_MAP_UID, gid, 0660)
 
+// idea from Linux include/linux/compiler_types.h (eBPF is always a 64-bit arch)
+#define NATIVE_WORD(t) ((sizeof(t) == 1) || (sizeof(t) == 2) || (sizeof(t) == 4) || (sizeof(t) == 8))
+
+// simplified from Linux include/asm-generic/rwonce.h
+#define READ_ONCE(x) \
+  ({ \
+    _Static_assert(NATIVE_WORD(x), "READ_ONCE requires a native word size"); \
+    (*(const volatile typeof(x) *)&(x)) \
+  })
+
+#define WRITE_ONCE(x, value) \
+  do { \
+    _Static_assert(NATIVE_WORD(x), "WRITE_ONCE requires a native word size"); \
+    *(volatile typeof(x) *)&(x) = (value); \
+  } while (0)
+
 // LLVM eBPF builtins: they directly generate BPF_LD_ABS/BPF_LD_IND (skb may be ignored?)
 unsigned long long load_byte(void* skb, unsigned long long off) asm("llvm.bpf.load.byte");
 unsigned long long load_half(void* skb, unsigned long long off) asm("llvm.bpf.load.half");
diff --git a/bpf/headers/include/bpf_map_def.h b/bpf/headers/include/bpf_map_def.h
index 2d6736c..2e5afca 100644
--- a/bpf/headers/include/bpf_map_def.h
+++ b/bpf/headers/include/bpf_map_def.h
@@ -94,6 +94,10 @@
 _Static_assert(_Alignof(enum bpf_map_type) == 4, "_Alignof enum bpf_map_type != 4");
 
 // Linux kernel requires sizeof(int) == 4, sizeof(void*) == sizeof(long), sizeof(long long) == 8
+_Static_assert(sizeof(int) == 4, "sizeof int != 4");
+_Static_assert(__alignof__(int) == 4, "__alignof__ int != 4");
+_Static_assert(_Alignof(int) == 4, "_Alignof int != 4");
+
 _Static_assert(sizeof(unsigned int) == 4, "sizeof unsigned int != 4");
 _Static_assert(__alignof__(unsigned int) == 4, "__alignof__ unsigned int != 4");
 _Static_assert(_Alignof(unsigned int) == 4, "_Alignof unsigned int != 4");
@@ -102,8 +106,12 @@
 // Here sizeof & __alignof__ are consistent, but _Alignof is not: compile for 'aosp_cf_x86_phone'
 _Static_assert(sizeof(unsigned long long) == 8, "sizeof unsigned long long != 8");
 _Static_assert(__alignof__(unsigned long long) == 8, "__alignof__ unsigned long long != 8");
-// BPF wants 8, but 32-bit x86 wants 4
-//_Static_assert(_Alignof(unsigned long long) == 8, "_Alignof unsigned long long != 8");
+// BPF & everyone else wants 8, but 32-bit x86 wants 4
+#if defined(__i386__)
+_Static_assert(_Alignof(unsigned long long) == 4, "x86-32 _Alignof unsigned long long != 4");
+#else
+_Static_assert(_Alignof(unsigned long long) == 8, "_Alignof unsigned long long != 8");
+#endif
 
 
 // for maps:
diff --git a/bpf/loader/Android.bp b/bpf/loader/Android.bp
index b08913a..345e92b 100644
--- a/bpf/loader/Android.bp
+++ b/bpf/loader/Android.bp
@@ -42,6 +42,7 @@
     shared_libs: [
         "libbase",
         "liblog",
+        "libbpf",
     ],
     srcs: ["NetBpfLoad.cpp"],
     apex_available: [
@@ -56,11 +57,22 @@
     installable: false,
 }
 
-// Versioned netbpfload init rc: init system will process it only on api T/33+ devices
+// Versioned netbpfload init rc: init system will process it only on api R/30 S/31 Sv2/32 devices
 // Note: R[30] S[31] Sv2[32] T[33] U[34] V[35])
 //
 // For details of versioned rc files see:
 // https://android.googlesource.com/platform/system/core/+/HEAD/init/README.md#versioned-rc-files-within-apexs
+//
+// However, .Xrc versioning doesn't work on S, so we use unversioned, and thus *do* trigger on R,
+// luckily nothing ever uses the new service on R, so you can think of it as being S/Sv2 only
+prebuilt_etc {
+    name: "netbpfload.31rc",
+    src: "netbpfload.31rc",
+    filename: "netbpfload.rc", // intentional: .31rc wouldn't take effect on S
+    installable: false,
+}
+
+// Versioned netbpfload init rc: init system will process it only on api T/33+ devices
 prebuilt_etc {
     name: "netbpfload.33rc",
     src: "netbpfload.33rc",
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 4834b09..bdc2e8c 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -17,6 +17,8 @@
 #define LOG_TAG "NetBpfLoad"
 
 #include <arpa/inet.h>
+#include <bpf/btf.h>
+#include <bpf/libbpf.h>
 #include <dirent.h>
 #include <elf.h>
 #include <errno.h>
@@ -50,6 +52,7 @@
 #include <android-base/logging.h>
 #include <android-base/macros.h>
 #include <android-base/properties.h>
+#include <android-base/scopeguard.h>
 #include <android-base/stringprintf.h>
 #include <android-base/strings.h>
 #include <android-base/unique_fd.h>
@@ -60,7 +63,8 @@
 #include "bpf_map_def.h"
 
 // The following matches bpf_helpers.h, which is only for inclusion in bpf code
-#define BPFLOADER_MAINLINE_VERSION 42u
+#define BPFLOADER_MAINLINE_S_VERSION 42u
+#define BPFLOADER_MAINLINE_25Q2_VERSION 47u
 
 using android::base::EndsWith;
 using android::base::GetIntProperty;
@@ -121,6 +125,7 @@
 struct Location {
     const char* const dir = "";
     const char* const prefix = "";
+    const bool t_plus = true;
 };
 
 // Returns the build type string (from ro.build.type).
@@ -551,9 +556,9 @@
         vector<string> csSymNames;
         ret = getSectionSymNames(elfFile, oldName, csSymNames, STT_FUNC);
         if (ret || !csSymNames.size()) return ret;
-        for (size_t i = 0; i < progDefNames.size(); ++i) {
-            if (!progDefNames[i].compare(csSymNames[0] + "_def")) {
-                cs_temp.prog_def = pd[i];
+        for (size_t j = 0; j < progDefNames.size(); ++j) {
+            if (!progDefNames[j].compare(csSymNames[0] + "_def")) {
+                cs_temp.prog_def = pd[j];
                 break;
             }
         }
@@ -616,9 +621,6 @@
     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.
@@ -649,10 +651,185 @@
     return false;
 }
 
+static int setBtfDatasecSize(ifstream &elfFile, struct btf *btf,
+                             struct btf_type *bt) {
+    const char *name = btf__name_by_offset(btf, bt->name_off);
+    if (!name) {
+        ALOGE("Couldn't resolve section name, errno: %d", errno);
+        return -errno;
+    }
+
+    vector<char> data;
+    int ret = readSectionByName(name, elfFile, data);
+    if (ret) {
+        ALOGE("Couldn't read section %s, ret: %d", name, ret);
+        return ret;
+    }
+    bt->size = data.size();
+    return 0;
+}
+
+static int getSymOffsetByName(ifstream &elfFile, const char *name, int *off) {
+    vector<Elf64_Sym> symtab;
+    int ret = readSymTab(elfFile, 1 /* sort */, symtab);
+    if (ret) return ret;
+    for (int i = 0; i < (int)symtab.size(); i++) {
+        string s;
+        ret = getSymName(elfFile, symtab[i].st_name, s);
+        if (ret) continue;
+        if (!strcmp(s.c_str(), name)) {
+            *off = symtab[i].st_value;
+            return 0;
+        }
+    }
+    return -1;
+}
+
+static int setBtfVarOffset(ifstream &elfFile, struct btf *btf,
+                           struct btf_type *datasecBt) {
+    int i, vars = btf_vlen(datasecBt);
+    struct btf_var_secinfo *vsi;
+    const char *datasecName = btf__name_by_offset(btf, datasecBt->name_off);
+    if (!datasecName) {
+        ALOGE("Couldn't resolve section name, errno: %d", errno);
+        return -errno;
+    }
+
+    for (i = 0, vsi = btf_var_secinfos(datasecBt); i < vars; i++, vsi++) {
+        const struct btf_type *varBt = btf__type_by_id(btf, vsi->type);
+        if (!varBt || !btf_is_var(varBt)) {
+            ALOGE("Found non VAR kind btf_type, section: %s id: %d", datasecName,
+                  vsi->type);
+            return -1;
+        }
+
+        const struct btf_var *var = btf_var(varBt);
+        if (var->linkage == BTF_VAR_STATIC) continue;
+
+        const char *varName = btf__name_by_offset(btf, varBt->name_off);
+        if (!varName) {
+            ALOGE("Failed to resolve var name, section: %s", datasecName);
+            return -1;
+        }
+
+        int off;
+        int ret = getSymOffsetByName(elfFile, varName, &off);
+        if (ret) {
+            ALOGE("No offset found in symbol table, section: %s, var: %s, ret: %d",
+                  datasecName, varName, ret);
+            return ret;
+        }
+        vsi->offset = off;
+    }
+    return 0;
+}
+
+static int loadBtf(ifstream &elfFile, struct btf *btf) {
+    int ret;
+    for (unsigned int i = 1; i < btf__type_cnt(btf); ++i) {
+        struct btf_type *bt = (struct btf_type *)btf__type_by_id(btf, i);
+        if (!btf_is_datasec(bt)) continue;
+        ret = setBtfDatasecSize(elfFile, btf, bt);
+        if (ret) return ret;
+        ret = setBtfVarOffset(elfFile, btf, bt);
+        if (ret) return ret;
+    }
+
+    ret = btf__load_into_kernel(btf);
+    if (ret) {
+        if (errno != EINVAL) {
+            ALOGE("btf__load_into_kernel failed, errno: %d", errno);
+            return ret;
+        };
+        // For BTF_KIND_FUNC, newer kernels can read the BTF_INFO_VLEN bits of
+        // struct btf_type to distinguish static vs. global vs. extern
+        // functions, but older kernels enforce that only the BTF_INFO_KIND bits
+        // can be set. Retry with non-BTF_INFO_KIND bits zeroed out to handle
+        // this case.
+        for (unsigned int i = 1; i < btf__type_cnt(btf); ++i) {
+            struct btf_type *bt = (struct btf_type *)btf__type_by_id(btf, i);
+            if (btf_is_func(bt)) {
+                bt->info = (BTF_INFO_KIND(bt->info)) << 24;
+            }
+        }
+        ret = btf__load_into_kernel(btf);
+        if (ret) {
+            ALOGE("btf__load_into_kernel retry failed, errno: %d", errno);
+            return ret;
+        };
+    }
+    return 0;
+}
+
+int getKeyValueTids(const struct btf *btf, const char *mapName,
+                    uint32_t expectedKeySize, uint32_t expectedValueSize,
+                    uint32_t *keyTypeId, uint32_t *valueTypeId) {
+    const struct btf_type *kvBt;
+    const struct btf_member *key, *value;
+    const size_t max_name = 256;
+    char kvTypeName[max_name];
+    int64_t keySize, valueSize;
+    int32_t kvId;
+
+    if (snprintf(kvTypeName, max_name, "____btf_map_%s", mapName) == max_name) {
+        ALOGE("____btf_map_%s is too long", mapName);
+        return -1;
+    }
+
+    kvId = btf__find_by_name(btf, kvTypeName);
+    if (kvId < 0) {
+        ALOGE("section not found, map: %s typeName: %s", mapName, kvTypeName);
+        return -1;
+    }
+
+    kvBt = btf__type_by_id(btf, kvId);
+    if (!kvBt) {
+        ALOGE("Couldn't find BTF type, map: %s id: %u", mapName, kvId);
+        return -1;
+    }
+
+    if (!btf_is_struct(kvBt) || btf_vlen(kvBt) < 2) {
+        ALOGE("Non Struct kind or invalid vlen, map: %s id: %u", mapName, kvId);
+        return -1;
+    }
+
+    key = btf_members(kvBt);
+    value = key + 1;
+
+    keySize = btf__resolve_size(btf, key->type);
+    if (keySize < 0) {
+        ALOGE("Couldn't get key size, map: %s errno: %d", mapName, errno);
+        return -1;
+    }
+
+    valueSize = btf__resolve_size(btf, value->type);
+    if (valueSize < 0) {
+        ALOGE("Couldn't get value size, map: %s errno: %d", mapName, errno);
+        return -1;
+    }
+
+    if (expectedKeySize != keySize || expectedValueSize != valueSize) {
+        ALOGE("Key value size mismatch, map: %s key size: %d expected key size: "
+              "%d value size: %d expected value size: %d",
+              mapName, (uint32_t)keySize, expectedKeySize, (uint32_t)valueSize,
+              expectedValueSize);
+        return -1;
+    }
+
+    *keyTypeId = key->type;
+    *valueTypeId = value->type;
+
+    return 0;
+}
+
+static bool isBtfSupported(enum bpf_map_type type) {
+    return type != BPF_MAP_TYPE_DEVMAP_HASH && type != BPF_MAP_TYPE_RINGBUF;
+}
+
 static int createMaps(const char* elfPath, ifstream& elfFile, vector<unique_fd>& mapFds,
                       const char* prefix, const unsigned int bpfloader_ver) {
     int ret;
-    vector<char> mdData;
+    vector<char> mdData, btfData;
     vector<struct bpf_map_def> md;
     vector<string> mapNames;
     string objName = pathToObjName(string(elfPath));
@@ -679,6 +856,27 @@
     ret = getSectionSymNames(elfFile, "maps", mapNames);
     if (ret) return ret;
 
+    struct btf *btf = NULL;
+    auto scopeGuard = base::make_scope_guard([btf] { if (btf) btf__free(btf); });
+    if (isAtLeastKernelVersion(5, 10, 0)) {
+        // Untested on Linux Kernel 5.4, but likely compatible.
+        // On Linux Kernels older than 4.18 BPF_BTF_LOAD command doesn't exist.
+        // On Linux Kernels older than 5.2 BTF_KIND_VAR and BTF_KIND_DATASEC don't exist.
+        ret = readSectionByName(".BTF", elfFile, btfData);
+        if (ret) {
+            ALOGE("Failed to read .BTF section, ret:%d", ret);
+            return ret;
+        }
+        btf = btf__new(btfData.data(), btfData.size());
+        if (btf == NULL) {
+            ALOGE("btf__new failed, errno: %d", errno);
+            return -errno;
+        }
+
+        ret = loadBtf(elfFile, btf);
+        if (ret) return ret;
+    }
+
     unsigned kvers = kernelVersion();
 
     for (int i = 0; i < (int)mapNames.size(); i++) {
@@ -732,6 +930,12 @@
         }
 
         enum bpf_map_type type = md[i].type;
+        if (type == BPF_MAP_TYPE_LPM_TRIE && !isAtLeastKernelVersion(4, 14, 0)) {
+            // On Linux Kernels older than 4.14 this map type doesn't exist - autoskip.
+            ALOGD("skipping LPM_TRIE map %s - requires kver 4.14+", mapNames[i].c_str());
+            mapFds.push_back(unique_fd());
+            continue;
+        }
         if (type == BPF_MAP_TYPE_DEVMAP && !isAtLeastKernelVersion(4, 14, 0)) {
             // On Linux Kernels older than 4.14 this map type doesn't exist, but it can kind
             // of be approximated: ARRAY has the same userspace api, though it is not usable
@@ -794,16 +998,30 @@
               .key_size = md[i].key_size,
               .value_size = md[i].value_size,
               .max_entries = max_entries,
-              .map_flags = md[i].map_flags | (type == BPF_MAP_TYPE_LPM_TRIE ? BPF_F_NO_PREALLOC : 0),
+              .map_flags = md[i].map_flags,
             };
             if (isAtLeastKernelVersion(4, 15, 0))
                 strlcpy(req.map_name, mapNames[i].c_str(), sizeof(req.map_name));
+
+            bool haveBtf = btf && isBtfSupported(type);
+            if (haveBtf) {
+                uint32_t kTid, vTid;
+                ret = getKeyValueTids(btf, mapNames[i].c_str(), md[i].key_size,
+                                      md[i].value_size, &kTid, &vTid);
+                if (ret) return ret;
+                req.btf_fd = btf__fd(btf);
+                req.btf_key_type_id = kTid;
+                req.btf_value_type_id = vTid;
+            }
+
             fd.reset(bpf(BPF_MAP_CREATE, req));
             saved_errno = errno;
             if (fd.ok()) {
-              ALOGD("bpf_create_map[%s] -> %d", mapNames[i].c_str(), fd.get());
+                ALOGD("bpf_create_map[%s] btf:%d -> %d",
+                      mapNames[i].c_str(), haveBtf, fd.get());
             } else {
-              ALOGE("bpf_create_map[%s] -> %d errno:%d", mapNames[i].c_str(), fd.get(), saved_errno);
+                ALOGE("bpf_create_map[%s] btf:%d -> %d errno:%d",
+                      mapNames[i].c_str(), haveBtf, fd.get(), saved_errno);
             }
         }
 
@@ -820,14 +1038,14 @@
                                    "tmp_map_" + objName + "_" + mapNames[i];
                 ret = bpfFdPin(fd, createLoc.c_str());
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("create %s -> %d [%d:%s]", createLoc.c_str(), ret, err, strerror(err));
                     return -err;
                 }
                 ret = renameat2(AT_FDCWD, createLoc.c_str(),
                                 AT_FDCWD, mapPinLoc.c_str(), RENAME_NOREPLACE);
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("rename %s %s -> %d [%d:%s]", createLoc.c_str(), mapPinLoc.c_str(), ret,
                           err, strerror(err));
                     return -err;
@@ -835,32 +1053,34 @@
             } else {
                 ret = bpfFdPin(fd, mapPinLoc.c_str());
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("pin %s -> %d [%d:%s]", mapPinLoc.c_str(), ret, err, strerror(err));
                     return -err;
                 }
             }
             ret = chmod(mapPinLoc.c_str(), md[i].mode);
             if (ret) {
-                int err = errno;
+                const int err = errno;
                 ALOGE("chmod(%s, 0%o) = %d [%d:%s]", mapPinLoc.c_str(), md[i].mode, ret, err,
                       strerror(err));
                 return -err;
             }
             ret = chown(mapPinLoc.c_str(), (uid_t)md[i].uid, (gid_t)md[i].gid);
             if (ret) {
-                int err = errno;
+                const int err = errno;
                 ALOGE("chown(%s, %u, %u) = %d [%d:%s]", mapPinLoc.c_str(), md[i].uid, md[i].gid,
                       ret, err, strerror(err));
                 return -err;
             }
         }
 
-        int mapId = bpfGetFdMapId(fd);
-        if (mapId == -1) {
-            if (isAtLeastKernelVersion(4, 14, 0))
-                ALOGE("bpfGetFdMapId failed, ret: %d [%d]", mapId, errno);
-        } else {
+        if (isAtLeastKernelVersion(4, 14, 0)) {
+            int mapId = bpfGetFdMapId(fd);
+            if (mapId == -1) {
+                const int err = errno;
+                ALOGE("bpfGetFdMapId failed, errno: %d", err);
+                return -err;
+            }
             ALOGI("map %s id %d", mapPinLoc.c_str(), mapId);
         }
 
@@ -1003,7 +1223,7 @@
         if (access(progPinLoc.c_str(), F_OK) == 0) {
             fd.reset(retrieveProgram(progPinLoc.c_str()));
             ALOGD("New bpf prog load reusing prog %s, ret: %d (%s)", progPinLoc.c_str(), fd.get(),
-                  (!fd.ok() ? std::strerror(errno) : "no error"));
+                  !fd.ok() ? std::strerror(errno) : "ok");
             reuse = true;
         } else {
             static char log_buf[1 << 20];  // 1 MiB logging buffer
@@ -1034,7 +1254,7 @@
 
             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"));
+                  fd.get(), !fd.ok() ? std::strerror(errno) : "ok");
 
             if (!fd.ok()) {
                 // kernel NULL terminates log_buf, so this checks for non-empty string
@@ -1063,14 +1283,14 @@
                                    "tmp_prog_" + objName + '_' + string(name);
                 ret = bpfFdPin(fd, createLoc.c_str());
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("create %s -> %d [%d:%s]", createLoc.c_str(), ret, err, strerror(err));
                     return -err;
                 }
                 ret = renameat2(AT_FDCWD, createLoc.c_str(),
                                 AT_FDCWD, progPinLoc.c_str(), RENAME_NOREPLACE);
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("rename %s %s -> %d [%d:%s]", createLoc.c_str(), progPinLoc.c_str(), ret,
                           err, strerror(err));
                     return -err;
@@ -1078,30 +1298,52 @@
             } else {
                 ret = bpfFdPin(fd, progPinLoc.c_str());
                 if (ret) {
-                    int err = errno;
+                    const int err = errno;
                     ALOGE("create %s -> %d [%d:%s]", progPinLoc.c_str(), ret, err, strerror(err));
                     return -err;
                 }
             }
             if (chmod(progPinLoc.c_str(), 0440)) {
-                int err = errno;
+                const int err = errno;
                 ALOGE("chmod %s 0440 -> [%d:%s]", progPinLoc.c_str(), err, strerror(err));
                 return -err;
             }
             if (chown(progPinLoc.c_str(), (uid_t)cs[i].prog_def->uid,
                       (gid_t)cs[i].prog_def->gid)) {
-                int err = errno;
+                const int err = errno;
                 ALOGE("chown %s %d %d -> [%d:%s]", progPinLoc.c_str(), cs[i].prog_def->uid,
                       cs[i].prog_def->gid, err, strerror(err));
                 return -err;
             }
         }
 
-        int progId = bpfGetFdProgId(fd);
-        if (progId == -1) {
-            ALOGE("bpfGetFdProgId failed, ret: %d [%d]", progId, errno);
-        } else {
-            ALOGI("prog %s id %d", progPinLoc.c_str(), progId);
+        if (isAtLeastKernelVersion(4, 14, 0)) {
+            int progId = bpfGetFdProgId(fd);
+            if (progId == -1) {
+                const int err = errno;
+                ALOGE("bpfGetFdProgId failed, errno: %d", err);
+                return -err;
+            }
+
+            int jitLen = bpfGetFdJitProgLen(fd);
+            if (jitLen == -1) {
+                const int err = errno;
+                ALOGE("bpfGetFdJitProgLen failed, ret: %d", err);
+                return -err;
+            }
+
+            int xlatLen = bpfGetFdXlatProgLen(fd);
+            if (xlatLen == -1) {
+                const int err = errno;
+                ALOGE("bpfGetFdXlatProgLen failed, ret: %d", err);
+                return -err;
+            }
+            ALOGI("prog %s id %d len jit:%d xlat:%d", progPinLoc.c_str(), progId, jitLen, xlatLen);
+
+            if (!jitLen && bpfloader_ver >= BPFLOADER_MAINLINE_25Q2_VERSION) {
+                ALOGE("Kernel eBPF JIT failure for %s", progPinLoc.c_str());
+                return -ENOTSUP;
+            }
         }
     }
 
@@ -1159,7 +1401,7 @@
     ret = readCodeSections(elfFile, cs);
     // BPF .o's with no programs are only supported by mainline netbpfload,
     // make sure .o's targeting non-mainline (ie. S) bpfloader don't show up.
-    if (ret == -ENOENT && bpfLoaderMinVer >= BPFLOADER_MAINLINE_VERSION)
+    if (ret == -ENOENT && bpfLoaderMinVer >= BPFLOADER_MAINLINE_S_VERSION)
         return 0;
     if (ret) {
         ALOGE("Couldn't read all code sections in %s", elfPath);
@@ -1188,8 +1430,9 @@
 const Location locations[] = {
         // S+ Tethering mainline module (network_stack): tether offload
         {
-                .dir = BPFROOT "/",
+                .dir = BPFROOT "/tethering/",
                 .prefix = "tethering/",
+                .t_plus = false,
         },
         // T+ Tethering mainline module (shared with netd & system server)
         // netutils_wrapper (for iptables xt_bpf) has access to programs
@@ -1383,43 +1626,41 @@
     return wear;
 }
 
+static int libbpfPrint(enum libbpf_print_level lvl, const char *const formatStr,
+                       va_list argList) {
+    int32_t prio;
+    switch (lvl) {
+      case LIBBPF_WARN:
+        prio = ANDROID_LOG_WARN;
+        break;
+      case LIBBPF_INFO:
+        prio = ANDROID_LOG_INFO;
+        break;
+      case LIBBPF_DEBUG:
+        prio = ANDROID_LOG_DEBUG;
+        break;
+    }
+    char *s = strdup(formatStr ?: "(no format string)");
+    int len = strlen(s);
+    if (len && s[len - 1] == '\n')
+        s[len - 1] = 0;
+    LOG_PRI_VA(prio, LOG_TAG, s, argList);
+    free(s);
+    return 0;
+}
+
 static int doLoad(char** argv, char * const envp[]) {
+    if (!isAtLeastS) {
+        ALOGE("Impossible - not reachable on Android <S.");
+        // for safety, we don't fail, this is a just-in-case workaround
+        // for any possible busted 'optimized' start everything vendor init hacks on R
+        return 0;
+    }
+    libbpf_set_print(libbpfPrint);
+
     const bool runningAsRoot = !getuid();  // true iff U QPR3 or V+
 
-    // 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 = (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)
-    //
-    // Since we develop in AOSP, we want it to behave as if it was bumped too.
-    //
-    // Note that AOSP doesn't really have a good api level (for example during
-    // early V dev cycle, it would have *all* of T, some but not all of U, and some V).
-    // One could argue that for our purposes AOSP api level should be infinite or 10000.
-    //
-    // This could also cause api to be increased in goog/main or other branches,
-    // but I can't imagine a case where this would be a problem: the problem
-    // is rather a too low api level, rather than some ill defined high value.
-    // For example as I write this aosp is 34/U, and goog is 35/V,
-    // we want to treat both goog & aosp as 35/V, but it's harmless if we
-    // treat goog as 36 because that value isn't yet defined to mean anything,
-    // and we thus never compare against it.
-    //
-    // Also note that 'android_get_device_api_level()' is what the
-    //   //system/core/init/apex_init_util.cpp
-    // apex init .XXrc parsing code uses for XX filtering.
-    //
-    // That code has a hack to bump <35 to 35 (to force aosp/main to parse .35rc),
-    // but could (should?) perhaps be adjusted to match this.
-    const int effective_api_level = android_get_device_api_level() + (int)unreleased;
-    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);
+    const int first_api_level = GetIntProperty("ro.board.first_api_level", api_level);
 
     // last in U QPR2 beta1
     const bool has_platform_bpfloader_rc = exists("/system/etc/init/bpfloader.rc");
@@ -1427,17 +1668,19 @@
     const bool has_platform_netbpfload_rc = exists("/system/etc/init/netbpfload.rc");
 
     // Version of Network BpfLoader depends on the Android OS version
-    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_VERSION;  // [42u]
+    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_S_VERSION;  // [42u]
     if (isAtLeastT) ++bpfloader_ver;     // [43] BPFLOADER_MAINLINE_T_VERSION
     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
+    if (isAtLeast25Q2) ++bpfloader_ver;  // [47] BPFLOADER_MAINLINE_25Q2_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,
-          kernelVersion(), describeArch(), getuid(),
-          has_platform_bpfloader_rc, has_platform_netbpfload_rc);
+    ALOGI("NetBpfLoad v0.%u (%s) api:%d/%d kver:%07x (%s) libbpf: v%u.%u "
+          "uid:%d rc:%d%d",
+          bpfloader_ver, argv[0], android_get_device_api_level(), api_level,
+          kernelVersion(), describeArch(), libbpf_major_version(),
+          libbpf_minor_version(), getuid(), has_platform_bpfloader_rc,
+          has_platform_netbpfload_rc);
 
     if (!has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
         ALOGE("Unable to find platform's bpfloader & netbpfload init scripts.");
@@ -1451,14 +1694,9 @@
 
     logTetheringApexVersion();
 
-    if (!isAtLeastT) {
-        ALOGE("Impossible - not reachable on Android <T.");
-        return 1;
-    }
-
     // both S and T require kernel 4.9 (and eBpf support)
-    if (isAtLeastT && !isAtLeastKernelVersion(4, 9, 0)) {
-        ALOGE("Android T requires kernel 4.9.");
+    if (!isAtLeastKernelVersion(4, 9, 0)) {
+        ALOGE("Android S & T require kernel 4.9.");
         return 1;
     }
 
@@ -1475,6 +1713,13 @@
         return 1;
     }
 
+    // 25Q2 bumps the kernel requirement up to 5.4
+    // see also: //system/netd/tests/kernel_test.cpp TestKernel54
+    if (isAtLeast25Q2 && !isAtLeastKernelVersion(5, 4, 0)) {
+        ALOGE("Android 25Q2 requires kernel 5.4.");
+        return 1;
+    }
+
     // Technically already required by U, but only enforce on V+
     // see also: //system/netd/tests/kernel_test.cpp TestKernel64Bit
     if (isAtLeastV && isKernel32Bit() && isAtLeastKernelVersion(5, 16, 0)) {
@@ -1482,6 +1727,11 @@
         if (!isTV()) return 1;
     }
 
+    if (isKernel32Bit() && isAtLeast25Q2) {
+        ALOGE("Android 25Q2 requires 64 bit kernel.");
+        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");
@@ -1498,13 +1748,13 @@
         bool bad = false;
 
         if (!isLtsKernel()) {
-            ALOGW("Android V only supports LTS kernels.");
+            ALOGW("Android V+ only supports LTS kernels.");
             bad = true;
         }
 
 #define REQUIRE(maj, min, sub) \
         if (isKernelVersion(maj, min) && !isAtLeastKernelVersion(maj, min, sub)) { \
-            ALOGW("Android V requires %d.%d kernel to be %d.%d.%d+.", maj, min, maj, min, sub); \
+            ALOGW("Android V+ requires %d.%d kernel to be %d.%d.%d+.", maj, min, maj, min, sub); \
             bad = true; \
         }
 
@@ -1514,6 +1764,7 @@
         REQUIRE(5, 15, 136)
         REQUIRE(6, 1, 57)
         REQUIRE(6, 6, 0)
+        REQUIRE(6, 12, 0)
 
 #undef REQUIRE
 
@@ -1552,7 +1803,7 @@
         if (isArm() && (isTV() || isWear())) {
             // exempt Arm TV or Wear devices (arm32 ABI is far less problematic than x86-32)
             ALOGW("[Arm TV/Wear] 32-bit userspace unsupported on 6.2+ kernels.");
-        } else if (first_api_level <= __ANDROID_API_T__ && isArm()) {
+        } else if (first_api_level <= 33 /*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.");
@@ -1571,6 +1822,19 @@
         return 1;
     }
 
+    if (isAtLeast25Q2) {
+        FILE * f = fopen("/system/etc/init/netbpfload.rc", "re");
+        if (!f) {
+            ALOGE("failure opening /system/etc/init/netbpfload.rc");
+            return 1;
+        }
+        int y = -1, q = -1, a = -1, b = -1, c = -1;
+        int v = fscanf(f, "# %d %d %d %d %d #", &y, &q, &a, &b, &c);
+        ALOGI("detected %d of 5: %dQ%d api:%d.%d.%d", v, y, q, a, b, c);
+        fclose(f);
+        if (v != 5 || y != 2025 || q != 2 || a != 36 || b || c) return 1;
+    }
+
     // Ensure we can determine the Android build type.
     if (!isEng() && !isUser() && !isUserdebug()) {
         ALOGE("Failed to determine the build type: got %s, want 'eng', 'user', or 'userdebug'",
@@ -1614,18 +1878,22 @@
     //  which could otherwise fail with ENOENT during object pinning or renaming,
     //  due to ordering issues)
     for (const auto& location : locations) {
+        if (location.t_plus && !isAtLeastT) continue;
         if (createSysFsBpfSubDir(location.prefix)) return 1;
     }
 
-    // Note: there's no actual src dir for fs_bpf_loader .o's,
-    // so it is not listed in 'locations[].prefix'.
-    // This is because this is primarily meant for triggering genfscon rules,
-    // and as such this will likely always be the case.
-    // Thus we need to manually create the /sys/fs/bpf/loader subdirectory.
-    if (createSysFsBpfSubDir("loader")) return 1;
+    if (isAtLeastT) {
+        // Note: there's no actual src dir for fs_bpf_loader .o's,
+        // so it is not listed in 'locations[].prefix'.
+        // This is because this is primarily meant for triggering genfscon rules,
+        // and as such this will likely always be the case.
+        // Thus we need to manually create the /sys/fs/bpf/loader subdirectory.
+        if (createSysFsBpfSubDir("loader")) return 1;
+    }
 
     // Load all ELF objects, create programs and maps, and pin them
     for (const auto& location : locations) {
+        if (location.t_plus && !isAtLeastT) continue;
         if (loadAllElfObjects(bpfloader_ver, location) != 0) {
             ALOGE("=== CRITICAL FAILURE LOADING BPF PROGRAMS FROM %s ===", location.dir);
             ALOGE("If this triggers reliably, you're probably missing kernel options or patches.");
@@ -1646,6 +1914,9 @@
         return 1;
     }
 
+    // on S we haven't created this subdir yet, but we need it for 'mainline_done' flag below
+    if (!isAtLeastT && createSysFsBpfSubDir("netd_shared")) return 1;
+
     // leave a flag that we're done
     if (createSysFsBpfSubDir("netd_shared/mainline_done")) return 1;
 
@@ -1658,17 +1929,17 @@
     }
 
     // unreachable before U QPR3
-    {
+    if (exists(uprobestatsBpfLoader)) {
       ALOGI("done, transferring control to uprobestatsbpfload.");
       const char *args[] = {
           uprobestatsBpfLoader,
           NULL,
       };
       execve(args[0], (char **)args, envp);
+      ALOGI("unable to execute uprobestatsbpfload, transferring control to "
+            "platform bpfloader.");
     }
 
-    ALOGI("unable to execute uprobestatsbpfload, transferring control to "
-          "platform bpfloader.");
     // platform BpfLoader *needs* to run as root
     const char * args[] = { platformBpfLoader, NULL, };
     execve(args[0], (char**)args, envp);
@@ -1680,7 +1951,12 @@
 }  // namespace android
 
 int main(int argc, char** argv, char * const envp[]) {
-    InitLogging(argv, &KernelLogger);
+    if (android::bpf::isAtLeastT) {
+        InitLogging(argv, &KernelLogger);
+    } else {
+        // S lacks the sepolicy to make non-root uid KernelLogger viable
+        InitLogging(argv);
+    }
 
     if (argc == 2 && !strcmp(argv[1], "done")) {
         // we're being re-exec'ed from platform bpfloader to 'finalize' things
diff --git a/bpf/loader/netbpfload.31rc b/bpf/loader/netbpfload.31rc
new file mode 100644
index 0000000..bca7dc8
--- /dev/null
+++ b/bpf/loader/netbpfload.31rc
@@ -0,0 +1,13 @@
+# This file takes effect only on S and Sv2
+# (Note: it does take effect on R as well, but isn't actually used)
+#
+# The service is started from netd's dnsresolver call into ADnsHelper_init()
+# on initial (boot time) startup of netd.
+
+service mdnsd_netbpfload /apex/com.android.tethering/bin/netbpfload
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    group system root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw
+    user system
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,netbpfload-failed
diff --git a/bpf/loader/netbpfload.rc b/bpf/loader/netbpfload.rc
index 10bfbb2..4cc6284 100644
--- a/bpf/loader/netbpfload.rc
+++ b/bpf/loader/netbpfload.rc
@@ -1,3 +1,5 @@
+# 2025 2 36 0 0 # 25q2 sdk/api level 36.0 - Android 16 Baklava QPR0
+
 # Note: This will actually execute /apex/com.android.tethering/bin/netbpfload
 # by virtue of 'service bpfloader' being overridden by the apex shipped .rc
 # Warning: most of the below settings are irrelevant unless the apex is missing.
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 340acda..680c05e 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -22,7 +22,6 @@
 #include <inttypes.h>
 
 #include <android-base/unique_fd.h>
-#include <android-modules-utils/sdk_level.h>
 #include <bpf/WaitForProgsLoaded.h>
 #include <log/log.h>
 #include <netdutils/UidConstants.h>
@@ -36,6 +35,12 @@
 using base::unique_fd;
 using base::WaitForProperty;
 using bpf::getSocketCookie;
+using bpf::isAtLeastKernelVersion;
+using bpf::isAtLeastT;
+using bpf::isAtLeastU;
+using bpf::isAtLeastV;
+using bpf::isAtLeast25Q2;
+using bpf::queryProgram;
 using bpf::retrieveProgram;
 using netdutils::Status;
 using netdutils::statusFromErrno;
@@ -56,7 +61,7 @@
     if (!cgroupProg.ok()) {
         return statusFromErrno(errno, fmt::format("Failed to get program from {}", programPath));
     }
-    if (android::bpf::attachProgram(type, cgroupProg, cgroupFd)) {
+    if (bpf::attachProgram(type, cgroupProg, cgroupFd)) {
         return statusFromErrno(errno, fmt::format("Program {} attach failed", programPath));
     }
     return netdutils::status::ok;
@@ -74,30 +79,36 @@
     if (!cg2_path) return Status("cg2_path is NULL");
 
     // This code was mainlined in T, so this should be trivially satisfied.
-    if (!modules::sdklevel::IsAtLeastT()) return Status("S- platform is unsupported");
+    if (!isAtLeastT) return Status("S- platform is unsupported");
 
     // S requires eBPF support which was only added in 4.9, so this should be satisfied.
-    if (!bpf::isAtLeastKernelVersion(4, 9, 0)) {
+    if (!isAtLeastKernelVersion(4, 9, 0)) {
         return Status("kernel version < 4.9.0 is unsupported");
     }
 
     // U bumps the kernel requirement up to 4.14
-    if (modules::sdklevel::IsAtLeastU() && !bpf::isAtLeastKernelVersion(4, 14, 0)) {
+    if (isAtLeastU && !isAtLeastKernelVersion(4, 14, 0)) {
         return Status("U+ platform with kernel version < 4.14.0 is unsupported");
     }
 
     // U mandates this mount point (though it should also be the case on T)
-    if (modules::sdklevel::IsAtLeastU() && !!strcmp(cg2_path, "/sys/fs/cgroup")) {
+    if (isAtLeastU && !!strcmp(cg2_path, "/sys/fs/cgroup")) {
         return Status("U+ platform with cg2_path != /sys/fs/cgroup is unsupported");
     }
 
-    unique_fd cg_fd(open(cg2_path, O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (!cg_fd.ok()) {
-        const int err = errno;
-        ALOGE("Failed to open the cgroup directory: %s", strerror(err));
-        return statusFromErrno(err, "Open the cgroup directory failed");
+    // V bumps the kernel requirement up to 4.19
+    if (isAtLeastV && !isAtLeastKernelVersion(4, 19, 0)) {
+        return Status("V+ platform with kernel version < 4.19.0 is unsupported");
     }
 
+    // 25Q2 bumps the kernel requirement up to 5.4
+    if (isAtLeast25Q2 && !isAtLeastKernelVersion(5, 4, 0)) {
+        return Status("25Q2+ platform with kernel version < 5.4.0 is unsupported");
+    }
+
+    unique_fd cg_fd(open(cg2_path, O_DIRECTORY | O_RDONLY | O_CLOEXEC));
+    if (!cg_fd.ok()) return statusFromErrno(errno, "Opening cgroup dir failed");
+
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_ALLOWLIST_PROG_PATH));
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_DENYLIST_PROG_PATH));
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_EGRESS_PROG_PATH));
@@ -110,20 +121,20 @@
     // cgroup if the program is pinned properly.
     // TODO: delete the if statement once all devices should support cgroup
     // socket filter (ie. the minimum kernel version required is 4.14).
-    if (bpf::isAtLeastKernelVersion(4, 14, 0)) {
+    if (isAtLeastKernelVersion(4, 14, 0)) {
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_CREATE_PROG_PATH,
                                     cg_fd, BPF_CGROUP_INET_SOCK_CREATE));
     }
 
-    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+    if (isAtLeastKernelVersion(5, 10, 0)) {
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
                                     cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
     }
 
-    if (modules::sdklevel::IsAtLeastV()) {
+    if (isAtLeastV) {
         // V requires 4.19+, so technically this 2nd 'if' is not required, but it
         // doesn't hurt us to try to support AOSP forks that try to support older kernels.
-        if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
+        if (isAtLeastKernelVersion(4, 19, 0)) {
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
                                         cg_fd, BPF_CGROUP_INET4_CONNECT));
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT6_PROG_PATH,
@@ -138,7 +149,7 @@
                                         cg_fd, BPF_CGROUP_UDP6_SENDMSG));
         }
 
-        if (bpf::isAtLeastKernelVersion(5, 4, 0)) {
+        if (isAtLeastKernelVersion(5, 4, 0)) {
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_GETSOCKOPT_PROG_PATH,
                                         cg_fd, BPF_CGROUP_GETSOCKOPT));
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_SETSOCKOPT_PROG_PATH,
@@ -146,7 +157,7 @@
         }
     }
 
-    if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
+    if (isAtLeastKernelVersion(4, 19, 0)) {
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND4_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET4_BIND));
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND6_PROG_PATH,
@@ -154,32 +165,32 @@
 
         // This should trivially pass, since we just attached up above,
         // but BPF_PROG_QUERY is only implemented on 4.19+ kernels.
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_EGRESS) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_INGRESS) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_CREATE) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_BIND) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
+        if (queryProgram(cg_fd, BPF_CGROUP_INET_EGRESS) <= 0) abort();
+        if (queryProgram(cg_fd, BPF_CGROUP_INET_INGRESS) <= 0) abort();
+        if (queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_CREATE) <= 0) abort();
+        if (queryProgram(cg_fd, BPF_CGROUP_INET4_BIND) <= 0) abort();
+        if (queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
     }
 
-    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
+    if (isAtLeastKernelVersion(5, 10, 0)) {
+        if (queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
     }
 
-    if (modules::sdklevel::IsAtLeastV()) {
+    if (isAtLeastV) {
         // V requires 4.19+, so technically this 2nd 'if' is not required, but it
         // doesn't hurt us to try to support AOSP forks that try to support older kernels.
-        if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_RECVMSG) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_RECVMSG) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_SENDMSG) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_SENDMSG) <= 0) abort();
+        if (isAtLeastKernelVersion(4, 19, 0)) {
+            if (queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_UDP4_RECVMSG) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_UDP6_RECVMSG) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_UDP4_SENDMSG) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_UDP6_SENDMSG) <= 0) abort();
         }
 
-        if (bpf::isAtLeastKernelVersion(5, 4, 0)) {
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_GETSOCKOPT) <= 0) abort();
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_SETSOCKOPT) <= 0) abort();
+        if (isAtLeastKernelVersion(5, 4, 0)) {
+            if (queryProgram(cg_fd, BPF_CGROUP_GETSOCKOPT) <= 0) abort();
+            if (queryProgram(cg_fd, BPF_CGROUP_SETSOCKOPT) <= 0) abort();
         }
     }
 
@@ -219,7 +230,7 @@
         // but there could be platform provided (xt_)bpf programs that oem/vendor
         // modified netd (which calls us during init) depends on...
         ALOGI("Waiting for platform BPF programs");
-        android::bpf::waitForProgsLoaded();
+        bpf::waitForProgsLoaded();
     }
 
     if (!mainlineNetBpfLoadDone()) {
@@ -251,12 +262,31 @@
     // ...unless someone changed 'exec_start bpfloader' to 'start bpfloader'
     // in the rc file.
     //
-    // TODO: should be: if (!modules::sdklevel::IsAtLeastW())
-    if (android_get_device_api_level() <= __ANDROID_API_V__) waitForBpf();
+    if (!isAtLeast25Q2) waitForBpf();
 
     RETURN_IF_NOT_OK(initPrograms(cg2_path));
     RETURN_IF_NOT_OK(initMaps());
 
+    if (isAtLeast25Q2) {
+        struct rlimit limit = {
+            .rlim_cur = 1u << 30,  // 1 GiB
+            .rlim_max = 1u << 30,  // 1 GiB
+        };
+        // 25Q2 netd.rc includes "rlimit memlock 1073741824 1073741824"
+        // so this should be a no-op, and thus just succeed.
+        // make sure it isn't lowered in platform netd.rc...
+        if (setrlimit(RLIMIT_MEMLOCK, &limit))
+            return statusFromErrno(errno, "Failed to set 1GiB RLIMIT_MEMLOCK");
+
+        // Make sure netd can create & write maps.  sepolicy is V+, but enough to enforce on 25Q2+
+        int key = 1;
+        int value = 123;
+        unique_fd map(bpf::createMap(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 2, 0));
+        if (!map.ok()) return statusFromErrno(errno, fmt::format("map create failed"));
+        int rv = bpf::writeToMapEntry(map, &key, &value, BPF_ANY);
+        if (rv) return statusFromErrno(errno, fmt::format("map write failed (rv={})", rv));
+    }
+
     return netdutils::status::ok;
 }
 
@@ -283,7 +313,7 @@
 
 Status BpfHandler::initMaps() {
     // bpfLock() requires bpfGetFdMapId which is only available on 4.14+ kernels.
-    if (bpf::isAtLeastKernelVersion(4, 14, 0)) {
+    if (isAtLeastKernelVersion(4, 14, 0)) {
         mapLockTest();
     }
 
@@ -293,7 +323,6 @@
     RETURN_IF_NOT_OK(mUidPermissionMap.init(UID_PERMISSION_MAP_PATH));
     // initialized last so mCookieTagMap.isValid() implies everything else is valid too
     RETURN_IF_NOT_OK(mCookieTagMap.init(COOKIE_TAG_MAP_PATH));
-    ALOGI("%s successfully", __func__);
 
     return netdutils::status::ok;
 }
@@ -322,8 +351,8 @@
     if (chargeUid == AID_CLAT) return -EPERM;
 
     // The socket destroy listener only monitors on the group {INET_TCP, INET_UDP, INET6_TCP,
-    // INET6_UDP}. Tagging listener unsupported socket causes that the tag can't be removed from
-    // tag map automatically. Eventually, the tag map may run out of space because of dead tag
+    // INET6_UDP}. Tagging listener unsupported sockets (on <5.10) means the tag cannot be
+    // removed from tag map automatically. Eventually, it may run out of space due to dead tag
     // entries. Note that although tagSocket() of net client has already denied the family which
     // is neither AF_INET nor AF_INET6, the family validation is still added here just in case.
     // See tagSocket in system/netd/client/NetdClient.cpp and
@@ -337,19 +366,23 @@
         return -errno;
     }
     if (socketFamily != AF_INET && socketFamily != AF_INET6) {
-        ALOGE("Unsupported family: %d", socketFamily);
+        ALOGV("Unsupported family: %d", socketFamily);
         return -EAFNOSUPPORT;
     }
 
-    int socketProto;
-    socklen_t protoLen = sizeof(socketProto);
-    if (getsockopt(sockFd, SOL_SOCKET, SO_PROTOCOL, &socketProto, &protoLen)) {
-        ALOGE("Failed to getsockopt SO_PROTOCOL: %s, fd: %d", strerror(errno), sockFd);
-        return -errno;
-    }
-    if (socketProto != IPPROTO_UDP && socketProto != IPPROTO_TCP) {
-        ALOGE("Unsupported protocol: %d", socketProto);
-        return -EPROTONOSUPPORT;
+    // On 5.10+ the BPF_CGROUP_INET_SOCK_RELEASE hook takes care of cookie tag map cleanup
+    // during socket destruction. As such the socket destroy listener is superfluous.
+    if (!isAtLeastKernelVersion(5, 10, 0)) {
+        int socketProto;
+        socklen_t protoLen = sizeof(socketProto);
+        if (getsockopt(sockFd, SOL_SOCKET, SO_PROTOCOL, &socketProto, &protoLen)) {
+            ALOGE("Failed to getsockopt SO_PROTOCOL: %s, fd: %d", strerror(errno), sockFd);
+            return -errno;
+        }
+        if (socketProto != IPPROTO_UDP && socketProto != IPPROTO_TCP) {
+            ALOGV("Unsupported protocol: %d", socketProto);
+            return -EPROTONOSUPPORT;
+        }
     }
 
     uint64_t sock_cookie = getSocketCookie(sockFd);
@@ -410,8 +443,8 @@
         ALOGE("Failed to tag the socket: %s", strerror(res.error().code()));
         return -res.error().code();
     }
-    ALOGD("Socket with cookie %" PRIu64 " tagged successfully with tag %" PRIu32 " uid %u "
-              "and real uid %u", sock_cookie, tag, chargeUid, realUid);
+    ALOGV("Socket with cookie %" PRIu64 " tagged successfully with tag %" PRIu32 " uid %u "
+          "and real uid %u", sock_cookie, tag, chargeUid, realUid);
     return 0;
 }
 
@@ -422,10 +455,11 @@
     if (!mCookieTagMap.isValid()) return -EPERM;
     base::Result<void> res = mCookieTagMap.deleteValue(sock_cookie);
     if (!res.ok()) {
-        ALOGE("Failed to untag socket: %s", strerror(res.error().code()));
-        return -res.error().code();
+        const int err = res.error().code();
+        if (err != ENOENT) ALOGE("Failed to untag socket: %s", strerror(err));
+        return -err;
     }
-    ALOGD("Socket with cookie %" PRIu64 " untagged successfully.", sock_cookie);
+    ALOGV("Socket with cookie %" PRIu64 " untagged successfully.", sock_cookie);
     return 0;
 }
 
diff --git a/bpf/netd/BpfHandlerTest.cpp b/bpf/netd/BpfHandlerTest.cpp
index b38fa16..4002b4c 100644
--- a/bpf/netd/BpfHandlerTest.cpp
+++ b/bpf/netd/BpfHandlerTest.cpp
@@ -191,7 +191,11 @@
     int rawSocket = socket(AF_INET, SOCK_RAW | SOCK_CLOEXEC, IPPROTO_RAW);
     EXPECT_LE(0, rawSocket);
     EXPECT_NE(NONEXISTENT_COOKIE, getSocketCookie(rawSocket));
-    EXPECT_EQ(-EPROTONOSUPPORT, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    if (isAtLeastKernelVersion(5, 10, 0)) {
+        EXPECT_EQ(0, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    } else {
+        EXPECT_EQ(-EPROTONOSUPPORT, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    }
 }
 
 TEST_F(BpfHandlerTest, TestTagSocketWithoutPermission) {
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index 20d194c..2bfe613 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -69,32 +69,16 @@
     sub_dir: "net_shared",
 }
 
-// Ships to Android S, the bpfloader of which fails to parse BTF enabled .o's.
 bpf {
     name: "offload.o",
     srcs: ["offload.c"],
-    btf: false,
+    sub_dir: "tethering",
 }
 
-// This version ships to Android T+ which uses mainline netbpfload.
-bpf {
-    name: "offload@mainline.o",
-    srcs: ["offload@mainline.c"],
-    cflags: ["-DMAINLINE"],
-}
-
-// Ships to Android S, the bpfloader of which fails to parse BTF enabled .o's.
 bpf {
     name: "test.o",
     srcs: ["test.c"],
-    btf: false,
-}
-
-// This version ships to Android T+ which uses mainline netbpfload.
-bpf {
-    name: "test@mainline.o",
-    srcs: ["test@mainline.c"],
-    cflags: ["-DMAINLINE"],
+    sub_dir: "tethering",
 }
 
 bpf {
diff --git a/bpf/progs/bpf_net_helpers.h b/bpf/progs/bpf_net_helpers.h
index a5664ba..4085ed4 100644
--- a/bpf/progs/bpf_net_helpers.h
+++ b/bpf/progs/bpf_net_helpers.h
@@ -84,6 +84,8 @@
 #define ETH_IP6_TCP_OFFSET(field) (ETH_HLEN + IP6_TCP_OFFSET(field))
 #define ETH_IP6_UDP_OFFSET(field) (ETH_HLEN + IP6_UDP_OFFSET(field))
 
+static uint64_t (*bpf_get_netns_cookie)(void* ctx) = (void*)BPF_FUNC_get_netns_cookie;
+
 // this returns 0 iff skb->sk is NULL
 static uint64_t (*bpf_get_socket_cookie)(struct __sk_buff* skb) = (void*)BPF_FUNC_get_socket_cookie;
 static uint64_t (*bpf_get_sk_cookie)(struct bpf_sock* sk) = (void*)BPF_FUNC_get_socket_cookie;
diff --git a/bpf/progs/clatd.c b/bpf/progs/clatd.c
index 2d4551e..f4e4f3b 100644
--- a/bpf/progs/clatd.c
+++ b/bpf/progs/clatd.c
@@ -288,6 +288,9 @@
     // We cannot handle IP options, just standard 20 byte == 5 dword minimal IPv4 header
     if (ip4->ihl != 5) return TC_ACT_PIPE;
 
+    // Packet must not be multicast
+    if ((ip4->daddr & htonl(0xf0000000)) == htonl(0xe0000000)) return TC_ACT_PIPE;
+
     // Calculate the IPv4 one's complement checksum of the IPv4 header.
     __wsum sum4 = 0;
     for (unsigned i = 0; i < sizeof(*ip4) / sizeof(__u16); ++i) {
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index ed0eed5..b146e45 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -25,10 +25,6 @@
 static const int PASS = 1;
 static const int DROP_UNLESS_DNS = 2;  // internal to our program
 
-// Used for 'bool enable_tracing'
-static const bool TRACE_ON = true;
-static const bool TRACE_OFF = false;
-
 // offsetof(struct iphdr, ihl) -- but that's a bitfield
 #define IPPROTO_IHL_OFF 0
 
@@ -46,14 +42,14 @@
     DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,         \
                        AID_ROOT, AID_NET_BW_ACCT, 0060, "fs_bpf_net_shared", "",   \
                        PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,              \
-                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG, 0)
 
 // For maps netd only needs read only access to
 #define DEFINE_BPF_MAP_RO_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries)  \
     DEFINE_BPF_MAP_EXT(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries,          \
                        AID_ROOT, AID_NET_BW_ACCT, 0460, "fs_bpf_netd_readonly", "", \
                        PRIVATE, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER,               \
-                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+                       LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG, 0)
 
 // For maps netd needs to be able to read and write
 #define DEFINE_BPF_MAP_RW_NETD(the_map, TYPE, TypeOfKey, TypeOfValue, num_entries) \
@@ -92,7 +88,7 @@
 DEFINE_BPF_MAP_EXT(packet_trace_enabled_map, ARRAY, uint32_t, bool, 1,
                    AID_ROOT, AID_SYSTEM, 0060, "fs_bpf_net_shared", "", PRIVATE,
                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG,
-                   LOAD_ON_USER, LOAD_ON_USERDEBUG)
+                   LOAD_ON_USER, LOAD_ON_USERDEBUG, 0)
 
 // A ring buffer on which packet information is pushed.
 DEFINE_BPF_RINGBUF_EXT(packet_trace_ringbuf, PacketTrace, PACKET_TRACE_BUF_SIZE,
@@ -103,6 +99,17 @@
 DEFINE_BPF_MAP_RO_NETD(data_saver_enabled_map, ARRAY, uint32_t, bool,
                        DATA_SAVER_ENABLED_MAP_SIZE)
 
+DEFINE_BPF_MAP_EXT(local_net_access_map, LPM_TRIE, LocalNetAccessKey, bool, 1000,
+                   AID_ROOT, AID_NET_BW_ACCT, 0060, "fs_bpf_net_shared", "", PRIVATE,
+                   BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG, LOAD_ON_USER,
+                   LOAD_ON_USERDEBUG, 0)
+
+// not preallocated
+DEFINE_BPF_MAP_EXT(local_net_blocked_uid_map, HASH, uint32_t, bool, -1000,
+                   AID_ROOT, AID_NET_BW_ACCT, 0060, "fs_bpf_net_shared", "", PRIVATE,
+                   BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER, LOAD_ON_ENG, LOAD_ON_USER,
+                   LOAD_ON_USERDEBUG, 0)
+
 // iptables xt_bpf programs need to be usable by both netd and netutils_wrappers
 // selinux contexts, because even non-xt_bpf iptables mutations are implemented as
 // a full table dump, followed by an update in userspace, and then a reload into the kernel,
@@ -110,31 +117,34 @@
 // program (see XT_BPF_MODE_PATH_PINNED) and then the iptables binary (or rather
 // the kernel acting on behalf of it) must be able to retrieve the pinned program
 // for the reload to succeed
-#define DEFINE_XTBPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
-    DEFINE_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog)
+#define DEFINE_XTBPF_PROG(SECTION_NAME, the_prog) \
+    DEFINE_BPF_PROG(SECTION_NAME, AID_ROOT, AID_NET_ADMIN, the_prog)
 
 // programs that need to be usable by netd, but not by netutils_wrappers
 // (this is because these are currently attached by the mainline provided libnetd_updatable .so
 // which is loaded into netd and thus runs as netd uid/gid/selinux context)
-#define DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, minKV, maxKV) \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog,                               \
-                        minKV, maxKV, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY,            \
+#define DEFINE_NETD_BPF_PROG_RANGES(SECTION_NAME, the_prog, minKV, maxKV, min_loader, max_loader) \
+    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog,                               \
+                        minKV, maxKV, min_loader, max_loader, MANDATORY,                          \
                         "fs_bpf_netd_readonly", "", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
-#define DEFINE_NETD_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv) \
-    DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, prog_uid, prog_gid, the_prog, min_kv, KVER_INF)
+#define DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, the_prog, minKV, maxKV) \
+    DEFINE_NETD_BPF_PROG_RANGES(SECTION_NAME, the_prog, minKV, maxKV, BPFLOADER_MIN_VER, BPFLOADER_MAX_VER)
 
-#define DEFINE_NETD_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
-    DEFINE_NETD_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, KVER_NONE)
+#define DEFINE_NETD_BPF_PROG_KVER(SECTION_NAME, the_prog, min_kv) \
+    DEFINE_NETD_BPF_PROG_KVER_RANGE(SECTION_NAME, the_prog, min_kv, KVER_INF)
 
-#define DEFINE_NETD_V_BPF_PROG_KVER(SECTION_NAME, prog_uid, prog_gid, the_prog, minKV)            \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog, minKV,                        \
+#define DEFINE_NETD_BPF_PROG(SECTION_NAME, the_prog) \
+    DEFINE_NETD_BPF_PROG_KVER(SECTION_NAME, the_prog, KVER_NONE)
+
+#define DEFINE_NETD_V_BPF_PROG_KVER(SECTION_NAME, the_prog, minKV)                                \
+    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog, minKV,                        \
                         KVER_INF, BPFLOADER_MAINLINE_V_VERSION, BPFLOADER_MAX_VER, MANDATORY,     \
                         "fs_bpf_netd_readonly", "", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
 // programs that only need to be usable by the system server
-#define DEFINE_SYS_BPF_PROG(SECTION_NAME, prog_uid, prog_gid, the_prog) \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, prog_uid, prog_gid, the_prog, KVER_NONE, KVER_INF,  \
+#define DEFINE_SYS_BPF_PROG(SECTION_NAME, the_prog) \
+    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_NET_ADMIN, the_prog, KVER_NONE, KVER_INF,  \
                         BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
                         "fs_bpf_net_shared", "", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
 
@@ -231,11 +241,70 @@
         : bpf_skb_load_bytes(skb, L3_off, to, len);
 }
 
+// False iff arguments are found with longest prefix match lookup and disallowed.
+static inline __always_inline bool is_local_net_access_allowed(const uint32_t if_index,
+        const struct in6_addr* remote_ip6, const uint16_t protocol, const __be16 remote_port) {
+    LocalNetAccessKey query_key = {
+        .lpm_bitlen = 8 * (sizeof(if_index) + sizeof(*remote_ip6) + sizeof(protocol)
+            + sizeof(remote_port)),
+        .if_index = if_index,
+        .remote_ip6 = *remote_ip6,
+        .protocol = protocol,
+        .remote_port = remote_port
+    };
+    bool* v = bpf_local_net_access_map_lookup_elem(&query_key);
+    return v ? *v : true;
+}
+
+static __always_inline inline bool should_block_local_network_packets(struct __sk_buff *skb,
+                                   const uint32_t uid, const struct egress_bool egress,
+                                   const struct kver_uint kver) {
+    if (is_system_uid(uid)) return false;
+
+    bool* block_local_net = bpf_local_net_blocked_uid_map_lookup_elem(&uid);
+    if (!block_local_net) return false; // uid not found in map
+    if (!*block_local_net) return false; // lookup returned 'bool false'
+
+    struct in6_addr remote_ip6;
+    uint8_t ip_proto;
+    uint8_t L4_off;
+    if (skb->protocol == htons(ETH_P_IP)) {
+        int remote_ip_ofs = egress.egress ? IP4_OFFSET(daddr) : IP4_OFFSET(saddr);
+        remote_ip6.s6_addr32[0] = 0;
+        remote_ip6.s6_addr32[1] = 0;
+        remote_ip6.s6_addr32[2] = htonl(0xFFFF);
+        (void)bpf_skb_load_bytes_net(skb, remote_ip_ofs, &remote_ip6.s6_addr32[3], 4, kver);
+        (void)bpf_skb_load_bytes_net(skb, IP4_OFFSET(protocol), &ip_proto, sizeof(ip_proto), kver);
+        uint8_t ihl;
+        (void)bpf_skb_load_bytes_net(skb, IPPROTO_IHL_OFF, &ihl, sizeof(ihl), kver);
+        L4_off = (ihl & 0x0F) * 4;  // IHL calculation.
+    } else if (skb->protocol == htons(ETH_P_IPV6)) {
+        int remote_ip_ofs = egress.egress ? IP6_OFFSET(daddr) : IP6_OFFSET(saddr);
+        (void)bpf_skb_load_bytes_net(skb, remote_ip_ofs, &remote_ip6, sizeof(remote_ip6), kver);
+        (void)bpf_skb_load_bytes_net(skb, IP6_OFFSET(nexthdr), &ip_proto, sizeof(ip_proto), kver);
+        L4_off = sizeof(struct ipv6hdr);
+    } else {
+        return false;
+    }
+
+    __be16 remote_port = 0;
+    switch (ip_proto) {
+      case IPPROTO_TCP:
+      case IPPROTO_DCCP:
+      case IPPROTO_UDP:
+      case IPPROTO_UDPLITE:
+      case IPPROTO_SCTP:
+        (void)bpf_skb_load_bytes_net(skb, L4_off + (egress.egress ? 2 : 0), &remote_port, sizeof(remote_port), kver);
+        break;
+    }
+
+    return !is_local_net_access_allowed(skb->ifindex, &remote_ip6, ip_proto, remote_port);
+}
+
 static __always_inline inline void do_packet_tracing(
         const struct __sk_buff* const skb, const struct egress_bool egress, const uint32_t uid,
-        const uint32_t tag, const bool enable_tracing, const struct kver_uint kver) {
-    if (!enable_tracing) return;
-    if (!KVER_IS_AT_LEAST(kver, 5, 8, 0)) return;
+        const uint32_t tag, const struct kver_uint kver) {
+    if (!KVER_IS_AT_LEAST(kver, 5, 10, 0)) return;
 
     uint32_t mapKey = 0;
     bool* traceConfig = bpf_packet_trace_enabled_map_lookup_elem(&mapKey);
@@ -393,7 +462,8 @@
 
 static __always_inline inline int bpf_owner_match(struct __sk_buff* skb, uint32_t uid,
                                                   const struct egress_bool egress,
-                                                  const struct kver_uint kver) {
+                                                  const struct kver_uint kver,
+                                                  const struct sdk_level_uint lvl) {
     if (is_system_uid(uid)) return PASS;
 
     if (skip_owner_match(skb, egress, kver)) return PASS;
@@ -423,6 +493,11 @@
             return DROP_UNLESS_DNS;
         }
     }
+
+    if (SDK_LEVEL_IS_AT_LEAST(lvl, 25Q2) && skb->ifindex == 1) {
+        // TODO: sdksandbox localhost restrictions
+    }
+
     return PASS;
 }
 
@@ -440,8 +515,8 @@
 
 static __always_inline inline int bpf_traffic_account(struct __sk_buff* skb,
                                                       const struct egress_bool egress,
-                                                      const bool enable_tracing,
-                                                      const struct kver_uint kver) {
+                                                      const struct kver_uint kver,
+                                                      const struct sdk_level_uint lvl) {
     // sock_uid will be 'overflowuid' if !sk_fullsock(sk_to_full_sk(skb->sk))
     uint32_t sock_uid = bpf_get_socket_uid(skb);
 
@@ -470,7 +545,7 @@
     // CLAT daemon receives via an untagged AF_PACKET socket.
     if (egress.egress && uid == AID_CLAT) return PASS;
 
-    int match = bpf_owner_match(skb, sock_uid, egress, kver);
+    int match = bpf_owner_match(skb, sock_uid, egress, kver, lvl);
 
 // Workaround for secureVPN with VpnIsolation enabled, refer to b/159994981 for details.
 // Keep TAG_SYSTEM_DNS in sync with DnsResolver/include/netd_resolv/resolv.h
@@ -483,6 +558,10 @@
         if (match == DROP_UNLESS_DNS) match = DROP;
     }
 
+    if (SDK_LEVEL_IS_AT_LEAST(lvl, 25Q2) && (match != DROP)) {
+        if (should_block_local_network_packets(skb, uid, egress, kver)) match = DROP;
+    }
+
     // If an outbound packet is going to be dropped, we do not count that traffic.
     if (egress.egress && (match == DROP)) return DROP;
 
@@ -496,7 +575,7 @@
 
     if (!selectedMap) return PASS;  // cannot happen, needed to keep bpf verifier happy
 
-    do_packet_tracing(skb, egress, uid, tag, enable_tracing, kver);
+    do_packet_tracing(skb, egress, uid, tag, kver);
     update_stats_with_config(*selectedMap, skb, &key, egress, kver);
     update_app_uid_stats_map(skb, &uid, egress, kver);
 
@@ -509,52 +588,104 @@
     return match;
 }
 
-// Tracing on Android U+ 5.8+
-DEFINE_BPF_PROG_EXT("cgroupskb/ingress/stats$trace", AID_ROOT, AID_SYSTEM,
-                    bpf_cgroup_ingress_trace, KVER_5_8, KVER_INF,
-                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, MANDATORY,
-                    "fs_bpf_netd_readonly", "",
-                    LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+// -----
+
+// Supported kernel + platform/os version combinations:
+//
+//      | 4.9 | 4.14 | 4.19 | 5.4 | 5.10 | 5.15 | 6.1 | 6.6 | 6.12 |
+// 25Q2 |     |      |      |  x  |  x   |  x   |  x  |  x  |  x   |
+//    V |     |      |  x   |  x  |  x   |  x   |  x  |  x  |      | (netbpfload)
+//    U |     |  x   |  x   |  x  |  x   |  x   |  x  |     |      |
+//    T |  x  |  x   |  x   |  x  |  x   |  x   |     |     |      | (magic netbpfload)
+//    S |  x  |  x   |  x   |  x  |  x   |      |     |     |      | (dns netbpfload for offload)
+//    R |  x  |  x   |  x   |  x  |      |      |     |     |      | (no mainline ebpf)
+//
+// Not relevant for eBPF, but R can also run on 4.4
+
+// ----- cgroupskb/ingress/stats -----
+
+// Android 25Q2+ 5.10+ (localnet protection + tracing)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/ingress/stats$5_10_25q2",
+                            bpf_cgroup_ingress_5_10_25q2, KVER_5_10, KVER_INF,
+                            BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, INGRESS, TRACE_ON, KVER_5_8);
+    return bpf_traffic_account(skb, INGRESS, KVER_5_10, SDK_LEVEL_25Q2);
 }
 
-DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_19", AID_ROOT, AID_SYSTEM,
+// Android 25Q2+ 5.4 (localnet protection)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/ingress/stats$5_4_25q2",
+                            bpf_cgroup_ingress_5_4_25q2, KVER_5_4, KVER_5_10,
+                            BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, INGRESS, KVER_5_4, SDK_LEVEL_25Q2);
+}
+
+// Android U/V 5.10+ (tracing)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/ingress/stats$5_10_u",
+                            bpf_cgroup_ingress_5_10_u, KVER_5_10, KVER_INF,
+                            BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAINLINE_25Q2_VERSION)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, INGRESS, KVER_5_10, SDK_LEVEL_U);
+}
+
+// Android T/U/V 4.19 & T/U/V/25Q2 5.4 & T 5.10/5.15
+DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_19",
                                 bpf_cgroup_ingress_4_19, KVER_4_19, KVER_INF)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, INGRESS, TRACE_OFF, KVER_4_19);
+    return bpf_traffic_account(skb, INGRESS, KVER_4_19, SDK_LEVEL_T);
 }
 
-DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_14", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_ingress_4_14, KVER_NONE, KVER_4_19)
+// Android T 4.9 & T/U 4.14
+DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/ingress/stats$4_9",
+                                bpf_cgroup_ingress_4_9, KVER_NONE, KVER_4_19)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, INGRESS, TRACE_OFF, KVER_NONE);
+    return bpf_traffic_account(skb, INGRESS, KVER_NONE, SDK_LEVEL_T);
 }
 
-// Tracing on Android U+ 5.8+
-DEFINE_BPF_PROG_EXT("cgroupskb/egress/stats$trace", AID_ROOT, AID_SYSTEM,
-                    bpf_cgroup_egress_trace, KVER_5_8, KVER_INF,
-                    BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAX_VER, MANDATORY,
-                    "fs_bpf_netd_readonly", "",
-                    LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
+// ----- cgroupskb/egress/stats -----
+
+// Android 25Q2+ 5.10+ (localnet protection + tracing)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/egress/stats$5_10_25q2",
+                            bpf_cgroup_egress_5_10_25q2, KVER_5_10, KVER_INF,
+                            BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, EGRESS, TRACE_ON, KVER_5_8);
+    return bpf_traffic_account(skb, EGRESS, KVER_5_10, SDK_LEVEL_25Q2);
 }
 
-DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_19", AID_ROOT, AID_SYSTEM,
+// Android 25Q2+ 5.4 (localnet protection)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/egress/stats$5_4_25q2",
+                            bpf_cgroup_egress_5_4_25q2, KVER_5_4, KVER_5_10,
+                            BPFLOADER_MAINLINE_25Q2_VERSION, BPFLOADER_MAX_VER)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, EGRESS, KVER_5_4, SDK_LEVEL_25Q2);
+}
+
+// Android U/V 5.10+ (tracing)
+DEFINE_NETD_BPF_PROG_RANGES("cgroupskb/egress/stats$5_10_u",
+                            bpf_cgroup_egress_5_10_u, KVER_5_10, KVER_INF,
+                            BPFLOADER_MAINLINE_U_VERSION, BPFLOADER_MAINLINE_25Q2_VERSION)
+(struct __sk_buff* skb) {
+    return bpf_traffic_account(skb, EGRESS, KVER_5_10, SDK_LEVEL_U);
+}
+
+// Android T/U/V 4.19 & T/U/V/25Q2 5.4 & T 5.10/5.15
+DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_19",
                                 bpf_cgroup_egress_4_19, KVER_4_19, KVER_INF)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, EGRESS, TRACE_OFF, KVER_4_19);
+    return bpf_traffic_account(skb, EGRESS, KVER_4_19, SDK_LEVEL_T);
 }
 
-DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_14", AID_ROOT, AID_SYSTEM,
-                                bpf_cgroup_egress_4_14, KVER_NONE, KVER_4_19)
+// Android T 4.9 & T/U 4.14
+DEFINE_NETD_BPF_PROG_KVER_RANGE("cgroupskb/egress/stats$4_9",
+                                bpf_cgroup_egress_4_9, KVER_NONE, KVER_4_19)
 (struct __sk_buff* skb) {
-    return bpf_traffic_account(skb, EGRESS, TRACE_OFF, KVER_NONE);
+    return bpf_traffic_account(skb, EGRESS, KVER_NONE, SDK_LEVEL_T);
 }
 
+// -----
+
 // WARNING: Android T's non-updatable netd depends on the name of this program.
-DEFINE_XTBPF_PROG("skfilter/egress/xtbpf", AID_ROOT, AID_NET_ADMIN, xt_bpf_egress_prog)
+DEFINE_XTBPF_PROG("skfilter/egress/xtbpf", xt_bpf_egress_prog)
 (struct __sk_buff* skb) {
     // Clat daemon does not generate new traffic, all its traffic is accounted for already
     // on the v4-* interfaces (except for the 20 (or 28) extra bytes of IPv6 vs IPv4 overhead,
@@ -573,7 +704,7 @@
 }
 
 // WARNING: Android T's non-updatable netd depends on the name of this program.
-DEFINE_XTBPF_PROG("skfilter/ingress/xtbpf", AID_ROOT, AID_NET_ADMIN, xt_bpf_ingress_prog)
+DEFINE_XTBPF_PROG("skfilter/ingress/xtbpf", xt_bpf_ingress_prog)
 (struct __sk_buff* skb) {
     // Clat daemon traffic is not accounted by virtue of iptables raw prerouting drop rule
     // (in clat_raw_PREROUTING chain), which triggers before this (in bw_raw_PREROUTING chain).
@@ -585,7 +716,7 @@
     return XTBPF_MATCH;
 }
 
-DEFINE_SYS_BPF_PROG("schedact/ingress/account", AID_ROOT, AID_NET_ADMIN,
+DEFINE_SYS_BPF_PROG("schedact/ingress/account",
                     tc_bpf_ingress_account_prog)
 (struct __sk_buff* skb) {
     if (is_received_skb(skb)) {
@@ -597,7 +728,7 @@
 }
 
 // WARNING: Android T's non-updatable netd depends on the name of this program.
-DEFINE_XTBPF_PROG("skfilter/allowlist/xtbpf", AID_ROOT, AID_NET_ADMIN, xt_bpf_allowlist_prog)
+DEFINE_XTBPF_PROG("skfilter/allowlist/xtbpf", xt_bpf_allowlist_prog)
 (struct __sk_buff* skb) {
     uint32_t sock_uid = bpf_get_socket_uid(skb);
     if (is_system_uid(sock_uid)) return XTBPF_MATCH;
@@ -616,7 +747,7 @@
 }
 
 // WARNING: Android T's non-updatable netd depends on the name of this program.
-DEFINE_XTBPF_PROG("skfilter/denylist/xtbpf", AID_ROOT, AID_NET_ADMIN, xt_bpf_denylist_prog)
+DEFINE_XTBPF_PROG("skfilter/denylist/xtbpf", xt_bpf_denylist_prog)
 (struct __sk_buff* skb) {
     uint32_t sock_uid = bpf_get_socket_uid(skb);
     UidOwnerValue* denylistMatch = bpf_uid_owner_map_lookup_elem(&sock_uid);
@@ -639,14 +770,12 @@
     return permissions ? *permissions : BPF_PERMISSION_INTERNET;
 }
 
-DEFINE_NETD_BPF_PROG_KVER("cgroupsock/inet_create", AID_ROOT, AID_ROOT, inet_socket_create,
-                          KVER_4_14)
+DEFINE_NETD_BPF_PROG_KVER("cgroupsock/inet_create", inet_socket_create, KVER_4_14)
 (__unused struct bpf_sock* sk) {
     return (get_app_permissions() & BPF_PERMISSION_INTERNET) ? BPF_ALLOW : BPF_DISALLOW;
 }
 
-DEFINE_NETD_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
-                          inet_socket_release, KVER_5_10)
+DEFINE_NETD_BPF_PROG_KVER("cgroupsockrelease/inet_release", inet_socket_release, KVER_5_10)
 (struct bpf_sock* sk) {
     uint64_t cookie = bpf_get_sk_cookie(sk);
     if (cookie) bpf_cookie_tag_map_delete_elem(&cookie);
@@ -699,47 +828,47 @@
     return BPF_ALLOW;
 }
 
-DEFINE_NETD_BPF_PROG_KVER("bind4/inet4_bind", AID_ROOT, AID_ROOT, inet4_bind, KVER_4_19)
+DEFINE_NETD_BPF_PROG_KVER("bind4/inet4_bind", inet4_bind, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return block_port(ctx);
 }
 
-DEFINE_NETD_BPF_PROG_KVER("bind6/inet6_bind", AID_ROOT, AID_ROOT, inet6_bind, KVER_4_19)
+DEFINE_NETD_BPF_PROG_KVER("bind6/inet6_bind", inet6_bind, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return block_port(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", inet4_connect, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect6/inet6_connect", AID_ROOT, AID_ROOT, inet6_connect, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("connect6/inet6_connect", inet6_connect, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("recvmsg4/udp4_recvmsg", AID_ROOT, AID_ROOT, udp4_recvmsg, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg4/udp4_recvmsg", udp4_recvmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("recvmsg6/udp6_recvmsg", AID_ROOT, AID_ROOT, udp6_recvmsg, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg6/udp6_recvmsg", udp6_recvmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("sendmsg4/udp4_sendmsg", AID_ROOT, AID_ROOT, udp4_sendmsg, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg4/udp4_sendmsg", udp4_sendmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("sendmsg6/udp6_sendmsg", AID_ROOT, AID_ROOT, udp6_sendmsg, KVER_4_19)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg6/udp6_sendmsg", udp6_sendmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("getsockopt/prog", AID_ROOT, AID_ROOT, getsockopt_prog, KVER_5_4)
+DEFINE_NETD_V_BPF_PROG_KVER("getsockopt/prog", getsockopt_prog, KVER_5_4)
 (struct bpf_sockopt *ctx) {
     // Tell kernel to return 'original' kernel reply (instead of the bpf modified buffer)
     // This is important if the answer is larger than PAGE_SIZE (max size this bpf hook can provide)
@@ -747,7 +876,7 @@
     return BPF_ALLOW;
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("setsockopt/prog", AID_ROOT, AID_ROOT, setsockopt_prog, KVER_5_4)
+DEFINE_NETD_V_BPF_PROG_KVER("setsockopt/prog", setsockopt_prog, KVER_5_4)
 (struct bpf_sockopt *ctx) {
     // Tell kernel to use/process original buffer provided by userspace.
     // This is important if it is larger than PAGE_SIZE (max size this bpf hook can handle).
diff --git a/bpf/progs/netd.h b/bpf/progs/netd.h
index be7c311..8400679 100644
--- a/bpf/progs/netd.h
+++ b/bpf/progs/netd.h
@@ -185,6 +185,8 @@
 #define PACKET_TRACE_RINGBUF_PATH BPF_NETD_PATH "map_netd_packet_trace_ringbuf"
 #define PACKET_TRACE_ENABLED_MAP_PATH BPF_NETD_PATH "map_netd_packet_trace_enabled_map"
 #define DATA_SAVER_ENABLED_MAP_PATH BPF_NETD_PATH "map_netd_data_saver_enabled_map"
+#define LOCAL_NET_ACCESS_MAP_PATH BPF_NETD_PATH "map_netd_local_net_access_map"
+#define LOCAL_NET_BLOCKED_UID_MAP_PATH BPF_NETD_PATH "map_netd_local_net_blocked_uid_map"
 
 #endif // __cplusplus
 
@@ -245,6 +247,18 @@
 } IngressDiscardValue;
 STRUCT_SIZE(IngressDiscardValue, 2 * 4);  // 8
 
+typedef struct {
+  // Longest prefix match length in bits (value from 0 to 192).
+  uint32_t lpm_bitlen;
+  uint32_t if_index;
+  // IPv4 uses IPv4-mapped IPv6 address format.
+  struct in6_addr remote_ip6;
+  // u16 instead of u8 to avoid padding due to alignment requirement.
+  uint16_t protocol;
+  __be16 remote_port;
+} LocalNetAccessKey;
+STRUCT_SIZE(LocalNetAccessKey, 4 + 4 + 16 + 2 + 2);  // 28
+
 // Entry in the configuration map that stores which UID rules are enabled.
 #define UID_RULES_CONFIGURATION_KEY 0
 // Entry in the configuration map that stores which stats map is currently in use.
diff --git a/bpf/progs/offload.c b/bpf/progs/offload.c
index 631908a..b34fe6f 100644
--- a/bpf/progs/offload.c
+++ b/bpf/progs/offload.c
@@ -14,16 +14,8 @@
  * limitations under the License.
  */
 
-#ifdef MAINLINE
-// BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
-// ship a different file than for later versions, but we need bpfloader v0.25+
-// for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-#else /* MAINLINE */
-// The resulting .o needs to load on the Android S bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
-#endif /* MAINLINE */
+// The resulting .o needs to load on Android S+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_S_VERSION
 
 #include "bpf_net_helpers.h"
 #include "offload.h"
@@ -609,27 +601,27 @@
 // Full featured (required) implementations for 5.8+ kernels (these are S+ by definition)
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_downstream4_rawip$5_8", AID_ROOT, AID_NETWORK_STACK,
-                     sched_cls_tether_downstream4_rawip_5_8, KVER_5_8)
+                     sched_cls_tether_downstream4_rawip_5_8, KVER_5_10)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER_5_8);
+    return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER_5_10);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_upstream4_rawip$5_8", AID_ROOT, AID_NETWORK_STACK,
-                     sched_cls_tether_upstream4_rawip_5_8, KVER_5_8)
+                     sched_cls_tether_upstream4_rawip_5_8, KVER_5_10)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER_5_8);
+    return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER_5_10);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_downstream4_ether$5_8", AID_ROOT, AID_NETWORK_STACK,
-                     sched_cls_tether_downstream4_ether_5_8, KVER_5_8)
+                     sched_cls_tether_downstream4_ether_5_8, KVER_5_10)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER_5_8);
+    return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER_5_10);
 }
 
 DEFINE_BPF_PROG_KVER("schedcls/tether_upstream4_ether$5_8", AID_ROOT, AID_NETWORK_STACK,
-                     sched_cls_tether_upstream4_ether_5_8, KVER_5_8)
+                     sched_cls_tether_upstream4_ether_5_8, KVER_5_10)
 (struct __sk_buff* skb) {
-    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER_5_8);
+    return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER_5_10);
 }
 
 // Full featured (optional) implementations for 4.14-S, 4.19-S & 5.4-S kernels
@@ -638,7 +630,7 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$opt",
                                     AID_ROOT, AID_NETWORK_STACK,
                                     sched_cls_tether_downstream4_rawip_opt,
-                                    KVER_4_14, KVER_5_8)
+                                    KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, RAWIP, DOWNSTREAM, UPDATETIME, KVER_4_14);
 }
@@ -646,7 +638,7 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$opt",
                                     AID_ROOT, AID_NETWORK_STACK,
                                     sched_cls_tether_upstream4_rawip_opt,
-                                    KVER_4_14, KVER_5_8)
+                                    KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, RAWIP, UPSTREAM, UPDATETIME, KVER_4_14);
 }
@@ -654,7 +646,7 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_ether$opt",
                                     AID_ROOT, AID_NETWORK_STACK,
                                     sched_cls_tether_downstream4_ether_opt,
-                                    KVER_4_14, KVER_5_8)
+                                    KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, ETHER, DOWNSTREAM, UPDATETIME, KVER_4_14);
 }
@@ -662,7 +654,7 @@
 DEFINE_OPTIONAL_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_ether$opt",
                                     AID_ROOT, AID_NETWORK_STACK,
                                     sched_cls_tether_upstream4_ether_opt,
-                                    KVER_4_14, KVER_5_8)
+                                    KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, ETHER, UPSTREAM, UPDATETIME, KVER_4_14);
 }
@@ -682,13 +674,13 @@
 // RAWIP: Required for 5.4-R kernels -- which always support bpf_skb_change_head().
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_rawip$5_4", AID_ROOT, AID_NETWORK_STACK,
-                           sched_cls_tether_downstream4_rawip_5_4, KVER_5_4, KVER_5_8)
+                           sched_cls_tether_downstream4_rawip_5_4, KVER_5_4, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, RAWIP, DOWNSTREAM, NO_UPDATETIME, KVER_5_4);
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_rawip$5_4", AID_ROOT, AID_NETWORK_STACK,
-                           sched_cls_tether_upstream4_rawip_5_4, KVER_5_4, KVER_5_8)
+                           sched_cls_tether_upstream4_rawip_5_4, KVER_5_4, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, RAWIP, UPSTREAM, NO_UPDATETIME, KVER_5_4);
 }
@@ -715,13 +707,13 @@
 // ETHER: Required for 4.14-Q/R, 4.19-Q/R & 5.4-R kernels.
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_downstream4_ether$4_14", AID_ROOT, AID_NETWORK_STACK,
-                           sched_cls_tether_downstream4_ether_4_14, KVER_4_14, KVER_5_8)
+                           sched_cls_tether_downstream4_ether_4_14, KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, ETHER, DOWNSTREAM, NO_UPDATETIME, KVER_4_14);
 }
 
 DEFINE_BPF_PROG_KVER_RANGE("schedcls/tether_upstream4_ether$4_14", AID_ROOT, AID_NETWORK_STACK,
-                           sched_cls_tether_upstream4_ether_4_14, KVER_4_14, KVER_5_8)
+                           sched_cls_tether_upstream4_ether_4_14, KVER_4_14, KVER_5_10)
 (struct __sk_buff* skb) {
     return do_forward4(skb, ETHER, UPSTREAM, NO_UPDATETIME, KVER_4_14);
 }
@@ -805,7 +797,7 @@
 }
 
 #define DEFINE_XDP_PROG(str, func) \
-    DEFINE_BPF_PROG_KVER(str, AID_ROOT, AID_NETWORK_STACK, func, KVER_5_9)(struct xdp_md *ctx)
+    DEFINE_BPF_PROG_KVER(str, AID_ROOT, AID_NETWORK_STACK, func, KVER_5_10)(struct xdp_md *ctx)
 
 DEFINE_XDP_PROG("xdp/tether_downstream_ether",
                  xdp_tether_downstream_ether) {
diff --git a/bpf/progs/test.c b/bpf/progs/test.c
index 8585118..4dba6b9 100644
--- a/bpf/progs/test.c
+++ b/bpf/progs/test.c
@@ -14,16 +14,8 @@
  * limitations under the License.
  */
 
-#ifdef MAINLINE
-// BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
-// ship a different file than for later versions, but we need bpfloader v0.25+
-// for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-#else /* MAINLINE */
-// The resulting .o needs to load on the Android S bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
-#endif /* MAINLINE */
+// The resulting .o needs to load on Android S+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_S_VERSION
 
 // This is non production code, only used for testing
 // Needed because the bitmap array definition is non-kosher for pre-T OS devices.
diff --git a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
index a31445a..1d72b77 100644
--- a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
+++ b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
@@ -269,7 +269,7 @@
     return info.FIELD; \
 }
 
-// All 7 of these fields are already present in Linux v4.14 (even ACK 4.14-P)
+// All 9 of these fields are already present in Linux v4.14 (even ACK 4.14-P)
 // while BPF_OBJ_GET_INFO_BY_FD is not implemented at all in v4.9 (even ACK 4.9-Q)
 DEFINE_BPF_GET_FD(map, MapType, type)            // int bpfGetFdMapType(const borrowed_fd& map_fd)
 DEFINE_BPF_GET_FD(map, MapId, id)                // int bpfGetFdMapId(const borrowed_fd& map_fd)
@@ -278,6 +278,8 @@
 DEFINE_BPF_GET_FD(map, MaxEntries, max_entries)  // int bpfGetFdMaxEntries(const borrowed_fd& map_fd)
 DEFINE_BPF_GET_FD(map, MapFlags, map_flags)      // int bpfGetFdMapFlags(const borrowed_fd& map_fd)
 DEFINE_BPF_GET_FD(prog, ProgId, id)              // int bpfGetFdProgId(const borrowed_fd& prog_fd)
+DEFINE_BPF_GET_FD(prog, JitProgLen, jited_prog_len)   // int bpfGetFdJitProgLen(...)
+DEFINE_BPF_GET_FD(prog, XlatProgLen, xlated_prog_len) // int bpfGetFdXlatProgLen(...)
 
 #undef DEFINE_BPF_GET_FD
 
diff --git a/bpf/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
index 0b5b7be..4d5f9b5 100644
--- a/bpf/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -20,7 +20,8 @@
 #include <set>
 #include <string>
 
-#include <android-modules-utils/sdk_level.h>
+#include <android-base/properties.h>
+#include <android/api-level.h>
 #include <bpf/BpfUtils.h>
 
 #include <gtest/gtest.h>
@@ -30,11 +31,12 @@
 using std::string;
 
 using android::bpf::isAtLeastKernelVersion;
-using android::modules::sdklevel::IsAtLeastR;
-using android::modules::sdklevel::IsAtLeastS;
-using android::modules::sdklevel::IsAtLeastT;
-using android::modules::sdklevel::IsAtLeastU;
-using android::modules::sdklevel::IsAtLeastV;
+using android::bpf::isAtLeastR;
+using android::bpf::isAtLeastS;
+using android::bpf::isAtLeastT;
+using android::bpf::isAtLeastU;
+using android::bpf::isAtLeastV;
+using android::bpf::isAtLeast25Q2;
 
 #define PLATFORM "/sys/fs/bpf/"
 #define TETHERING "/sys/fs/bpf/tethering/"
@@ -159,6 +161,12 @@
     NETD "prog_netd_setsockopt_prog",
 };
 
+// Provided by *current* mainline module for 25Q2+ devices
+static const set<string> MAINLINE_FOR_25Q2_PLUS = {
+    NETD "map_netd_local_net_access_map",
+    NETD "map_netd_local_net_blocked_uid_map",
+};
+
 static void addAll(set<string>& a, const set<string>& b) {
     a.insert(b.begin(), b.end());
 }
@@ -181,33 +189,41 @@
     // and for the presence of mainline stuff.
 
     // Note: Q is no longer supported by mainline
-    ASSERT_TRUE(IsAtLeastR());
+    ASSERT_TRUE(isAtLeastR);
 
     // R can potentially run on pre-4.9 kernel non-eBPF capable devices.
-    DO_EXPECT(IsAtLeastR() && !IsAtLeastS() && isAtLeastKernelVersion(4, 9, 0), PLATFORM_ONLY_IN_R);
+    DO_EXPECT(isAtLeastR && !isAtLeastS && isAtLeastKernelVersion(4, 9, 0), PLATFORM_ONLY_IN_R);
 
     // 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);
+    if (isAtLeastS) ASSERT_TRUE(isAtLeastKernelVersion(4, 9, 0));
+
+    // on S without a new enough DnsResolver apex, NetBpfLoad doesn't get triggered,
+    // and thus no mainline programs get loaded.
+    bool mainlineBpfCapableResolve = !access("/apex/com.android.resolv/NetBpfLoad-S.flag", F_OK);
+    bool mainlineNetBpfLoad = isAtLeastT || mainlineBpfCapableResolve;
+    DO_EXPECT(isAtLeastS && mainlineNetBpfLoad, MAINLINE_FOR_S_PLUS);
 
     // Nothing added or removed in SCv2.
 
     // T still only requires Linux Kernel 4.9+.
-    DO_EXPECT(IsAtLeastT(), MAINLINE_FOR_T_PLUS);
-    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
-    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
-    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_T_5_10_PLUS);
-    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
+    DO_EXPECT(isAtLeastT, MAINLINE_FOR_T_PLUS);
+    DO_EXPECT(isAtLeastT && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
+    DO_EXPECT(isAtLeastT && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
+    DO_EXPECT(isAtLeastT && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_T_5_10_PLUS);
+    DO_EXPECT(isAtLeastT && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
 
     // U requires Linux Kernel 4.14+, but nothing (as yet) added or removed in U.
-    if (IsAtLeastU()) ASSERT_TRUE(isAtLeastKernelVersion(4, 14, 0));
-    DO_EXPECT(IsAtLeastU(), MAINLINE_FOR_U_PLUS);
-    DO_EXPECT(IsAtLeastU() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_U_5_10_PLUS);
+    if (isAtLeastU) ASSERT_TRUE(isAtLeastKernelVersion(4, 14, 0));
+    DO_EXPECT(isAtLeastU, MAINLINE_FOR_U_PLUS);
+    DO_EXPECT(isAtLeastU && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_U_5_10_PLUS);
 
     // V requires Linux Kernel 4.19+, but nothing (as yet) added or removed in V.
-    if (IsAtLeastV()) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
-    DO_EXPECT(IsAtLeastV(), MAINLINE_FOR_V_PLUS);
-    DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_V_5_4_PLUS);
+    if (isAtLeastV) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
+    DO_EXPECT(isAtLeastV, MAINLINE_FOR_V_PLUS);
+    DO_EXPECT(isAtLeastV && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_V_5_4_PLUS);
+
+    if (isAtLeast25Q2) ASSERT_TRUE(isAtLeastKernelVersion(5, 4, 0));
+    DO_EXPECT(isAtLeast25Q2, MAINLINE_FOR_25Q2_PLUS);
 
     for (const auto& file : mustExist) {
         EXPECT_EQ(0, access(file.c_str(), R_OK)) << file << " does not exist";
diff --git a/clatd/clatd.c b/clatd/clatd.c
index bac8b1d..9b1d987 100644
--- a/clatd/clatd.c
+++ b/clatd/clatd.c
@@ -49,7 +49,8 @@
 
 struct clat_config Global_Clatd_Config;
 
-volatile sig_atomic_t running = 1;
+volatile sig_atomic_t sigterm = 0;
+bool running = true;
 
 // reads IPv6 packet from AF_PACKET socket, translates to IPv4, writes to tun
 void process_packet_6_to_4(struct tun_data *tunnel) {
@@ -78,10 +79,11 @@
     if (errno != EAGAIN) {
       logmsg(ANDROID_LOG_WARN, "%s: read error: %s", __func__, strerror(errno));
     }
+    if (errno == ENETDOWN) running = false;
     return;
   } else if (readlen == 0) {
     logmsg(ANDROID_LOG_WARN, "%s: packet socket removed?", __func__);
-    running = 0;
+    running = false;
     return;
   } else if (readlen >= sizeof(buf)) {
     logmsg(ANDROID_LOG_WARN, "%s: read truncation - ignoring pkt", __func__);
@@ -161,10 +163,11 @@
     if (errno != EAGAIN) {
       logmsg(ANDROID_LOG_WARN, "%s: read error: %s", __func__, strerror(errno));
     }
+    if (errno == ENETDOWN) running = false;  // not sure if this can happen
     return;
   } else if (readlen == 0) {
     logmsg(ANDROID_LOG_WARN, "%s: tun interface removed", __func__);
-    running = 0;
+    running = false;
     return;
   } else if (readlen >= sizeof(buf)) {
     logmsg(ANDROID_LOG_WARN, "%s: read truncation - ignoring pkt", __func__);
@@ -273,23 +276,12 @@
  *   tunnel - tun device data
  */
 void event_loop(struct tun_data *tunnel) {
-  // Apparently some network gear will refuse to perform NS for IPs that aren't DAD'ed,
-  // this would then result in an ipv6-only network with working native ipv6, working
-  // IPv4 via DNS64, but non-functioning IPv4 via CLAT (ie. IPv4 literals + IPv4 only apps).
-  // The kernel itself doesn't do DAD for anycast ips (but does handle IPV6 MLD and handle ND).
-  // So we'll spoof dad here, and yeah, we really should check for a response and in
-  // case of failure pick a different IP.  Seeing as 48-bits of the IP are utterly random
-  // (with the other 16 chosen to guarantee checksum neutrality) this seems like a remote
-  // concern...
-  // TODO: actually perform true DAD
-  send_dad(tunnel->write_fd6, &Global_Clatd_Config.ipv6_local_subnet);
-
   struct pollfd wait_fd[] = {
     { tunnel->read_fd6, POLLIN, 0 },
     { tunnel->fd4, POLLIN, 0 },
   };
 
-  while (running) {
+  while (running && !sigterm) {
     if (poll(wait_fd, ARRAY_SIZE(wait_fd), -1) == -1) {
       if (errno != EINTR) {
         logmsg(ANDROID_LOG_WARN, "event_loop/poll returned an error: %s", strerror(errno));
diff --git a/clatd/clatd.h b/clatd/clatd.h
index e170c58..11e9687 100644
--- a/clatd/clatd.h
+++ b/clatd/clatd.h
@@ -48,11 +48,13 @@
 // plus some extra just-in-case headroom, because it doesn't hurt.
 #define MAXDUMPLEN (64 + MAXMTU)
 
-#define CLATD_VERSION "1.7"
+#define CLATD_VERSION "1.8"
 
 #define ARRAY_SIZE(x) (sizeof(x) / sizeof((x)[0]))
 
-extern volatile sig_atomic_t running;
+extern volatile sig_atomic_t sigterm;
+
+void send_dad(int fd, const struct in6_addr* tgt);
 
 void event_loop(struct tun_data *tunnel);
 
diff --git a/clatd/ipv4.c b/clatd/ipv4.c
index 2be02e3..d7ac0d1 100644
--- a/clatd/ipv4.c
+++ b/clatd/ipv4.c
@@ -85,6 +85,11 @@
     return 0;
   }
 
+  if ((header->daddr & htonl(0xf0000000)) == htonl(0xe0000000)) {
+    logmsg_dbg(ANDROID_LOG_INFO, "ip_packet/daddr is multicast: %x", ntohl(header->daddr));
+    return 0;
+  }
+
   /* rfc6145 - If any IPv4 options are present in the IPv4 packet, they MUST be
    * ignored and the packet translated normally; there is no attempt to
    * translate the options.
diff --git a/clatd/main.c b/clatd/main.c
index f888041..9e5710e 100644
--- a/clatd/main.c
+++ b/clatd/main.c
@@ -18,12 +18,17 @@
 
 #include <arpa/inet.h>
 #include <errno.h>
+#include <linux/audit.h>
+#include <linux/filter.h>
+#include <linux/seccomp.h>
+#include <linux/unistd.h>
 #include <netinet/in.h>
 #include <stdbool.h>
 #include <stdint.h>
 #include <stdlib.h>
 #include <string.h>
 #include <sys/personality.h>
+#include <sys/prctl.h>
 #include <sys/utsname.h>
 #include <unistd.h>
 
@@ -34,10 +39,10 @@
 
 #define DEVICEPREFIX "v4-"
 
-/* function: stop_loop
+/* function: handle_sigterm
  * signal handler: stop the event loop
  */
-static void stop_loop() { running = 0; };
+static void handle_sigterm(__attribute__((unused)) int unused) { sigterm = 1; };
 
 /* function: print_help
  * in case the user is running this on the command line
@@ -53,6 +58,131 @@
   printf("-w [write socket descriptor number]\n");
 }
 
+// Load the architecture identifier (AUDIT_ARCH_* constant)
+#define BPF_SECCOMP_LOAD_AUDIT_ARCH \
+	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch))
+
+// Load the system call number
+#define BPF_SECCOMP_LOAD_SYSCALL_NR \
+	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr))
+
+#if __BYTE_ORDER == __LITTLE_ENDIAN
+// Load the system call argument n, where n is [0..5]
+#define BPF_SECCOMP_LOAD_SYSCALL_ARG_LO32(n) \
+	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[n]))
+#define BPF_SECCOMP_LOAD_SYSCALL_ARG_HI32(n) \
+	BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[n]) + 4)
+#else
+#error "Not a little endian architecture?"
+#endif
+
+// Allow the system call
+#define BPF_SECCOMP_ALLOW BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW)
+
+// Allow (but 'audit' log) the system call
+#define BPF_SECCOMP_LOG BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_LOG)
+
+// Reject the system call (kill thread)
+#define BPF_SECCOMP_KILL BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL)
+
+// Note arguments to BPF_JUMP(opcode, operand, true_offset, false_offset)
+
+// If not equal, jump over count instructions
+#define BPF_JUMP_IF_NOT_EQUAL(v, count) \
+	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (v), 0, (count))
+
+// If equal, jump over count instructions
+#define BPF_JUMP_IF_EQUAL(v, count) \
+	BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (v), (count), 0)
+
+// *TWO* instructions: compare and if not equal jump over the allow statement
+#define BPF2_SECCOMP_ALLOW_IF_EQUAL(v) \
+	BPF_JUMP_IF_NOT_EQUAL((v), 1), \
+	BPF_SECCOMP_ALLOW
+
+// *TWO* instructions: compare and if not equal jump over the log statement
+#define BPF2_SECCOMP_LOG_IF_EQUAL(v) \
+	BPF_JUMP_IF_NOT_EQUAL((v), 1), \
+	BPF_SECCOMP_LOG
+
+// *TWO* instructions: compare and if equal jump over the kill statement
+#define BPF2_SECCOMP_KILL_IF_NOT_EQUAL(v) \
+	BPF_JUMP_IF_EQUAL((v), 1), \
+	BPF_SECCOMP_KILL
+
+// Android only supports the following 5 little endian architectures
+#if defined(__aarch64__) && defined(__LP64__)
+  #define MY_AUDIT_ARCH AUDIT_ARCH_AARCH64
+#elif defined(__arm__) && defined(__ILP32__)
+  #define MY_AUDIT_ARCH AUDIT_ARCH_ARM
+#elif defined(__i386__) && defined(__ILP32__)
+  #define MY_AUDIT_ARCH AUDIT_ARCH_I386
+#elif defined(__x86_64__) && defined(__LP64__)
+  #define MY_AUDIT_ARCH AUDIT_ARCH_X86_64
+#elif defined(__riscv) && defined(__LP64__)
+  #define MY_AUDIT_ARCH AUDIT_ARCH_RISCV64
+#else
+  #error "Unknown AUDIT_ARCH_* architecture."
+#endif
+
+void enable_seccomp(void) {
+  static const struct sock_filter filter[] = {
+    BPF_SECCOMP_LOAD_AUDIT_ARCH,
+    BPF2_SECCOMP_KILL_IF_NOT_EQUAL(MY_AUDIT_ARCH),
+
+    BPF_SECCOMP_LOAD_SYSCALL_NR,                     // aarch64
+
+    // main event loop:
+    //   ppoll ( read sendmsg | recvmsg writev )
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_ppoll),         // 73
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_read),          // 63
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_sendmsg),       // 211
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_recvmsg),       // 212
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_writev),        // 66
+
+    // logging: getuid writev
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_getuid),        // 174
+
+    // inbound signal (SIGTERM) processing
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_rt_sigreturn),  // 139
+
+    // sleep(n)
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_nanosleep),     // 101
+
+    // _exit(0)
+    BPF2_SECCOMP_ALLOW_IF_EQUAL(__NR_exit_group),    // 94
+
+#if defined(__aarch64__)
+    // Pixels are aarch64 - if we break clatd functionality on them,
+    // we *will* notice on GoogleGuest WiFi network (which is ipv6 only)
+    BPF_SECCOMP_KILL,
+#else
+    // All other architectures: generate audit lines visible in dmesg and logcat
+    BPF_SECCOMP_LOG,
+#endif
+  };
+  static const struct sock_fprog prog = {
+    .len = (unsigned short)ARRAY_SIZE(filter),
+    .filter = (struct sock_filter *)filter,
+  };
+
+  // https://man7.org/linux/man-pages/man2/PR_SET_NO_NEW_PRIVS.2const.html
+  // required to allow non-privileged seccomp filter installation
+  int rv = prctl(PR_SET_NO_NEW_PRIVS, 1L, 0L, 0L, 0L);
+  if (rv) {
+    logmsg(ANDROID_LOG_FATAL, "prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) = %d [%d]", rv, errno);
+    exit(1);
+  }
+
+  // https://man7.org/linux/man-pages/man2/PR_SET_SECCOMP.2const.html
+  // but see also https://man7.org/linux/man-pages/man2/seccomp.2.html
+  rv = prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog, 0L, 0L);
+  if (rv) {
+    logmsg(ANDROID_LOG_FATAL, "prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog, 0, 0) = %d [%d]", rv, errno);
+    exit(1);
+  }
+}
+
 /* function: main
  * allocate and setup the tun device, then run the event loop
  */
@@ -64,6 +194,48 @@
        *write_sock_str = NULL;
   unsigned len;
 
+  // Clatd binary is setuid/gid CLAT, thus when we reach here we have:
+  //   $ adb shell ps | grep clat
+  //                [pid] [ppid]
+  //   clat          7650  1393   10785364   2612 do_sys_poll         0 S clatd-wlan0
+  //   $ adb shell cat /proc/7650/status | egrep -i '^(Uid:|Gid:|Groups:)'
+  //         [real][effective][saved][filesystem]
+  //          [uid]   [euid]  [suid]  [fsuid]
+  //   Uid:    1000    1029    1029    1029
+  //          [gid]   [egid]  [sgid]  [fsgid]
+  //   Gid:    1000    1029    1029    1029
+  //   Groups: 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1018 1021 1023 1024 1032 1065 3001 3002 3003 3005 3006 3007 3009 3010 3011 3012
+  // This mismatch between uid & euid appears to cause periodic (every 5 minutes):
+  //                                                  objhash pid  ppid             uid
+  //   W ActivityManager: Stale PhantomProcessRecord {xxxxxxx 7650:1393:clatd-wlan0/1000}, removing
+  // This is due to:
+  //   $ adbz shell ls -ld /proc/7650
+  //   dr-xr-xr-x 9 clat clat 0 2025-03-14 11:37 /proc/7650
+  // which is used by
+  //   //frameworks/base/core/java/com/android/internal/os/ProcessCpuTracker.java
+  // which thus returns the uid 'clat' vs
+  //   //frameworks/base/core/java/android/os/Process.java
+  // getUidForPid() which grabs *real* 'uid' from /proc/<pid>/status and is used in:
+  //   //frameworks/base/services/core/java/com/android/server/am/PhantomProcessList.java
+  // (perhaps this should grab euid instead? unclear)
+  //
+  // However, we want to drop as many privs as possible, hence:
+  gid_t egid = getegid();  // documented to never fail, hence should return AID_CLAT == 1029
+  uid_t euid = geteuid();  // (ditto)
+  setresgid(egid, egid, egid);  // ignore any failure
+  setresuid(euid, euid, euid);  // ignore any failure
+  // ideally we'd somehow drop supplementary groups too...
+  // but for historical reasons that actually requires CAP_SETGID which we don't have
+  // (see man 2 setgroups)
+  //
+  // Now we (should) have:
+  // $ adb shell ps | grep clat
+  // clat          5370  1479   10785364   2528 do_sys_poll         0 S clatd-wlan0
+  // # adb shell cat /proc/5370/status | egrep -i '^(Uid:|Gid:|Groups:)'
+  // Uid:    1029    1029    1029    1029
+  // Gid:    1029    1029    1029    1029
+  // Groups: 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1018 1021 1023 1024 1032 1065 3001 3002 3003 3005 3006 3007 3009 3010 3011 3012
+
   while ((opt = getopt(argc, argv, "i:p:4:6:t:r:w:h")) != -1) {
     switch (opt) {
       case 'i':
@@ -150,7 +322,7 @@
     exit(1);
   }
 
-  logmsg(ANDROID_LOG_INFO, "Starting clat version %s on %s plat=%s v4=%s v6=%s", CLATD_VERSION,
+  logmsg(ANDROID_LOG_INFO, "Starting clat version " CLATD_VERSION " on %s plat=%s v4=%s v6=%s",
          uplink_interface, plat_prefix ? plat_prefix : "(none)", v4_addr ? v4_addr : "(none)",
          v6_addr ? v6_addr : "(none)");
 
@@ -183,25 +355,39 @@
   }
 
   // Loop until someone sends us a signal or brings down the tun interface.
-  if (signal(SIGTERM, stop_loop) == SIG_ERR) {
+  if (signal(SIGTERM, handle_sigterm) == SIG_ERR) {
     logmsg(ANDROID_LOG_FATAL, "sigterm handler failed: %s", strerror(errno));
     exit(1);
   }
 
+  // Apparently some network gear will refuse to perform NS for IPs that aren't DAD'ed,
+  // this would then result in an ipv6-only network with working native ipv6, working
+  // IPv4 via DNS64, but non-functioning IPv4 via CLAT (ie. IPv4 literals + IPv4 only apps).
+  // The kernel itself doesn't do DAD for anycast ips (but does handle IPV6 MLD and handle ND).
+  // So we'll spoof dad here, and yeah, we really should check for a response and in
+  // case of failure pick a different IP.  Seeing as 48-bits of the IP are utterly random
+  // (with the other 16 chosen to guarantee checksum neutrality) this seems like a remote
+  // concern...
+  // TODO: actually perform true DAD
+  send_dad(tunnel.write_fd6, &Global_Clatd_Config.ipv6_local_subnet);
+
+  enable_seccomp();  // WARNING: from this point forward very limited system calls available.
+
   event_loop(&tunnel);
 
-  logmsg(ANDROID_LOG_INFO, "Shutting down clat on %s", uplink_interface);
-
-  if (running) {
-    logmsg(ANDROID_LOG_INFO, "Clatd on %s waiting for SIGTERM", uplink_interface);
+  if (sigterm) {
+    logmsg(ANDROID_LOG_INFO, "Shutting down clatd on %s, already received SIGTERM", uplink_interface);
+  } else {
+    // this implies running == false, ie. we received EOF or ENETDOWN error.
+    logmsg(ANDROID_LOG_INFO, "Shutting down clatd on %s, waiting for SIGTERM", uplink_interface);
     // let's give higher level java code 15 seconds to kill us,
     // but eventually terminate anyway, in case system server forgets about us...
-    // sleep() should be interrupted by SIGTERM, the handler should clear running
+    // sleep() should be interrupted by SIGTERM, the handler should set 'sigterm'
     sleep(15);
     logmsg(ANDROID_LOG_INFO, "Clatd on %s %s SIGTERM", uplink_interface,
-           running ? "timed out waiting for" : "received");
-  } else {
-    logmsg(ANDROID_LOG_INFO, "Clatd on %s already received SIGTERM", uplink_interface);
+           sigterm ? "received" : "timed out waiting for");
   }
-  return 0;
+
+  // Using _exit() here avoids 4 mprotect() syscalls triggered via 'exit(0)' or 'return 0'
+  _exit(0);
 }
diff --git a/common/FlaggedApi.bp b/common/FlaggedApi.bp
index 39ff2d4..f89ff9d 100644
--- a/common/FlaggedApi.bp
+++ b/common/FlaggedApi.bp
@@ -17,6 +17,7 @@
 aconfig_declarations {
     name: "com.android.net.flags-aconfig",
     package: "com.android.net.flags",
+    exportable: true,
     container: "com.android.tethering",
     srcs: ["flags.aconfig"],
     visibility: ["//packages/modules/Connectivity:__subpackages__"],
@@ -32,6 +33,17 @@
     ],
 }
 
+java_aconfig_library {
+    name: "com.android.net.flags-aconfig-java-export",
+    aconfig_declarations: "com.android.net.flags-aconfig",
+    defaults: ["framework-minus-apex-aconfig-java-defaults"],
+    min_sdk_version: "30",
+    apex_available: [
+        "com.android.wifi",
+    ],
+    mode: "exported",
+}
+
 aconfig_declarations {
     name: "com.android.net.thread.flags-aconfig",
     package: "com.android.net.thread.flags",
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 17ef94b..51b4fc0 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -156,3 +156,21 @@
   bug: "354619988"
   is_fixed_read_only: true
 }
+
+flag {
+  name: "ipv6_over_ble"
+  is_exported: true
+  namespace: "android_core_networking"
+  description: "API flag for IPv6 over BLE"
+  bug: "372936361"
+  is_fixed_read_only: true
+}
+
+flag {
+  name: "restrict_local_network"
+  is_exported: true
+  namespace: "android_core_networking"
+  description: "Flag for controlling access to the local network behind a new runtime permission. Requires ConnectivityCompatChanges.RESTRICT_LOCAL_NETWORK to enable feature."
+  bug: "388774939"
+  is_fixed_read_only: true
+}
diff --git a/common/src/com/android/net/module/util/bpf/LocalNetAccessKey.java b/common/src/com/android/net/module/util/bpf/LocalNetAccessKey.java
new file mode 100644
index 0000000..48e8b06
--- /dev/null
+++ b/common/src/com/android/net/module/util/bpf/LocalNetAccessKey.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.bpf;
+
+import com.android.net.module.util.InetAddressUtils;
+import com.android.net.module.util.Struct;
+
+import java.net.Inet4Address;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+
+public class LocalNetAccessKey extends Struct {
+
+    @Field(order = 0, type = Type.U32)
+    public final long lpmBitlen;
+    @Field(order = 1, type = Type.U32)
+    public final long ifIndex;
+    @Field(order = 2, type = Type.Ipv6Address)
+    public final Inet6Address remoteAddress;
+    @Field(order = 3, type = Type.U16)
+    public final int protocol;
+    @Field(order = 4, type = Type.UBE16)
+    public final int remotePort;
+
+    public LocalNetAccessKey(long lpmBitlen, long ifIndex, InetAddress remoteAddress, int protocol,
+            int remotePort) {
+        this.lpmBitlen = lpmBitlen;
+        this.ifIndex = ifIndex;
+        this.protocol = protocol;
+        this.remotePort = remotePort;
+
+        if (remoteAddress instanceof Inet4Address) {
+            this.remoteAddress = InetAddressUtils.v4MappedV6Address((Inet4Address) remoteAddress);
+        } else {
+            this.remoteAddress = (Inet6Address) remoteAddress;
+        }
+    }
+
+    public LocalNetAccessKey(long lpmBitlen, long ifIndex, Inet6Address remoteAddress, int protocol,
+            int remotePort) {
+        this.lpmBitlen = lpmBitlen;
+        this.ifIndex = ifIndex;
+        this.remoteAddress = remoteAddress;
+        this.protocol = protocol;
+        this.remotePort = remotePort;
+    }
+
+    @Override
+    public String toString() {
+        String s = "LocalNetAccessKey{lpmBitlen=" + lpmBitlen;
+
+        long bits = lpmBitlen;
+
+        // u32 ifIndex
+        if (bits <= 0 && ifIndex != 0) s += " ??";
+        if (bits > 0 || ifIndex != 0) s += " ifIndex=" + ifIndex;
+        if (bits > 0 && bits < 32) s += "/" + bits + "[LE]";
+        bits -= 32;
+
+        // u128 remoteAddress
+        if (bits <= 0 && !remoteAddress.isAnyLocalAddress()) s += " ??";
+        if (bits > 0 || !remoteAddress.isAnyLocalAddress()) {
+            s += " remoteAddress=";
+            String ip = remoteAddress.toString();
+            if (ip.startsWith("/::ffff:")) { // technically wrong IPv4-mapped IPv6 address detection
+              s += ip.substring(8);
+              if (bits >= 96 && bits < 128) s += "/" + (bits - 96);
+            } else if (ip.startsWith("/")) {
+              s += ip.substring(1);
+              if (bits >= 0 && bits < 128) s += "/" + bits;
+            } else { // WTF, includes a hostname or what?
+              s += ip;
+            }
+        }
+        bits -= 128;
+
+        // u16 protocol
+        if (bits <= 0 && protocol != 0) s += " ??";
+        if (bits > 0 || protocol != 0) s += " protocol=" + protocol;
+        if (bits > 0 && bits < 16) s += "/" + bits + "[LE16]";
+        bits -= 16;
+
+        // be16 remotePort
+        if (bits <= 0 && remotePort != 0) s += " ??";
+        if (bits > 0 || remotePort != 0) s += " remotePort=" + remotePort;
+        if (bits > 0 && bits < 16) s += "/" + bits + "[BE16]";
+        bits -= 16;
+
+        s += "}";
+        return s;
+    }
+}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 26fc145..9d6d356 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -102,6 +102,7 @@
 java_library {
     name: "framework-connectivity-t-pre-jarjar",
     defaults: ["framework-connectivity-t-defaults"],
+    installable: false,
     libs: [
         "framework-bluetooth.stubs.module_lib",
         "framework-wifi.stubs.module_lib",
diff --git a/framework-t/api/current.txt b/framework-t/api/current.txt
index 9ae0cf7..d66482c 100644
--- a/framework-t/api/current.txt
+++ b/framework-t/api/current.txt
@@ -210,6 +210,23 @@
 
 package android.net.nsd {
 
+  @FlaggedApi("com.android.net.flags.ipv6_over_ble") public final class AdvertisingRequest implements android.os.Parcelable {
+    method public int describeContents();
+    method public long getFlags();
+    method public int getProtocolType();
+    method @NonNull public android.net.nsd.NsdServiceInfo getServiceInfo();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.nsd.AdvertisingRequest> CREATOR;
+    field public static final long FLAG_SKIP_PROBING = 2L; // 0x2L
+  }
+
+  @FlaggedApi("com.android.net.flags.ipv6_over_ble") public static final class AdvertisingRequest.Builder {
+    ctor public AdvertisingRequest.Builder(@NonNull android.net.nsd.NsdServiceInfo);
+    method @NonNull public android.net.nsd.AdvertisingRequest build();
+    method @NonNull public android.net.nsd.AdvertisingRequest.Builder setFlags(long);
+    method @NonNull public android.net.nsd.AdvertisingRequest.Builder setProtocolType(int);
+  }
+
   @FlaggedApi("com.android.net.flags.nsd_subtypes_support_enabled") public final class DiscoveryRequest implements android.os.Parcelable {
     method public int describeContents();
     method @Nullable public android.net.Network getNetwork();
@@ -288,6 +305,7 @@
     method public java.util.Map<java.lang.String,byte[]> getAttributes();
     method @Deprecated public java.net.InetAddress getHost();
     method @NonNull public java.util.List<java.net.InetAddress> getHostAddresses();
+    method @FlaggedApi("com.android.net.flags.ipv6_over_ble") @Nullable public String getHostname();
     method @Nullable public android.net.Network getNetwork();
     method public int getPort();
     method public String getServiceName();
diff --git a/framework-t/src/android/net/NetworkStatsAccess.java b/framework-t/src/android/net/NetworkStatsAccess.java
index 7c9b3ec..449588a 100644
--- a/framework-t/src/android/net/NetworkStatsAccess.java
+++ b/framework-t/src/android/net/NetworkStatsAccess.java
@@ -111,6 +111,12 @@
     /** Returns the {@link NetworkStatsAccess.Level} for the given caller. */
     public static @NetworkStatsAccess.Level int checkAccessLevel(
             Context context, int callingPid, int callingUid, @Nullable String callingPackage) {
+        final int appId = UserHandle.getAppId(callingUid);
+        if (appId == Process.SYSTEM_UID) {
+            // the system can access data usage for all apps on the device.
+            // check system uid first, to avoid possible dead lock from other APIs
+            return NetworkStatsAccess.Level.DEVICE;
+        }
         final DevicePolicyManager mDpm = context.getSystemService(DevicePolicyManager.class);
         final TelephonyManager tm = (TelephonyManager)
                 context.getSystemService(Context.TELEPHONY_SERVICE);
@@ -126,16 +132,13 @@
             Binder.restoreCallingIdentity(token);
         }
 
-        final int appId = UserHandle.getAppId(callingUid);
-
         final boolean isNetworkStack = PermissionUtils.hasAnyPermissionOf(
                 context, callingPid, callingUid, android.Manifest.permission.NETWORK_STACK,
                 NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK);
 
-        if (hasCarrierPrivileges || isDeviceOwner
-                || appId == Process.SYSTEM_UID || isNetworkStack) {
-            // Carrier-privileged apps and device owners, and the system (including the
-            // network stack) can access data usage for all apps on the device.
+        if (hasCarrierPrivileges || isDeviceOwner || isNetworkStack) {
+            // Carrier-privileged apps and device owners, and the network stack
+            // can access data usage for all apps on the device.
             return NetworkStatsAccess.Level.DEVICE;
         }
 
diff --git a/framework-t/src/android/net/TrafficStats.java b/framework-t/src/android/net/TrafficStats.java
index 81f2cf9..868033a 100644
--- a/framework-t/src/android/net/TrafficStats.java
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -17,6 +17,7 @@
 package android.net;
 
 import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
+import static android.net.NetworkStats.UID_ALL;
 
 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 
@@ -33,21 +34,25 @@
 import android.content.Context;
 import android.media.MediaPlayer;
 import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.os.Binder;
 import android.os.Build;
 import android.os.RemoteException;
 import android.os.StrictMode;
+import android.os.SystemClock;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.BinderUtils;
+import com.android.net.module.util.LruCacheWithExpiry;
 
 import java.io.FileDescriptor;
 import java.io.IOException;
 import java.net.DatagramSocket;
 import java.net.Socket;
 import java.net.SocketException;
-
+import java.util.function.LongSupplier;
 
 /**
  * Class that provides network traffic statistics. These statistics include
@@ -182,13 +187,48 @@
     /** @hide */
     public static final int TAG_SYSTEM_PROBE = 0xFFFFFF42;
 
+    private static final StatsResult EMPTY_STATS = new StatsResult(0L, 0L, 0L, 0L);
+
+    private static final Object sRateLimitCacheLock = new Object();
+
     @GuardedBy("TrafficStats.class")
+    @Nullable
     private static INetworkStatsService sStatsService;
 
     // The variable will only be accessed in the test, which is effectively
     // single-threaded.
+    @Nullable
     private static INetworkStatsService sStatsServiceForTest = null;
 
+    // This holds the configuration for the TrafficStats rate limit caches.
+    // It will be filled with the result of a query to the service the first time
+    // the caller invokes get*Stats APIs.
+    // This variable can be accessed from any thread with the lock held.
+    @GuardedBy("sRateLimitCacheLock")
+    @Nullable
+    private static TrafficStatsRateLimitCacheConfig sRateLimitCacheConfig;
+
+    // Cache for getIfaceStats and getTotalStats binder interfaces.
+    // This variable can be accessed from any thread with the lock held,
+    // while the cache itself is thread-safe and can be accessed outside
+    // the lock.
+    @GuardedBy("sRateLimitCacheLock")
+    @Nullable
+    private static LruCacheWithExpiry<String, StatsResult> sRateLimitIfaceCache;
+
+    // Cache for getUidStats binder interface.
+    // This variable can be accessed from any thread with the lock held,
+    // while the cache itself is thread-safe and can be accessed outside
+    // the lock.
+    @GuardedBy("sRateLimitCacheLock")
+    @Nullable
+    private static LruCacheWithExpiry<Integer, StatsResult> sRateLimitUidCache;
+
+    // The variable will only be accessed in the test, which is effectively
+    // single-threaded.
+    @Nullable
+    private static LongSupplier sTimeSupplierForTest = null;
+
     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 130143562)
     private synchronized static INetworkStatsService getStatsService() {
         if (sStatsServiceForTest != null) return sStatsServiceForTest;
@@ -215,6 +255,28 @@
     }
 
     /**
+     * Set time supplier for test, or null to reset.
+     *
+     * @hide
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public static void setTimeSupplierForTest(LongSupplier timeSupplier) {
+        sTimeSupplierForTest = timeSupplier;
+    }
+
+    /**
+     * Trigger query rate-limit cache config and initializing the caches.
+     *
+     * This is for test purpose.
+     *
+     * @hide
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public static void reinitRateLimitCacheForTest() {
+        maybeGetConfigAndInitRateLimitCache(true /* forceReinit */);
+    }
+
+    /**
      * Snapshot of {@link NetworkStats} when the currently active profiling
      * session started, or {@code null} if no session active.
      *
@@ -254,6 +316,92 @@
         sStatsService = statsManager.getBinder();
     }
 
+    @Nullable
+    private static LruCacheWithExpiry<String, StatsResult> maybeGetRateLimitIfaceCache() {
+        if (!maybeGetConfigAndInitRateLimitCache(false /* forceReinit */)) return null;
+        synchronized (sRateLimitCacheLock) {
+            return sRateLimitIfaceCache;
+        }
+    }
+
+    @Nullable
+    private static LruCacheWithExpiry<Integer, StatsResult> maybeGetRateLimitUidCache() {
+        if (!maybeGetConfigAndInitRateLimitCache(false /* forceReinit */)) return null;
+        synchronized (sRateLimitCacheLock) {
+            return sRateLimitUidCache;
+        }
+    }
+
+    /**
+     * Gets the rate limit cache configuration and init caches if null.
+     *
+     * Gets the configuration from the service as the configuration
+     * is not expected to change dynamically. And use it to initialize
+     * rate-limit cache if not yet initialized.
+     *
+     * @return whether the rate-limit cache is enabled.
+     *
+     * @hide
+     */
+    private static boolean maybeGetConfigAndInitRateLimitCache(boolean forceReinit) {
+        // Access the service outside the lock to avoid potential deadlocks. This is
+        // especially important when the caller is a system component (e.g.,
+        // NetworkPolicyManagerService) that might hold other locks that the service
+        // also needs.
+        // Although this introduces a race condition where multiple threads might
+        // query the service concurrently, it's acceptable in this case because the
+        // configuration doesn't change dynamically. The configuration only needs to
+        // be fetched once before initializing the cache.
+        synchronized (sRateLimitCacheLock) {
+            if (sRateLimitCacheConfig != null && !forceReinit) {
+                return sRateLimitCacheConfig.isCacheEnabled;
+            }
+        }
+
+        final TrafficStatsRateLimitCacheConfig config;
+        try {
+            config = getStatsService().getRateLimitCacheConfig();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+
+        synchronized (sRateLimitCacheLock) {
+            if (sRateLimitCacheConfig == null || forceReinit) {
+                sRateLimitCacheConfig = config;
+                initRateLimitCacheLocked();
+            }
+        }
+        return config.isCacheEnabled;
+    }
+
+    @GuardedBy("sRateLimitCacheLock")
+    private static void initRateLimitCacheLocked() {
+        // Set up rate limiting caches.
+        // Use uid cache with UID_ALL to cache total stats.
+        if (sRateLimitCacheConfig.isCacheEnabled) {
+            // A time supplier which is monotonic until device reboots, and counts
+            // time spent in sleep. This is needed to ensure the get*Stats caller
+            // won't get stale value after system time adjustment or waking up from sleep.
+            final LongSupplier realtimeSupplier = (sTimeSupplierForTest != null
+                    ? sTimeSupplierForTest : () -> SystemClock.elapsedRealtime());
+            sRateLimitIfaceCache = new LruCacheWithExpiry<String, StatsResult>(
+                    realtimeSupplier,
+                    sRateLimitCacheConfig.expiryDurationMs,
+                    sRateLimitCacheConfig.maxEntries,
+                    (statsResult) -> !isEmpty(statsResult)
+            );
+            sRateLimitUidCache = new LruCacheWithExpiry<Integer, StatsResult>(
+                    realtimeSupplier,
+                    sRateLimitCacheConfig.expiryDurationMs,
+                    sRateLimitCacheConfig.maxEntries,
+                    (statsResult) -> !isEmpty(statsResult)
+            );
+        } else {
+            sRateLimitIfaceCache = null;
+            sRateLimitUidCache = null;
+        }
+    }
+
     /**
      * Attach the socket tagger implementation to the current process, to
      * get notified when a socket's {@link FileDescriptor} is assigned to
@@ -736,6 +884,14 @@
             android.Manifest.permission.NETWORK_STACK,
             android.Manifest.permission.NETWORK_SETTINGS})
     public static void clearRateLimitCaches() {
+        final LruCacheWithExpiry<String, StatsResult> ifaceCache = maybeGetRateLimitIfaceCache();
+        if (ifaceCache != null) {
+            ifaceCache.clear();
+        }
+        final LruCacheWithExpiry<Integer, StatsResult> uidCache = maybeGetRateLimitUidCache();
+        if (uidCache != null) {
+            uidCache.clear();
+        }
         try {
             getStatsService().clearTrafficStatsRateLimitCaches();
         } catch (RemoteException e) {
@@ -985,35 +1141,76 @@
 
     /** @hide */
     public static long getUidStats(int uid, int type) {
-        final StatsResult stats;
+        return fetchStats(maybeGetRateLimitUidCache(), uid,
+                () -> getStatsService().getUidStats(uid), type);
+    }
+
+    // Note: This method calls to the service, do not invoke this method with lock held.
+    private static <K> long fetchStats(
+            @Nullable LruCacheWithExpiry<K, StatsResult> cache, K key,
+            BinderUtils.ThrowingSupplier<StatsResult, RemoteException> statsFetcher, int type) {
         try {
-            stats = getStatsService().getUidStats(uid);
+            final StatsResult stats;
+            if (cache != null) {
+                stats = fetchStatsWithCache(cache, key, statsFetcher);
+            } else {
+                // Cache is not enabled, fetch directly from service.
+                stats = statsFetcher.get();
+            }
+            return getEntryValueForType(stats, type);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getEntryValueForType(stats, type);
+    }
+
+    // Note: This method calls to the service, do not invoke this method with lock held.
+    @Nullable
+    private static <K> StatsResult fetchStatsWithCache(LruCacheWithExpiry<K, StatsResult> cache,
+            K key, BinderUtils.ThrowingSupplier<StatsResult, RemoteException> statsFetcher)
+            throws RemoteException {
+        // Attempt to retrieve from the cache first.
+        StatsResult stats = cache.get(key);
+
+        // Although the cache instance is thread-safe, this can still introduce a
+        // race condition between threads of the same process, potentially
+        // returning non-monotonic results. This is because there is no lock
+        // between get, fetch, and put operations. This is considered acceptable
+        // because varying thread execution speeds can also cause non-monotonic
+        // results, even with locking.
+        if (stats == null) {
+            // Cache miss, fetch from the service.
+            stats = statsFetcher.get();
+
+            // Update the cache with the fetched result if valid.
+            if (stats != null && !isEmpty(stats)) {
+                final StatsResult cachedValue = cache.putIfAbsent(key, stats);
+                if (cachedValue != null) {
+                    // Some other thread cached a value after this thread
+                    // originally got a cache miss. Return the cached value
+                    // to ensure all returned values after caching are consistent.
+                    return cachedValue;
+                }
+            }
+        }
+        return stats;
+    }
+
+    private static boolean isEmpty(StatsResult stats) {
+        return stats.equals(EMPTY_STATS);
     }
 
     /** @hide */
     public static long getTotalStats(int type) {
-        final StatsResult stats;
-        try {
-            stats = getStatsService().getTotalStats();
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-        return getEntryValueForType(stats, type);
+        // In practice, Bpf doesn't use UID_ALL for storing per-UID stats.
+        // Use uid cache with UID_ALL to cache total stats.
+        return fetchStats(maybeGetRateLimitUidCache(), UID_ALL,
+                () -> getStatsService().getTotalStats(), type);
     }
 
     /** @hide */
     public static long getIfaceStats(String iface, int type) {
-        final StatsResult stats;
-        try {
-            stats = getStatsService().getIfaceStats(iface);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-        return getEntryValueForType(stats, type);
+        return fetchStats(maybeGetRateLimitIfaceCache(), iface,
+                () -> getStatsService().getIfaceStats(iface), type);
     }
 
     /**
diff --git a/framework-t/src/android/net/nsd/AdvertisingRequest.java b/framework-t/src/android/net/nsd/AdvertisingRequest.java
index 6afb2d5..a62df65 100644
--- a/framework-t/src/android/net/nsd/AdvertisingRequest.java
+++ b/framework-t/src/android/net/nsd/AdvertisingRequest.java
@@ -15,12 +15,16 @@
  */
 package android.net.nsd;
 
+import android.annotation.FlaggedApi;
 import android.annotation.LongDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.net.nsd.NsdManager.ProtocolType;
 import android.os.Parcel;
 import android.os.Parcelable;
 
+import com.android.net.flags.Flags;
+
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.time.Duration;
@@ -28,16 +32,32 @@
 
 /**
  * Encapsulates parameters for {@link NsdManager#registerService}.
- * @hide
  */
-//@FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
+@FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
 public final class AdvertisingRequest implements Parcelable {
 
     /**
      * Only update the registration without sending exit and re-announcement.
+     * @hide
      */
     public static final long NSD_ADVERTISING_UPDATE_ONLY = 1;
 
+    // TODO: if apps are allowed to set hostnames, the below doc should be updated to mention that
+    // passed in hostnames must also be known unique to use this flag.
+    /**
+     * Skip the probing step when advertising.
+     *
+     * <p>This must only be used when the service name ({@link NsdServiceInfo#getServiceName()} is
+     * known to be unique and cannot possibly be used by any other device on the network.
+     */
+    public static final long FLAG_SKIP_PROBING = 1 << 1;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @LongDef(flag = true, prefix = {"FLAG_"}, value = {
+            FLAG_SKIP_PROBING,
+    })
+    public @interface AdvertisingFlags {}
 
     @NonNull
     public static final Creator<AdvertisingRequest> CREATOR =
@@ -79,7 +99,7 @@
     /**
      * The constructor for the advertiseRequest
      */
-    private AdvertisingRequest(@NonNull NsdServiceInfo serviceInfo, int protocolType,
+    private AdvertisingRequest(@NonNull NsdServiceInfo serviceInfo, @ProtocolType int protocolType,
             long advertisingConfig, @NonNull Duration ttl) {
         mServiceInfo = serviceInfo;
         mProtocolType = protocolType;
@@ -88,7 +108,7 @@
     }
 
     /**
-     * Returns the {@link NsdServiceInfo}
+     * @return the {@link NsdServiceInfo} describing the service to advertise.
      */
     @NonNull
     public NsdServiceInfo getServiceInfo() {
@@ -96,16 +116,18 @@
     }
 
     /**
-     * Returns the service advertise protocol
+     * @return the service advertisement protocol.
      */
+    @ProtocolType
     public int getProtocolType() {
         return mProtocolType;
     }
 
     /**
-     * Returns the advertising config.
+     * @return the flags affecting advertising behavior.
      */
-    public long getAdvertisingConfig() {
+    @AdvertisingFlags
+    public long getFlags() {
         return mAdvertisingConfig;
     }
 
@@ -165,34 +187,45 @@
         dest.writeLong(mTtl == null ? -1L : mTtl.getSeconds());
     }
 
-//    @FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
     /**
-     * The builder for creating new {@link AdvertisingRequest} objects.
-     * @hide
+     * A builder for creating new {@link AdvertisingRequest} objects.
      */
+    @FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
     public static final class Builder {
         @NonNull
         private final NsdServiceInfo mServiceInfo;
-        private final int mProtocolType;
+        private int mProtocolType;
         private long mAdvertisingConfig;
         @Nullable
         private Duration mTtl;
+
         /**
          * Creates a new {@link Builder} object.
+         * @param serviceInfo the {@link NsdServiceInfo} describing the service to advertise.
+         * @param protocolType the advertising protocol to use.
+         * @hide
          */
-        public Builder(@NonNull NsdServiceInfo serviceInfo, int protocolType) {
+        public Builder(@NonNull NsdServiceInfo serviceInfo, @ProtocolType int protocolType) {
             mServiceInfo = serviceInfo;
             mProtocolType = protocolType;
         }
 
         /**
+         * Creates a new {@link Builder} object.
+         * @param serviceInfo the {@link NsdServiceInfo} describing the service to advertise.
+         */
+        public Builder(@NonNull NsdServiceInfo serviceInfo) {
+            this(serviceInfo, NsdManager.PROTOCOL_DNS_SD);
+        }
+
+        /**
          * Sets advertising configuration flags.
          *
-         * @param advertisingConfigFlags Bitmask of {@code AdvertisingConfig} flags.
+         * @param flags flags to use for advertising.
          */
         @NonNull
-        public Builder setAdvertisingConfig(long advertisingConfigFlags) {
-            mAdvertisingConfig = advertisingConfigFlags;
+        public Builder setFlags(@AdvertisingFlags long flags) {
+            mAdvertisingConfig = flags;
             return this;
         }
 
@@ -232,6 +265,16 @@
             return this;
         }
 
+        /**
+         * Sets the protocol to use for advertising.
+         * @param protocolType the advertising protocol to use.
+         */
+        @NonNull
+        public Builder setProtocolType(@ProtocolType int protocolType) {
+            mProtocolType = protocolType;
+            return this;
+        }
+
         /** Creates a new {@link AdvertisingRequest} object. */
         @NonNull
         public AdvertisingRequest build() {
diff --git a/framework-t/src/android/net/nsd/NsdManager.java b/framework-t/src/android/net/nsd/NsdManager.java
index 116bea6..426a92d 100644
--- a/framework-t/src/android/net/nsd/NsdManager.java
+++ b/framework-t/src/android/net/nsd/NsdManager.java
@@ -314,6 +314,13 @@
     /** Dns based service discovery protocol */
     public static final int PROTOCOL_DNS_SD = 0x0001;
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = {"PROTOCOL_"}, value = {
+            PROTOCOL_DNS_SD,
+    })
+    public @interface ProtocolType {}
+
     /**
      * The minimum TTL seconds which is allowed for a service registration.
      *
@@ -1272,7 +1279,7 @@
         // documented in the NsdServiceInfo.setSubtypes API instead, but this provides a limited
         // option for users of the older undocumented behavior, only for subtype changes.
         if (isSubtypeUpdateRequest(serviceInfo, listener)) {
-            builder.setAdvertisingConfig(AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY);
+            builder.setFlags(AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY);
         }
         registerService(builder.build(), executor, listener);
     }
@@ -1358,7 +1365,7 @@
         checkProtocol(protocolType);
         final int key;
         // For update only request, the old listener has to be reused
-        if ((advertisingRequest.getAdvertisingConfig()
+        if ((advertisingRequest.getFlags()
                 & AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY) > 0) {
             key = updateRegisteredListener(listener, executor, serviceInfo);
         } else {
diff --git a/framework-t/src/android/net/nsd/NsdServiceInfo.java b/framework-t/src/android/net/nsd/NsdServiceInfo.java
index 18c59d9..6a5ab4d 100644
--- a/framework-t/src/android/net/nsd/NsdServiceInfo.java
+++ b/framework-t/src/android/net/nsd/NsdServiceInfo.java
@@ -200,19 +200,19 @@
     /**
      * Get the hostname.
      *
-     * <p>When a service is resolved, it returns the hostname of the resolved service . The top
-     * level domain ".local." is omitted.
-     *
-     * <p>For example, it returns "MyHost" when the service's hostname is "MyHost.local.".
-     *
-     * @hide
+     * <p>When a service is resolved through {@link NsdManager#resolveService} or
+     * {@link NsdManager#registerServiceInfoCallback}, this returns the hostname of the resolved
+     * service. In all other cases, this will be null. The top level domain ".local." is omitted.
+     * For example, this returns "MyHost" when the service's hostname is "MyHost.local.".
      */
-//    @FlaggedApi(NsdManager.Flags.NSD_CUSTOM_HOSTNAME_ENABLED)
+    @FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
     @Nullable
     public String getHostname() {
         return mHostname;
     }
 
+    // TODO: if setHostname is made public, AdvertisingRequest#FLAG_SKIP_PROBING javadoc must be
+    // updated to mention that hostnames must also be known unique to use that flag.
     /**
      * Set a custom hostname for this service instance for registration.
      *
diff --git a/framework-t/src/android/net/nsd/OffloadServiceInfo.java b/framework-t/src/android/net/nsd/OffloadServiceInfo.java
index e4b2f43..fd824f3 100644
--- a/framework-t/src/android/net/nsd/OffloadServiceInfo.java
+++ b/framework-t/src/android/net/nsd/OffloadServiceInfo.java
@@ -282,7 +282,7 @@
         }
 
         /**
-         * Get the service type. (e.g. "_http._tcp.local" )
+         * Get the service type. (e.g. "_http._tcp" )
          */
         @NonNull
         public String getServiceType() {
diff --git a/framework/Android.bp b/framework/Android.bp
index a93a532..ab3af9a 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -75,6 +75,7 @@
             // the module builds against API (the parcelable declarations exist in framework.aidl)
             "frameworks/base/core/java", // For framework parcelables
             "frameworks/native/aidl/binder", // For PersistableBundle.aidl
+            "packages/modules/Connectivity/Tethering/common/TetheringLib/src",
         ],
     },
     stub_only_libs: [
@@ -143,6 +144,7 @@
 java_library {
     name: "framework-connectivity-pre-jarjar",
     defaults: ["framework-module-defaults"],
+    installable: false,
     min_sdk_version: "30",
     static_libs: [
         "framework-connectivity-pre-jarjar-without-cronet",
@@ -158,7 +160,9 @@
 java_defaults {
     name: "CronetJavaDefaults",
     srcs: [":httpclient_api_sources"],
-    static_libs: ["com.android.net.http.flags-aconfig-java"],
+    static_libs: [
+        "com.android.net.http.flags-aconfig-java",
+    ],
     libs: [
         "androidx.annotation_annotation",
     ],
@@ -291,7 +295,6 @@
         ":framework-connectivity-t-pre-jarjar{.jar}",
         ":framework-connectivity.stubs.module_lib{.jar}",
         ":framework-connectivity-t.stubs.module_lib{.jar}",
-        ":framework-connectivity-module-api-stubs-including-flagged{.jar}",
         "jarjar-excludes.txt",
     ],
     tools: [
@@ -304,7 +307,6 @@
         "--prefix android.net.connectivity " +
         "--apistubs $(location :framework-connectivity.stubs.module_lib{.jar}) " +
         "--apistubs $(location :framework-connectivity-t.stubs.module_lib{.jar}) " +
-        "--apistubs $(location :framework-connectivity-module-api-stubs-including-flagged{.jar}) " +
         // Make a ":"-separated list. There will be an extra ":" but empty items are ignored.
         "--unsupportedapi $$(printf ':%s' $(locations :connectivity-hiddenapi-files)) " +
         "--excludes $(location jarjar-excludes.txt) " +
@@ -316,34 +318,6 @@
     ],
 }
 
-droidstubs {
-    name: "framework-connectivity-module-api-stubs-including-flagged-droidstubs",
-    srcs: [
-        ":framework-connectivity-sources",
-        ":framework-connectivity-tiramisu-updatable-sources",
-        ":framework-networksecurity-sources",
-        ":framework-nearby-java-sources",
-        ":framework-thread-sources",
-    ],
-    flags: [
-        "--show-for-stub-purposes-annotation android.annotation.SystemApi" +
-            "\\(client=android.annotation.SystemApi.Client.PRIVILEGED_APPS\\)",
-        "--show-for-stub-purposes-annotation android.annotation.SystemApi" +
-            "\\(client=android.annotation.SystemApi.Client.MODULE_LIBRARIES\\)",
-    ],
-    aidl: {
-        include_dirs: [
-            "packages/modules/Connectivity/framework/aidl-export",
-            "frameworks/native/aidl/binder", // For PersistableBundle.aidl
-        ],
-    },
-}
-
-java_library {
-    name: "framework-connectivity-module-api-stubs-including-flagged",
-    srcs: [":framework-connectivity-module-api-stubs-including-flagged-droidstubs"],
-}
-
 // Library providing limited APIs within the connectivity module, so that R+ components like
 // Tethering have a controlled way to depend on newer components like framework-connectivity that
 // are not loaded on R.
diff --git a/framework/api/current.txt b/framework/api/current.txt
index 7bc0cf3..323c533 100644
--- a/framework/api/current.txt
+++ b/framework/api/current.txt
@@ -103,6 +103,7 @@
     method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, int);
     method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.net.ConnectivityManager.NetworkCallback, @NonNull android.os.Handler, int);
     method public void requestNetwork(@NonNull android.net.NetworkRequest, @NonNull android.app.PendingIntent);
+    method @FlaggedApi("com.android.net.flags.ipv6_over_ble") public void reserveNetwork(@NonNull android.net.NetworkRequest, @NonNull android.os.Handler, @NonNull android.net.ConnectivityManager.NetworkCallback);
     method @Deprecated public void setNetworkPreference(int);
     method @Deprecated public static boolean setProcessDefaultNetwork(@Nullable android.net.Network);
     method public void unregisterNetworkCallback(@NonNull android.net.ConnectivityManager.NetworkCallback);
@@ -151,6 +152,7 @@
     method public void onLinkPropertiesChanged(@NonNull android.net.Network, @NonNull android.net.LinkProperties);
     method public void onLosing(@NonNull android.net.Network, int);
     method public void onLost(@NonNull android.net.Network);
+    method @FlaggedApi("com.android.net.flags.ipv6_over_ble") public void onReserved(@NonNull android.net.NetworkCapabilities);
     method public void onUnavailable();
     field public static final int FLAG_INCLUDE_LOCATION_INFO = 1; // 0x1
   }
@@ -231,6 +233,32 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.net.IpPrefix> CREATOR;
   }
 
+  @FlaggedApi("com.android.net.flags.ipv6_over_ble") public final class L2capNetworkSpecifier extends android.net.NetworkSpecifier implements android.os.Parcelable {
+    method public int describeContents();
+    method public int getHeaderCompression();
+    method public int getPsm();
+    method @Nullable public android.net.MacAddress getRemoteAddress();
+    method public int getRole();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.L2capNetworkSpecifier> CREATOR;
+    field public static final int HEADER_COMPRESSION_6LOWPAN = 2; // 0x2
+    field public static final int HEADER_COMPRESSION_ANY = 0; // 0x0
+    field public static final int HEADER_COMPRESSION_NONE = 1; // 0x1
+    field public static final int PSM_ANY = 0; // 0x0
+    field public static final int ROLE_ANY = 0; // 0x0
+    field public static final int ROLE_CLIENT = 1; // 0x1
+    field public static final int ROLE_SERVER = 2; // 0x2
+  }
+
+  public static final class L2capNetworkSpecifier.Builder {
+    ctor public L2capNetworkSpecifier.Builder();
+    method @NonNull public android.net.L2capNetworkSpecifier build();
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setHeaderCompression(int);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setPsm(@IntRange(from=0, to=255) int);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setRemoteAddress(@Nullable android.net.MacAddress);
+    method @NonNull public android.net.L2capNetworkSpecifier.Builder setRole(int);
+  }
+
   public class LinkAddress implements android.os.Parcelable {
     method public int describeContents();
     method public java.net.InetAddress getAddress();
diff --git a/framework/jni/android_net_NetworkUtils.cpp b/framework/jni/android_net_NetworkUtils.cpp
index 3779a00..7404f32 100644
--- a/framework/jni/android_net_NetworkUtils.cpp
+++ b/framework/jni/android_net_NetworkUtils.cpp
@@ -23,9 +23,9 @@
 #include <netinet/in.h>
 #include <string.h>
 
+#include <DnsProxydProtocol.h> // NETID_USE_LOCAL_NAMESERVERS
 #include <bpf/BpfClassic.h>
 #include <bpf/KernelUtils.h>
-#include <DnsProxydProtocol.h> // NETID_USE_LOCAL_NAMESERVERS
 #include <nativehelper/JNIPlatformHelp.h>
 #include <nativehelper/ScopedPrimitiveArray.h>
 #include <utils/Log.h>
@@ -259,6 +259,21 @@
     return bpf::isX86();
 }
 
+static jlong android_net_utils_getSocketCookie(JNIEnv *env, jclass clazz,
+                                               jobject javaFd) {
+    int sock = AFileDescriptor_getFd(env, javaFd);
+    uint64_t cookie = 0;
+    socklen_t cookie_len = sizeof(cookie);
+    if (getsockopt(sock, SOL_SOCKET, SO_COOKIE, &cookie, &cookie_len)) {
+        // Failure is almost certainly either EBADF or ENOTSOCK
+        jniThrowErrnoException(env, "getSocketCookie", errno);
+    } else if (cookie_len != sizeof(cookie)) {
+        // This probably cannot actually happen, but...
+        jniThrowErrnoException(env, "getSocketCookie", 523); // EBADCOOKIE
+    }
+    return static_cast<jlong>(cookie);
+}
+
 // ----------------------------------------------------------------------------
 
 /*
@@ -283,6 +298,7 @@
     (void*) android_net_utils_setsockoptBytes},
     { "isKernel64Bit", "()Z", (void*) android_net_utils_isKernel64Bit },
     { "isKernelX86", "()Z", (void*) android_net_utils_isKernelX86 },
+    { "getSocketCookie", "(Ljava/io/FileDescriptor;)J", (void*) android_net_utils_getSocketCookie },
 };
 // clang-format on
 
diff --git a/framework/src/android/net/BpfNetMapsConstants.java b/framework/src/android/net/BpfNetMapsConstants.java
index f3773de..f1a6f00 100644
--- a/framework/src/android/net/BpfNetMapsConstants.java
+++ b/framework/src/android/net/BpfNetMapsConstants.java
@@ -60,6 +60,11 @@
             "/sys/fs/bpf/netd_shared/map_netd_data_saver_enabled_map";
     public static final String INGRESS_DISCARD_MAP_PATH =
             "/sys/fs/bpf/netd_shared/map_netd_ingress_discard_map";
+    public static final String LOCAL_NET_ACCESS_MAP_PATH =
+            "/sys/fs/bpf/netd_shared/map_netd_local_net_access_map";
+    public static final String LOCAL_NET_BLOCKED_UID_MAP_PATH =
+            "/sys/fs/bpf/netd_shared/map_netd_local_net_blocked_uid_map";
+
     public static final Struct.S32 UID_RULES_CONFIGURATION_KEY = new Struct.S32(0);
     public static final Struct.S32 CURRENT_STATS_MAP_CONFIGURATION_KEY = new Struct.S32(1);
     public static final Struct.S32 DATA_SAVER_ENABLED_KEY = new Struct.S32(0);
diff --git a/framework/src/android/net/CaptivePortal.java b/framework/src/android/net/CaptivePortal.java
index 4a7b601..4c534f3 100644
--- a/framework/src/android/net/CaptivePortal.java
+++ b/framework/src/android/net/CaptivePortal.java
@@ -18,10 +18,19 @@
 import android.annotation.NonNull;
 import android.annotation.RequiresPermission;
 import android.annotation.SystemApi;
+import android.annotation.TargetApi;
+import android.os.Binder;
+import android.os.Build;
 import android.os.IBinder;
+import android.os.OutcomeReceiver;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.system.OsConstants;
+
+import java.util.Objects;
+import java.util.concurrent.Executor;
 
 /**
  * A class allowing apps handling the {@link ConnectivityManager#ACTION_CAPTIVE_PORTAL_SIGN_IN}
@@ -69,6 +78,15 @@
     @SystemApi
     public static final int APP_REQUEST_REEVALUATION_REQUIRED = APP_REQUEST_BASE + 0;
 
+    /**
+     * Binder object used for tracking the lifetime of the process, so CS can perform cleanup if
+     * the CaptivePortal app dies. This binder is not parcelled as part of this object. It is
+     * created in the client process and sent to the server by setDelegateUid so that the server
+     * can use it to register a death recipient.
+     *
+     */
+    private final Binder mLifetimeBinder = new Binder();
+
     private final IBinder mBinder;
 
     /** @hide */
@@ -167,4 +185,56 @@
     @SystemApi
     public void logEvent(int eventId, @NonNull String packageName) {
     }
+
+    /**
+     * Sets the UID of the app that is allowed to perform network traffic for captive
+     * portal login.
+     *
+     * This app will be allowed to communicate directly on the captive
+     * portal by binding to the {@link android.net.Network} extra passed in the
+     * ACTION_CAPTIVE_PORTAL_SIGN_IN broadcast that contained this object.
+     *
+     * Communication will bypass network access restrictions such as VPNs and
+     * Private DNS settings, so the delegated UID must be trusted to ensure that only
+     * traffic intended for captive portal login binds to that network.
+     *
+     * By default, no UID is delegated. The delegation can be cleared by calling
+     * this method again with {@link android.os.Process.INVALID_UID}. Only one UID can
+     * be delegated at any given time.
+     *
+     * The operation is asynchronous. The uid is only guaranteed to have access when
+     * the provided OutcomeReceiver is called.
+     *
+     * @hide
+     */
+    @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
+    // OutcomeReceiver is not available on R, but the mainline version of this
+    // class is only available on S+.
+    @TargetApi(Build.VERSION_CODES.S)
+    public void setDelegateUid(int uid, @NonNull Executor executor,
+            @NonNull final OutcomeReceiver<Void, ServiceSpecificException> receiver) {
+        Objects.requireNonNull(executor);
+        Objects.requireNonNull(receiver);
+        try {
+            ICaptivePortal.Stub.asInterface(mBinder).setDelegateUid(
+                    uid,
+                    mLifetimeBinder,
+                    new IIntResultListener.Stub() {
+                        @Override
+                        public void onResult(int resultCode) {
+                            if (resultCode != 0) {
+                                final String msg = "Fail to set the delegate UID " + uid
+                                        + ", error: " + OsConstants.errnoName(resultCode);
+                                executor.execute(() -> {
+                                    receiver.onError(new ServiceSpecificException(resultCode, msg));
+                                });
+                            } else {
+                                executor.execute(() -> receiver.onResult(null));
+                            }
+                        }
+                    });
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 1ebc4a3..f8a1293 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -21,6 +21,7 @@
 import static android.net.NetworkRequest.Type.LISTEN;
 import static android.net.NetworkRequest.Type.LISTEN_FOR_BEST;
 import static android.net.NetworkRequest.Type.REQUEST;
+import static android.net.NetworkRequest.Type.RESERVATION;
 import static android.net.NetworkRequest.Type.TRACK_DEFAULT;
 import static android.net.NetworkRequest.Type.TRACK_SYSTEM_DEFAULT;
 import static android.net.QosCallback.QosCallbackRegistrationException;
@@ -1199,11 +1200,14 @@
 
     /** @hide */
     public static final long FEATURE_USE_DECLARED_METHODS_FOR_CALLBACKS = 1L;
+    /** @hide */
+    public static final long FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER = 1L << 1;
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @LongDef(flag = true, prefix = "FEATURE_", value = {
-            FEATURE_USE_DECLARED_METHODS_FOR_CALLBACKS
+            FEATURE_USE_DECLARED_METHODS_FOR_CALLBACKS,
+            FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER
     })
     public @interface ConnectivityManagerFeature {}
 
@@ -1241,6 +1245,26 @@
     @ConnectivityManagerFeature
     private Long mEnabledConnectivityManagerFeatures = null;
 
+    /**
+     * A class to help with mocking ConnectivityManager.
+     * @hide
+     */
+    public static class MockHelpers {
+        /**
+         * Produce an instance of the class returned by
+         * {@link ConnectivityManager#registerNetworkAgent}
+         * @hide
+         */
+        public static NetworkAndAgentRegistryParcelable registerNetworkAgentResult(
+                @Nullable final Network network, @Nullable final INetworkAgentRegistry registry) {
+            final NetworkAndAgentRegistryParcelable result =
+                    new NetworkAndAgentRegistryParcelable();
+            result.network = network;
+            result.registry = registry;
+            return result;
+        }
+    }
+
     private TetheringManager getTetheringManager() {
         synchronized (mTetheringEventCallbacks) {
             if (mTetheringManager == null) {
@@ -1873,7 +1897,7 @@
     public NetworkCapabilities[] getDefaultNetworkCapabilitiesForUser(int userId) {
         try {
             return mService.getDefaultNetworkCapabilitiesForUser(
-                    userId, mContext.getOpPackageName(), getAttributionTag());
+                    userId, mContext.getOpPackageName(), mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -1967,7 +1991,7 @@
             @NonNull String packageName) {
         try {
             return mService.getRedactedLinkPropertiesForPackage(
-                    lp, uid, packageName, getAttributionTag());
+                    lp, uid, packageName, mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -1993,7 +2017,7 @@
     public NetworkCapabilities getNetworkCapabilities(@Nullable Network network) {
         try {
             return mService.getNetworkCapabilities(
-                    network, mContext.getOpPackageName(), getAttributionTag());
+                    network, mContext.getOpPackageName(), mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -2027,7 +2051,7 @@
             int uid, @NonNull String packageName) {
         try {
             return mService.getRedactedNetworkCapabilitiesForPackage(nc, uid, packageName,
-                    getAttributionTag());
+                    mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -2752,21 +2776,13 @@
         checkLegacyRoutingApiAccess();
         try {
             return mService.requestRouteToHostAddress(networkType, hostAddress.getAddress(),
-                    mContext.getOpPackageName(), getAttributionTag());
+                    mContext.getOpPackageName(), mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
     }
 
     /**
-     * @return the context's attribution tag
-     */
-    // TODO: Remove method and replace with direct call once R code is pushed to AOSP
-    private @Nullable String getAttributionTag() {
-        return mContext.getAttributionTag();
-    }
-
-    /**
      * Returns the value of the setting for background data usage. If false,
      * applications should not use the network if the application is not in the
      * foreground. Developers should respect this setting, and check the value
@@ -3072,7 +3088,8 @@
      * <p>WARNING: New clients should not use this function. The only usages should be in PanService
      * and WifiStateMachine which need direct access. All other clients should use
      * {@link #startTethering} and {@link #stopTethering} which encapsulate proper provisioning
-     * logic.</p>
+     * logic. On SDK versions after {@link Build.VERSION_CODES.VANILLA_ICE_CREAM}, this will throw
+     * an UnsupportedOperationException.</p>
      *
      * @param iface the interface name to tether.
      * @return error a {@code TETHER_ERROR} value indicating success or failure type
@@ -3097,7 +3114,8 @@
      * <p>WARNING: New clients should not use this function. The only usages should be in PanService
      * and WifiStateMachine which need direct access. All other clients should use
      * {@link #startTethering} and {@link #stopTethering} which encapsulate proper provisioning
-     * logic.</p>
+     * logic. On SDK versions after {@link Build.VERSION_CODES.VANILLA_ICE_CREAM}, this will throw
+     * an UnsupportedOperationException.</p>
      *
      * @param iface the interface name to untether.
      * @return error a {@code TETHER_ERROR} value indicating success or failure type
@@ -3957,7 +3975,8 @@
     @RequiresPermission(anyOf = {
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
             android.Manifest.permission.NETWORK_FACTORY})
-    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+    public NetworkAndAgentRegistryParcelable registerNetworkAgent(
+            @NonNull INetworkAgent na, @NonNull NetworkInfo ni,
             @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
             @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId) {
         return registerNetworkAgent(na, ni, lp, nc, null /* localNetworkConfig */, score, config,
@@ -3972,7 +3991,8 @@
     @RequiresPermission(anyOf = {
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
             android.Manifest.permission.NETWORK_FACTORY})
-    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+    public NetworkAndAgentRegistryParcelable registerNetworkAgent(
+            @NonNull INetworkAgent na, @NonNull NetworkInfo ni,
             @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
             @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
             @NonNull NetworkAgentConfig config, int providerId) {
@@ -4279,12 +4299,18 @@
         private static final int METHOD_ONLOST = 6;
 
         /**
-         * Called if no network is found within the timeout time specified in
-         * {@link #requestNetwork(NetworkRequest, NetworkCallback, int)} call or if the
-         * requested network request cannot be fulfilled (whether or not a timeout was
-         * specified). When this callback is invoked the associated
-         * {@link NetworkRequest} will have already been removed and released, as if
-         * {@link #unregisterNetworkCallback(NetworkCallback)} had been called.
+         * If the callback was registered with one of the {@code requestNetwork} methods, this will
+         * be called if no network is found within the timeout specified in {@link
+         * #requestNetwork(NetworkRequest, NetworkCallback, int)} call or if the requested network
+         * request cannot be fulfilled (whether or not a timeout was specified).
+         *
+         * If the callback was registered when reserving a network, this method indicates that the
+         * reservation is removed. It can be called when the reservation is requested, because the
+         * system could not satisfy the reservation, or after the reserved network connects.
+         *
+         * When this callback is invoked the associated {@link NetworkRequest} will have already
+         * been removed and released, as if {@link #unregisterNetworkCallback(NetworkCallback)} had
+         * been called.
          */
         @FilteredCallback(methodId = METHOD_ONUNAVAILABLE, calledByCallbackId = CALLBACK_UNAVAIL)
         public void onUnavailable() {}
@@ -4425,6 +4451,28 @@
         }
         private static final int METHOD_ONBLOCKEDSTATUSCHANGED_INT = 14;
 
+        /**
+         * Called when a network is reserved.
+         *
+         * The reservation includes the {@link NetworkCapabilities} that uniquely describe the
+         * network that was reserved. the caller communicates this information to hardware or
+         * software components on or off-device to instruct them to create a network matching this
+         * reservation.
+         *
+         * {@link #onReserved(NetworkCapabilities)} is called at most once and is guaranteed to be
+         * called before any other callback unless the reservation is unavailable.
+         *
+         * Once a reservation is made, the reserved {@link NetworkCapabilities} will not be updated,
+         * and the reservation remains in place until the reserved network connects or {@link
+         * #onUnavailable} is called.
+         *
+         * @param networkCapabilities The {@link NetworkCapabilities} of the reservation.
+         */
+        @FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
+        @FilteredCallback(methodId = METHOD_ONRESERVED, calledByCallbackId = CALLBACK_RESERVED)
+        public void onReserved(@NonNull NetworkCapabilities networkCapabilities) {}
+        private static final int METHOD_ONRESERVED = 15;
+
         private NetworkRequest networkRequest;
         private final int mFlags;
     }
@@ -4476,6 +4524,8 @@
     public static final int CALLBACK_BLK_CHANGED                = 11;
     /** @hide */
     public static final int CALLBACK_LOCAL_NETWORK_INFO_CHANGED = 12;
+    /** @hide */
+    public static final int CALLBACK_RESERVED                   = 13;
     // When adding new IDs, note CallbackQueue assumes callback IDs are at most 16 bits.
 
 
@@ -4495,6 +4545,7 @@
             case CALLBACK_RESUMED:      return "CALLBACK_RESUMED";
             case CALLBACK_BLK_CHANGED:  return "CALLBACK_BLK_CHANGED";
             case CALLBACK_LOCAL_NETWORK_INFO_CHANGED: return "CALLBACK_LOCAL_NETWORK_INFO_CHANGED";
+            case CALLBACK_RESERVED:     return "CALLBACK_RESERVED";
             default:
                 return Integer.toString(whichCallback);
         }
@@ -4525,6 +4576,7 @@
     public static class NetworkCallbackMethodsHolder {
         public static final NetworkCallbackMethod[] NETWORK_CB_METHODS =
                 new NetworkCallbackMethod[] {
+                        method("onReserved", 1 << CALLBACK_RESERVED, NetworkCapabilities.class),
                         method("onPreCheck", 1 << CALLBACK_PRECHECK, Network.class),
                         // Note the final overload of onAvailable is not included, since it cannot
                         // match any overridden method.
@@ -4604,6 +4656,11 @@
             }
 
             switch (message.what) {
+                case CALLBACK_RESERVED: {
+                    final NetworkCapabilities cap = getObject(message, NetworkCapabilities.class);
+                    callback.onReserved(cap);
+                    break;
+                }
                 case CALLBACK_PRECHECK: {
                     callback.onPreCheck(network);
                     break;
@@ -4705,12 +4762,12 @@
                 if (reqType == LISTEN) {
                     request = mService.listenForNetwork(
                             need, messenger, binder, callbackFlags, callingPackageName,
-                            getAttributionTag(), declaredMethodsFlag);
+                            mContext.getAttributionTag(), declaredMethodsFlag);
                 } else {
                     request = mService.requestNetwork(
                             asUid, need, reqType.ordinal(), messenger, timeoutMs, binder,
-                            legacyType, callbackFlags, callingPackageName, getAttributionTag(),
-                            declaredMethodsFlag);
+                            legacyType, callbackFlags, callingPackageName,
+                            mContext.getAttributionTag(), declaredMethodsFlag);
                 }
                 if (request != null) {
                     sCallbacks.put(request, callback);
@@ -4827,7 +4884,8 @@
         return 0;
     }
 
-    private boolean isFeatureEnabled(@ConnectivityManagerFeature long connectivityManagerFeature) {
+    /** @hide */
+    public boolean isFeatureEnabled(@ConnectivityManagerFeature long connectivityManagerFeature) {
         synchronized (mEnabledConnectivityManagerFeaturesLock) {
             if (mEnabledConnectivityManagerFeatures == null) {
                 try {
@@ -4985,6 +5043,41 @@
     }
 
     /**
+     * Reserve a network to satisfy a set of {@link NetworkCapabilities}.
+     *
+     * Some types of networks require the system to generate (i.e. reserve) some set of information
+     * before a network can be connected. For such networks, {@link #reserveNetwork} can be used
+     * which may lead to a call to {@link NetworkCallback#onReserved(NetworkCapabilities)}
+     * containing the {@link NetworkCapabilities} that were reserved.
+     *
+     * A reservation reserves at most one network. If the network connects, a reservation request
+     * behaves similar to a request filed using {@link #requestNetwork}. The provided {@link
+     * NetworkCallback} will only be called for the reserved network.
+     *
+     * If the system determines that the requested reservation can never be fulfilled, {@link
+     * NetworkCallback#onUnavailable} is called, the reservation is released by the system, and the
+     * provided callback can be reused. Otherwise, the reservation remains in place until the
+     * requested network connects. There is no guarantee that the reserved network will ever
+     * connect.
+     *
+     * @param request {@link NetworkRequest} describing this request.
+     * @param handler {@link Handler} to specify the thread upon which the callback will be invoked.
+     * @param networkCallback The {@link NetworkCallback} to be utilized for this request. Note
+     *                        the callback must not be shared - it uniquely specifies this request.
+     */
+    // TODO: add executor overloads for all network request methods. Any method that passed an
+    // Executor could process the messages on the singleton ConnectivityThread Handler.
+    @SuppressLint("ExecutorRegistration")
+    @FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
+    public void reserveNetwork(@NonNull NetworkRequest request,
+            @NonNull Handler handler,
+            @NonNull NetworkCallback networkCallback) {
+        final CallbackHandler cbHandler = new CallbackHandler(handler);
+        final NetworkCapabilities nc = request.networkCapabilities;
+        sendRequestForNetwork(nc, networkCallback, 0, RESERVATION, TYPE_NONE, cbHandler);
+    }
+
+    /**
      * Request a network to satisfy a set of {@link NetworkCapabilities}, limited
      * by a timeout.
      *
@@ -5127,7 +5220,7 @@
         try {
             mService.pendingRequestForNetwork(
                     request.networkCapabilities, operation, mContext.getOpPackageName(),
-                    getAttributionTag());
+                    mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         } catch (ServiceSpecificException e) {
@@ -5276,7 +5369,7 @@
         try {
             mService.pendingListenForNetwork(
                     request.networkCapabilities, operation, mContext.getOpPackageName(),
-                    getAttributionTag());
+                    mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         } catch (ServiceSpecificException e) {
diff --git a/framework/src/android/net/ICaptivePortal.aidl b/framework/src/android/net/ICaptivePortal.aidl
index e35f8d4..5cbb428 100644
--- a/framework/src/android/net/ICaptivePortal.aidl
+++ b/framework/src/android/net/ICaptivePortal.aidl
@@ -16,6 +16,9 @@
 
 package android.net;
 
+import android.net.IIntResultListener;
+import android.os.IBinder;
+
 /**
  * Interface to inform NetworkMonitor of decisions of app handling captive portal.
  * @hide
@@ -23,4 +26,5 @@
 oneway interface ICaptivePortal {
     void appRequest(int request);
     void appResponse(int response);
+    void setDelegateUid(int uid, IBinder binder, IIntResultListener listener);
 }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index 47b3316..a270684 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -30,6 +30,7 @@
 import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkAgentConfig;
+import android.net.NetworkAndAgentRegistryParcelable;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
 import android.net.NetworkRequest;
@@ -146,7 +147,8 @@
 
     void declareNetworkRequestUnfulfillable(in NetworkRequest request);
 
-    Network registerNetworkAgent(in INetworkAgent na, in NetworkInfo ni, in LinkProperties lp,
+    NetworkAndAgentRegistryParcelable registerNetworkAgent(
+            in INetworkAgent na, in NetworkInfo ni, in LinkProperties lp,
             in NetworkCapabilities nc, in NetworkScore score,
             in LocalNetworkConfig localNetworkConfig, in NetworkAgentConfig config,
             in int factorySerialNumber);
diff --git a/framework/src/android/net/INetworkAgent.aidl b/framework/src/android/net/INetworkAgent.aidl
index fa5175c..c6beeca 100644
--- a/framework/src/android/net/INetworkAgent.aidl
+++ b/framework/src/android/net/INetworkAgent.aidl
@@ -26,7 +26,7 @@
  * @hide
  */
 oneway interface INetworkAgent {
-    void onRegistered(in INetworkAgentRegistry registry);
+    void onRegistered();
     void onDisconnected();
     void onBandwidthUpdateRequested();
     void onValidationStatusChanged(int validationStatus,
diff --git a/framework/src/android/net/INetworkAgentRegistry.aidl b/framework/src/android/net/INetworkAgentRegistry.aidl
index 61b27b5..afdd1ee 100644
--- a/framework/src/android/net/INetworkAgentRegistry.aidl
+++ b/framework/src/android/net/INetworkAgentRegistry.aidl
@@ -30,7 +30,7 @@
  * Interface for NetworkAgents to send network properties.
  * @hide
  */
-oneway interface INetworkAgentRegistry {
+interface INetworkAgentRegistry {
     void sendNetworkCapabilities(in NetworkCapabilities nc);
     void sendLinkProperties(in LinkProperties lp);
     // TODO: consider replacing this by "markConnected()" and removing
diff --git a/framework/src/android/net/L2capNetworkSpecifier.java b/framework/src/android/net/L2capNetworkSpecifier.java
new file mode 100644
index 0000000..93f9352
--- /dev/null
+++ b/framework/src/android/net/L2capNetworkSpecifier.java
@@ -0,0 +1,415 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntDef;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.net.flags.Flags;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/**
+ * A {@link NetworkSpecifier} used to identify an L2CAP network over BLE.
+ *
+ * An L2CAP network is not symmetrical, meaning there exists both a server (Bluetooth peripheral)
+ * and a client (Bluetooth central) node. This specifier contains the information required to
+ * request a client L2CAP network using {@link ConnectivityManager#requestNetwork} while specifying
+ * the remote MAC address, and Protocol/Service Multiplexer (PSM). It can also contain information
+ * allocated by the system when reserving a server network using {@link
+ * ConnectivityManager#reserveNetwork} such as the Protocol/Service Multiplexer (PSM). In both
+ * cases, the header compression option must be specified.
+ *
+ * An L2CAP server network allocates a Protocol/Service Multiplexer (PSM) to be advertised to the
+ * client. A new server network must always be reserved using {@code
+ * ConnectivityManager#reserveNetwork}. The subsequent {@link
+ * ConnectivityManager.NetworkCallback#onReserved(NetworkCapabilities)} callback includes an {@code
+ * L2CapNetworkSpecifier}. The {@link getPsm()} method will return the Protocol/Service Multiplexer
+ * (PSM) of the reserved network so that the server can advertise it to the client and the client
+ * can connect.
+ * An L2CAP server network is backed by a {@link android.bluetooth.BluetoothServerSocket} which can,
+ * in theory, accept many connections. However, before SDK version {@link
+ * Build.VERSION_CODES.VANILLA_ICE_CREAM} Bluetooth APIs do not expose the channel ID, so these
+ * connections are indistinguishable. In practice, this means that the network matching semantics in
+ * ConnectivityService will tear down all but the first connection.
+ *
+ * When the connection between client and server completes, a {@link Network} whose capabilities
+ * satisfy this {@code L2capNetworkSpecifier} will connect and the usual callbacks, such as {@link
+ * NetworkCallback#onAvailable}, will be called on the callback object passed to {@code
+ * ConnectivityManager#reserveNetwork} or {@code ConnectivityManager#requestNetwork}.
+ */
+@FlaggedApi(Flags.FLAG_IPV6_OVER_BLE)
+public final class L2capNetworkSpecifier extends NetworkSpecifier implements Parcelable {
+    /**
+     * Match any role.
+     *
+     * This role is only meaningful in {@link NetworkRequest}s. Specifiers for actual L2CAP
+     * networks never have this role set.
+     */
+    public static final int ROLE_ANY = 0;
+    /** Specifier describes a client network, i.e., the device is the Bluetooth central. */
+    public static final int ROLE_CLIENT = 1;
+    /** Specifier describes a server network, i.e., the device is the Bluetooth peripheral. */
+    public static final int ROLE_SERVER = 2;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = false, prefix = "ROLE_", value = {
+        ROLE_ANY,
+        ROLE_CLIENT,
+        ROLE_SERVER
+    })
+    public @interface Role {}
+    /** Role used to distinguish client from server networks. */
+    @Role
+    private final int mRole;
+
+    /**
+     * Accept any form of header compression.
+     *
+     * This option is only meaningful in {@link NetworkRequest}s. Specifiers for actual L2CAP
+     * networks never have this option set.
+     */
+    public static final int HEADER_COMPRESSION_ANY = 0;
+    /** Do not compress packets on this network. */
+    public static final int HEADER_COMPRESSION_NONE = 1;
+    /** Use 6lowpan header compression as specified in rfc6282. */
+    public static final int HEADER_COMPRESSION_6LOWPAN = 2;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(flag = false, prefix = "HEADER_COMPRESSION_", value = {
+        HEADER_COMPRESSION_ANY,
+        HEADER_COMPRESSION_NONE,
+        HEADER_COMPRESSION_6LOWPAN
+    })
+    public @interface HeaderCompression {}
+    /** Header compression mechanism used on this network. */
+    @HeaderCompression
+    private final int mHeaderCompression;
+
+    /** The MAC address of the remote. */
+    @Nullable
+    private final MacAddress mRemoteAddress;
+
+    /**
+     * Match any Protocol/Service Multiplexer (PSM).
+     *
+     * This PSM value is only meaningful in {@link NetworkRequest}s. Specifiers for actual L2CAP
+     * networks never have this value set.
+     */
+    public static final int PSM_ANY = 0;
+
+    /** The Bluetooth L2CAP Protocol/Service Multiplexer (PSM). */
+    private final int mPsm;
+
+    private L2capNetworkSpecifier(Parcel in) {
+        mRole = in.readInt();
+        mHeaderCompression = in.readInt();
+        mRemoteAddress = in.readParcelable(getClass().getClassLoader());
+        mPsm = in.readInt();
+    }
+
+    /** @hide */
+    public L2capNetworkSpecifier(@Role int role, @HeaderCompression int headerCompression,
+            MacAddress remoteAddress, int psm) {
+        mRole = role;
+        mHeaderCompression = headerCompression;
+        mRemoteAddress = remoteAddress;
+        mPsm = psm;
+    }
+
+    /** Returns the role to be used for this network. */
+    @Role
+    public int getRole() {
+        return mRole;
+    }
+
+    /** Returns the compression mechanism for this network. */
+    @HeaderCompression
+    public int getHeaderCompression() {
+        return mHeaderCompression;
+    }
+
+    /**
+     * Returns the remote MAC address for this network to connect to.
+     *
+     * The remote address is only meaningful for networks that have ROLE_CLIENT.
+     *
+     * When receiving this {@link L2capNetworkSpecifier} from Connectivity APIs such as a {@link
+     * ConnectivityManager.NetworkCallback}, the MAC address is redacted.
+     */
+    public @Nullable MacAddress getRemoteAddress() {
+        return mRemoteAddress;
+    }
+
+    /** Returns the Protocol/Service Multiplexer (PSM) for this network to connect to. */
+    public int getPsm() {
+        return mPsm;
+    }
+
+    /**
+     * Checks whether the given L2capNetworkSpecifier is valid as part of a server network
+     * reservation request.
+     *
+     * @hide
+     */
+    public boolean isValidServerReservationSpecifier() {
+        // The ROLE_SERVER offer can be satisfied by a ROLE_ANY request.
+        if (mRole != ROLE_SERVER) return false;
+
+        // HEADER_COMPRESSION_ANY is never valid in a request.
+        if (mHeaderCompression == HEADER_COMPRESSION_ANY) return false;
+
+        // Remote address must be null for ROLE_SERVER requests.
+        if (mRemoteAddress != null) return false;
+
+        // reservation must allocate a PSM, so only PSM_ANY can be passed.
+        if (mPsm != PSM_ANY) return false;
+
+        return true;
+    }
+
+    /**
+     * Checks whether the given L2capNetworkSpecifier is valid as part of a client network request.
+     *
+     * @hide
+     */
+    public boolean isValidClientRequestSpecifier() {
+        // The ROLE_CLIENT offer can be satisfied by a ROLE_ANY request.
+        if (mRole != ROLE_CLIENT) return false;
+
+        // HEADER_COMPRESSION_ANY is never valid in a request.
+        if (mHeaderCompression == HEADER_COMPRESSION_ANY) return false;
+
+        // Remote address must not be null for ROLE_CLIENT requests.
+        if (mRemoteAddress == null) return false;
+
+        // Client network requests require a PSM to be specified.
+        // Ensure the PSM is within the valid range of dynamic BLE L2CAP values.
+        if (mPsm < 0x80) return false;
+        if (mPsm > 0xFF) return false;
+
+        return true;
+    }
+
+    /** A builder class for L2capNetworkSpecifier. */
+    public static final class Builder {
+        @Role
+        private int mRole = ROLE_ANY;
+        @HeaderCompression
+        private int mHeaderCompression = HEADER_COMPRESSION_ANY;
+        @Nullable
+        private MacAddress mRemoteAddress;
+        private int mPsm = PSM_ANY;
+
+        /**
+         * Set the role to use for this network.
+         *
+         * If not set, defaults to {@link ROLE_ANY}.
+         *
+         * @param role the role to use.
+         */
+        @NonNull
+        public Builder setRole(@Role int role) {
+            mRole = role;
+            return this;
+        }
+
+        /**
+         * Set the header compression mechanism to use for this network.
+         *
+         * If not set, defaults to {@link HEADER_COMPRESSION_ANY}. This option must be specified
+         * (i.e. must not be set to {@link HEADER_COMPRESSION_ANY}) when requesting or reserving a
+         * new network.
+         *
+         * @param headerCompression the header compression mechanism to use.
+         */
+        @NonNull
+        public Builder setHeaderCompression(@HeaderCompression int headerCompression) {
+            mHeaderCompression = headerCompression;
+            return this;
+        }
+
+        /**
+         * Set the remote address for the client to connect to.
+         *
+         * Only valid for client networks. If not set, the specifier matches any MAC address.
+         *
+         * @param remoteAddress the MAC address to connect to, or null to match any MAC address.
+         */
+        @NonNull
+        public Builder setRemoteAddress(@Nullable MacAddress remoteAddress) {
+            mRemoteAddress = remoteAddress;
+            return this;
+        }
+
+        /**
+         * Set the Protocol/Service Multiplexer (PSM) for the client to connect to.
+         *
+         * If not set, defaults to {@link PSM_ANY}.
+         *
+         * @param psm the Protocol/Service Multiplexer (PSM) to connect to.
+         */
+        @NonNull
+        public Builder setPsm(@IntRange(from = 0, to = 255) int psm) {
+            if (psm < 0 /* PSM_ANY */ || psm > 0xFF) {
+                throw new IllegalArgumentException("PSM must be PSM_ANY or within range [1, 255]");
+            }
+            mPsm = psm;
+            return this;
+        }
+
+        /** Create the L2capNetworkSpecifier object. */
+        @NonNull
+        public L2capNetworkSpecifier build() {
+            if (mRole == ROLE_SERVER && mRemoteAddress != null) {
+                throw new IllegalArgumentException(
+                        "Specifying a remote address is not valid for server role.");
+            }
+            return new L2capNetworkSpecifier(mRole, mHeaderCompression, mRemoteAddress, mPsm);
+        }
+    }
+
+    /** @hide */
+    @Override
+    public boolean canBeSatisfiedBy(NetworkSpecifier other) {
+        if (!(other instanceof L2capNetworkSpecifier)) return false;
+        final L2capNetworkSpecifier rhs = (L2capNetworkSpecifier) other;
+
+        // A network / offer cannot be ROLE_ANY, but it is added for consistency.
+        if (mRole != rhs.mRole && mRole != ROLE_ANY && rhs.mRole != ROLE_ANY) {
+            return false;
+        }
+
+        if (mHeaderCompression != rhs.mHeaderCompression
+                && mHeaderCompression != HEADER_COMPRESSION_ANY
+                && rhs.mHeaderCompression != HEADER_COMPRESSION_ANY) {
+            return false;
+        }
+
+        if (!Objects.equals(mRemoteAddress, rhs.mRemoteAddress)
+                && mRemoteAddress != null && rhs.mRemoteAddress != null) {
+            return false;
+        }
+
+        if (mPsm != rhs.mPsm && mPsm != PSM_ANY && rhs.mPsm != PSM_ANY) {
+            return false;
+        }
+        return true;
+    }
+
+    /** @hide */
+    @Override
+    @Nullable
+    public NetworkSpecifier redact() {
+        final NetworkSpecifier redactedSpecifier = new Builder()
+                .setRole(mRole)
+                .setHeaderCompression(mHeaderCompression)
+                // The remote address is redacted.
+                .setRemoteAddress(null)
+                .setPsm(mPsm)
+                .build();
+        return redactedSpecifier;
+    }
+
+    /** @hide */
+    @Override
+    public int hashCode() {
+        return Objects.hash(mRole, mHeaderCompression, mRemoteAddress, mPsm);
+    }
+
+    /** @hide */
+    public boolean equals(Object obj) {
+        if (this == obj) return true;
+        if (!(obj instanceof L2capNetworkSpecifier)) return false;
+
+        final L2capNetworkSpecifier rhs = (L2capNetworkSpecifier) obj;
+        return mRole == rhs.mRole
+                && mHeaderCompression == rhs.mHeaderCompression
+                && Objects.equals(mRemoteAddress, rhs.mRemoteAddress)
+                && mPsm == rhs.mPsm;
+    }
+
+    /** @hide */
+    @Override
+    public String toString() {
+        final String role;
+        switch (mRole) {
+            case ROLE_CLIENT:
+                role = "ROLE_CLIENT";
+                break;
+            case ROLE_SERVER:
+                role = "ROLE_SERVER";
+                break;
+            default:
+                role = "ROLE_ANY";
+                break;
+        }
+
+        final String headerCompression;
+        switch (mHeaderCompression) {
+            case HEADER_COMPRESSION_NONE:
+                headerCompression = "HEADER_COMPRESSION_NONE";
+                break;
+            case HEADER_COMPRESSION_6LOWPAN:
+                headerCompression = "HEADER_COMPRESSION_6LOWPAN";
+                break;
+            default:
+                headerCompression = "HEADER_COMPRESSION_ANY";
+                break;
+        }
+
+        final String psm = (mPsm == PSM_ANY) ? "PSM_ANY" : String.valueOf(mPsm);
+
+        return String.format("L2capNetworkSpecifier(%s, %s, RemoteAddress=%s, PSM=%s)",
+                role, headerCompression, Objects.toString(mRemoteAddress), psm);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeInt(mRole);
+        dest.writeInt(mHeaderCompression);
+        dest.writeParcelable(mRemoteAddress, flags);
+        dest.writeInt(mPsm);
+    }
+
+    public static final @NonNull Creator<L2capNetworkSpecifier> CREATOR = new Creator<>() {
+        @Override
+        public L2capNetworkSpecifier createFromParcel(Parcel in) {
+            return new L2capNetworkSpecifier(in);
+        }
+
+        @Override
+        public L2capNetworkSpecifier[] newArray(int size) {
+            return new L2capNetworkSpecifier[size];
+        }
+    };
+}
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index cefa1ea..d5a1ef3 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -37,6 +37,7 @@
 import android.telephony.data.NrQosSessionAttributes;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.FrameworkConnectivityStatsLog;
 
@@ -98,6 +99,7 @@
     @Nullable
     private volatile Network mNetwork;
 
+    // Null before the agent is registered
     @Nullable
     private volatile INetworkAgentRegistry mRegistry;
 
@@ -115,12 +117,32 @@
     private final ArrayList<RegistryAction> mPreConnectedQueue = new ArrayList<>();
     private volatile long mLastBwRefreshTime = 0;
     private static final long BW_REFRESH_MIN_WIN_MS = 500;
+
+    private final boolean mQueueRemoved;
+
     private boolean mBandwidthUpdateScheduled = false;
     private AtomicBoolean mBandwidthUpdatePending = new AtomicBoolean(false);
     @NonNull
     private NetworkInfo mNetworkInfo;
     @NonNull
     private final Object mRegisterLock = new Object();
+    // TODO : when ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER is
+    // not chickened out this is never read. Remove when retiring this flag.
+    private boolean mConnected = false;
+
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "STATE_" }, value = {
+        STATE_CREATED,
+        STATE_REGISTERED,
+        STATE_UNREGISTERED
+    })
+    public @interface NetworkAgentState {}
+    private static final int STATE_CREATED = 0;
+    private static final int STATE_REGISTERED = 1;
+    private static final int STATE_UNREGISTERED = 2;
+    @GuardedBy("mRegisterLock")
+    private int mState = STATE_CREATED;
 
     /**
      * The ID of the {@link NetworkProvider} that created this object, or
@@ -503,6 +525,18 @@
         return ni;
     }
 
+    /**
+     * Returns whether a given ConnectivityManager feature is enabled.
+     *
+     * Tests can override this.
+     * @hide
+     */
+    @VisibleForTesting
+    public boolean isFeatureEnabled(@NonNull Context context,
+            @ConnectivityManager.ConnectivityManagerFeature long feature) {
+        return context.getSystemService(ConnectivityManager.class).isFeatureEnabled(feature);
+    }
+
     // Temporary backward compatibility constructor
     public NetworkAgent(@NonNull Context context, @NonNull Looper looper, @NonNull String logTag,
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp, int score,
@@ -585,6 +619,10 @@
             @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
             @NonNull NetworkAgentConfig config, int providerId, @NonNull NetworkInfo ni) {
         mHandler = new NetworkAgentHandler(looper);
+        // If the feature is enabled, then events are queued in the system
+        // server, and it's removed from this NetworkAgent.
+        mQueueRemoved = isFeatureEnabled(context,
+                ConnectivityManager.FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER);
         LOG_TAG = logTag;
         mNetworkInfo = new NetworkInfo(ni);
         this.providerId = providerId;
@@ -606,22 +644,30 @@
         public void handleMessage(Message msg) {
             switch (msg.what) {
                 case EVENT_AGENT_CONNECTED: {
-                    if (mRegistry != null) {
-                        log("Received new connection while already connected!");
+                    if (mQueueRemoved) {
+                        // No handling. This message is legacy from a time where the
+                        // agent had to wait until the registry was sent to it, which
+                        // would only happen after the corresponding NetworkMonitor
+                        // was created.
+                        mConnected = true; // never read, but mConnected = false would be confusing
                     } else {
-                        if (VDBG) log("NetworkAgent fully connected");
-                        synchronized (mPreConnectedQueue) {
-                            final INetworkAgentRegistry registry = (INetworkAgentRegistry) msg.obj;
-                            mRegistry = registry;
-                            for (RegistryAction a : mPreConnectedQueue) {
-                                try {
-                                    a.execute(registry);
-                                } catch (RemoteException e) {
-                                    Log.wtf(LOG_TAG, "Communication error with registry", e);
-                                    // Fall through
+                        // Feature chickened out, keep the old queueing behavior
+                        synchronized (mRegisterLock) {
+                            if (mConnected) {
+                                log("Received new connection while already connected!");
+                            } else {
+                                if (VDBG) log("NetworkAgent fully connected");
+                                for (RegistryAction a : mPreConnectedQueue) {
+                                    try {
+                                        a.execute(mRegistry);
+                                    } catch (RemoteException e) {
+                                        Log.wtf(LOG_TAG, "Communication error with registry", e);
+                                        // Fall through
+                                    }
                                 }
+                                mPreConnectedQueue.clear();
                             }
-                            mPreConnectedQueue.clear();
+                            mConnected = true;
                         }
                     }
                     break;
@@ -630,8 +676,9 @@
                     if (DBG) log("NetworkAgent channel lost");
                     // let the client know CS is done with us.
                     onNetworkUnwanted();
-                    synchronized (mPreConnectedQueue) {
-                        mRegistry = null;
+                    synchronized (mRegisterLock) {
+                        mState = STATE_UNREGISTERED;
+                        mConnected = false;
                     }
                     break;
                 }
@@ -753,25 +800,49 @@
     public Network register() {
         if (VDBG) log("Registering NetworkAgent");
         synchronized (mRegisterLock) {
-            if (mNetwork != null) {
-                throw new IllegalStateException("Agent already registered");
+            if (mQueueRemoved) {
+                switch (mState) {
+                    case STATE_REGISTERED:
+                        throw new IllegalStateException("Agent already registered");
+                    case STATE_UNREGISTERED:
+                        throw new IllegalStateException("Agent already unregistered");
+                    default: // CREATED, this is the normal case
+                }
+            } else {
+                // Feature is chickened out, do the old processing
+                if (mNetwork != null) {
+                    throw new IllegalStateException("Agent already registered");
+                }
             }
             final ConnectivityManager cm = (ConnectivityManager) mInitialConfiguration.context
                     .getSystemService(Context.CONNECTIVITY_SERVICE);
+            final NetworkAndAgentRegistryParcelable result;
             if (mInitialConfiguration.localNetworkConfig == null) {
                 // Call registerNetworkAgent without localNetworkConfig argument to pass
                 // android.net.cts.NetworkAgentTest#testAgentStartsInConnecting in old cts
-                mNetwork = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
+                result = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
                         new NetworkInfo(mInitialConfiguration.info),
                         mInitialConfiguration.properties, mInitialConfiguration.capabilities,
                         mInitialConfiguration.score, mInitialConfiguration.config, providerId);
             } else {
-                mNetwork = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
+                result = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
                         new NetworkInfo(mInitialConfiguration.info),
                         mInitialConfiguration.properties, mInitialConfiguration.capabilities,
                         mInitialConfiguration.localNetworkConfig, mInitialConfiguration.score,
                         mInitialConfiguration.config, providerId);
             }
+            if (null == result && Process.isApplicationUid(Process.myUid())) {
+                // Let it slide in tests to allow mocking, since NetworkAndAgentRegistryParcelable
+                // is not public and can't be instantiated by CTS. The danger here is that if
+                // this happens in production for some reason the code will crash later instead
+                // of here. If this is a system app, it will still crash as expected.
+                Log.e(LOG_TAG, "registerNetworkAgent returned null. This agent will not work. "
+                        + "Is ConnectivityManager a mock ?");
+            } else {
+                mNetwork = result.network;
+                mRegistry = result.registry;
+                mState = STATE_REGISTERED;
+            }
             mInitialConfiguration = null; // All this memory can now be GC'd
         }
         return mNetwork;
@@ -787,8 +858,8 @@
         }
 
         @Override
-        public void onRegistered(@NonNull INetworkAgentRegistry registry) {
-            mHandler.sendMessage(mHandler.obtainMessage(EVENT_AGENT_CONNECTED, registry));
+        public void onRegistered() {
+            mHandler.sendMessage(mHandler.obtainMessage(EVENT_AGENT_CONNECTED));
         }
 
         @Override
@@ -913,11 +984,14 @@
      *
      * @hide
      */
-    public INetworkAgent registerForTest(final Network network) {
+    public INetworkAgent registerForTest(final Network network,
+            final INetworkAgentRegistry registry) {
         log("Registering NetworkAgent for test");
         synchronized (mRegisterLock) {
             mNetwork = network;
             mInitialConfiguration = null;
+            mRegistry = registry;
+            mState = STATE_REGISTERED;
         }
         return new NetworkAgentBinder(mHandler);
     }
@@ -943,30 +1017,49 @@
         return mNetwork;
     }
 
-    private void queueOrSendMessage(@NonNull RegistryAction action) {
-        synchronized (mPreConnectedQueue) {
-            if (mNetwork == null && !Process.isApplicationUid(Process.myUid())) {
-                // Theoretically, it should not be valid to queue messages here before
-                // registering the NetworkAgent. However, practically, with the way
-                // queueing works right now, it ends up working out just fine.
-                // Log a statistic so that we know if this is happening in the
-                // wild. The check for isApplicationUid is to prevent logging the
-                // metric from test code.
+    private void logTerribleErrorMessageBeforeConnect() {
+        FrameworkConnectivityStatsLog.write(
+                FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_MESSAGE_QUEUED_BEFORE_CONNECT
+        );
+    }
 
-                FrameworkConnectivityStatsLog.write(
-                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
-                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_MESSAGE_QUEUED_BEFORE_CONNECT
-                );
-            }
-            if (mRegistry != null) {
-                try {
-                    action.execute(mRegistry);
-                } catch (RemoteException e) {
-                    Log.wtf(LOG_TAG, "Error executing registry action", e);
-                    // Fall through: the channel is asynchronous and does not report errors back
+    private void send(@NonNull RegistryAction action) {
+        synchronized (mRegisterLock) {
+            if (mQueueRemoved) {
+                if (mState <= STATE_CREATED) {
+                    // Log a terrible error. There is nothing to do with this message
+                    // so drop it.
+                    logTerribleErrorMessageBeforeConnect();
+                    Log.e(LOG_TAG, "Agent not yet registered, ignoring command");
+                    return;
+                }
+                if (mState >= STATE_UNREGISTERED) {
+                    // This should not crash for two reasons : first, the agent may
+                    // be disconnected by ConnectivityService at any time and the message
+                    // typically arrives on another thread, so it's not feasible for
+                    // apps to check before sending, they'd have to always catch. Second,
+                    // historically this hasn't thrown and some code may be relying on
+                    // the historical behavior.
+                    Log.e(LOG_TAG, "Agent already unregistered, ignoring command");
+                    return;
                 }
             } else {
-                mPreConnectedQueue.add(action);
+                if (null == mNetwork) {
+                    // Log a terrible error but still enqueue the message for backward
+                    // compatibility.
+                    logTerribleErrorMessageBeforeConnect();
+                }
+                if (!mConnected) {
+                    mPreConnectedQueue.add(action);
+                    return;
+                }
+            }
+            try {
+                action.execute(mRegistry);
+            } catch (RemoteException e) {
+                Log.wtf(LOG_TAG, "Error executing registry action", e);
+                // Fall through: the channel is asynchronous and does not report errors back
             }
         }
     }
@@ -977,8 +1070,9 @@
      */
     public void sendLinkProperties(@NonNull LinkProperties linkProperties) {
         Objects.requireNonNull(linkProperties);
-        final LinkProperties lp = new LinkProperties(linkProperties);
-        queueOrSendMessage(reg -> reg.sendLinkProperties(lp));
+        // Copy the object because if the agent is running in the system server
+        // then the same instance will be seen by the registry
+        send(reg -> reg.sendLinkProperties(new LinkProperties(linkProperties)));
     }
 
     /**
@@ -1004,7 +1098,7 @@
             @SuppressLint("NullableCollection") @Nullable List<Network> underlyingNetworks) {
         final ArrayList<Network> underlyingArray = (underlyingNetworks != null)
                 ? new ArrayList<>(underlyingNetworks) : null;
-        queueOrSendMessage(reg -> reg.sendUnderlyingNetworks(underlyingArray));
+        send(reg -> reg.sendUnderlyingNetworks(underlyingArray));
     }
 
     /**
@@ -1014,7 +1108,7 @@
     public void markConnected() {
         mNetworkInfo.setDetailedState(NetworkInfo.DetailedState.CONNECTED, null /* reason */,
                 mNetworkInfo.getExtraInfo());
-        queueOrSendNetworkInfo(mNetworkInfo);
+        sendNetworkInfo(mNetworkInfo);
     }
 
     /**
@@ -1027,7 +1121,12 @@
         // When unregistering an agent nobody should use the extrainfo (or reason) any more.
         mNetworkInfo.setDetailedState(NetworkInfo.DetailedState.DISCONNECTED, null /* reason */,
                 null /* extraInfo */);
-        queueOrSendNetworkInfo(mNetworkInfo);
+        synchronized (mRegisterLock) {
+            if (mState >= STATE_REGISTERED) {
+                sendNetworkInfo(mNetworkInfo);
+            }
+            mState = STATE_UNREGISTERED;
+        }
     }
 
     /**
@@ -1050,7 +1149,7 @@
      */
     public void setTeardownDelayMillis(
             @IntRange(from = 0, to = MAX_TEARDOWN_DELAY_MS) int teardownDelayMillis) {
-        queueOrSendMessage(reg -> reg.sendTeardownDelayMs(teardownDelayMillis));
+        send(reg -> reg.sendTeardownDelayMs(teardownDelayMillis));
     }
 
     /**
@@ -1089,7 +1188,7 @@
      */
     public void unregisterAfterReplacement(
             @IntRange(from = 0, to = MAX_TEARDOWN_DELAY_MS) int timeoutMillis) {
-        queueOrSendMessage(reg -> reg.sendUnregisterAfterReplacement(timeoutMillis));
+        send(reg -> reg.sendUnregisterAfterReplacement(timeoutMillis));
     }
 
     /**
@@ -1107,7 +1206,7 @@
     @SystemApi
     public void setLegacySubtype(final int legacySubtype, @NonNull final String legacySubtypeName) {
         mNetworkInfo.setSubtype(legacySubtype, legacySubtypeName);
-        queueOrSendNetworkInfo(mNetworkInfo);
+        sendNetworkInfo(mNetworkInfo);
     }
 
     /**
@@ -1129,7 +1228,7 @@
     @Deprecated
     public void setLegacyExtraInfo(@Nullable final String extraInfo) {
         mNetworkInfo.setExtraInfo(extraInfo);
-        queueOrSendNetworkInfo(mNetworkInfo);
+        sendNetworkInfo(mNetworkInfo);
     }
 
     /**
@@ -1137,13 +1236,13 @@
      * @hide TODO: expose something better.
      */
     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
-    public final void sendNetworkInfo(NetworkInfo networkInfo) {
-        queueOrSendNetworkInfo(networkInfo);
-    }
-
-    private void queueOrSendNetworkInfo(NetworkInfo networkInfo) {
+    // Note : this should be private, but is still called by some OEM network agents
+    // linking against the full module. They should not do this, and it should be
+    // forbidden, but as long as there are such devices this can't be made private
+    // on pain of breaking them.
+    public void sendNetworkInfo(final NetworkInfo networkInfo) {
         final NetworkInfo ni = new NetworkInfo(networkInfo);
-        queueOrSendMessage(reg -> reg.sendNetworkInfo(ni));
+        send(reg -> reg.sendNetworkInfo(ni));
     }
 
     /**
@@ -1156,7 +1255,7 @@
         mLastBwRefreshTime = System.currentTimeMillis();
         final NetworkCapabilities nc =
                 new NetworkCapabilities(networkCapabilities, NetworkCapabilities.REDACT_NONE);
-        queueOrSendMessage(reg -> reg.sendNetworkCapabilities(nc));
+        send(reg -> reg.sendNetworkCapabilities(nc));
     }
 
     /**
@@ -1168,7 +1267,7 @@
         Objects.requireNonNull(config);
         // If the agent doesn't have NET_CAPABILITY_LOCAL_NETWORK, this will be ignored by
         // ConnectivityService with a Log.wtf.
-        queueOrSendMessage(reg -> reg.sendLocalNetworkConfig(config));
+        send(reg -> reg.sendLocalNetworkConfig(config));
     }
 
     /**
@@ -1178,7 +1277,7 @@
      */
     public void sendNetworkScore(@NonNull NetworkScore score) {
         Objects.requireNonNull(score);
-        queueOrSendMessage(reg -> reg.sendScore(score));
+        send(reg -> reg.sendScore(score));
     }
 
     /**
@@ -1228,8 +1327,7 @@
      * @hide should move to NetworkAgentConfig.
      */
     public void explicitlySelected(boolean explicitlySelected, boolean acceptUnvalidated) {
-        queueOrSendMessage(reg -> reg.sendExplicitlySelected(
-                explicitlySelected, acceptUnvalidated));
+        send(reg -> reg.sendExplicitlySelected(explicitlySelected, acceptUnvalidated));
     }
 
     /**
@@ -1369,7 +1467,7 @@
      */
     public final void sendSocketKeepaliveEvent(int slot,
             @SocketKeepalive.KeepaliveEvent int event) {
-        queueOrSendMessage(reg -> reg.sendSocketKeepaliveEvent(slot, event));
+        send(reg -> reg.sendSocketKeepaliveEvent(slot, event));
     }
     /** @hide TODO delete once callers have moved to sendSocketKeepaliveEvent */
     public void onSocketKeepaliveEvent(int slot, int reason) {
@@ -1475,11 +1573,11 @@
             @NonNull final QosSessionAttributes attributes) {
         Objects.requireNonNull(attributes, "The attributes must be non-null");
         if (attributes instanceof EpsBearerQosSessionAttributes) {
-            queueOrSendMessage(ra -> ra.sendEpsQosSessionAvailable(qosCallbackId,
+            send(reg -> reg.sendEpsQosSessionAvailable(qosCallbackId,
                     new QosSession(sessionId, QosSession.TYPE_EPS_BEARER),
                     (EpsBearerQosSessionAttributes)attributes));
         } else if (attributes instanceof NrQosSessionAttributes) {
-            queueOrSendMessage(ra -> ra.sendNrQosSessionAvailable(qosCallbackId,
+            send(reg -> reg.sendNrQosSessionAvailable(qosCallbackId,
                     new QosSession(sessionId, QosSession.TYPE_NR_BEARER),
                     (NrQosSessionAttributes)attributes));
         }
@@ -1494,7 +1592,7 @@
      */
     public final void sendQosSessionLost(final int qosCallbackId,
             final int sessionId, final int qosSessionType) {
-        queueOrSendMessage(ra -> ra.sendQosSessionLost(qosCallbackId,
+        send(reg -> reg.sendQosSessionLost(qosCallbackId,
                 new QosSession(sessionId, qosSessionType)));
     }
 
@@ -1508,7 +1606,7 @@
      */
     public final void sendQosCallbackError(final int qosCallbackId,
             @QosCallbackException.ExceptionType final int exceptionType) {
-        queueOrSendMessage(ra -> ra.sendQosCallbackError(qosCallbackId, exceptionType));
+        send(reg -> reg.sendQosCallbackError(qosCallbackId, exceptionType));
     }
 
     /**
@@ -1525,7 +1623,7 @@
             throw new IllegalArgumentException("Duration must be within ["
                     + MIN_LINGER_TIMER_MS + "," + Integer.MAX_VALUE + "]ms");
         }
-        queueOrSendMessage(ra -> ra.sendLingerDuration((int) durationMs));
+        send(reg -> reg.sendLingerDuration((int) durationMs));
     }
 
     /**
@@ -1534,7 +1632,7 @@
      */
     public void sendAddDscpPolicy(@NonNull final DscpPolicy policy) {
         Objects.requireNonNull(policy);
-        queueOrSendMessage(ra -> ra.sendAddDscpPolicy(policy));
+        send(reg -> reg.sendAddDscpPolicy(policy));
     }
 
     /**
@@ -1542,14 +1640,14 @@
      * @param policyId the ID corresponding to a specific DSCP Policy.
      */
     public void sendRemoveDscpPolicy(final int policyId) {
-        queueOrSendMessage(ra -> ra.sendRemoveDscpPolicy(policyId));
+        send(reg -> reg.sendRemoveDscpPolicy(policyId));
     }
 
     /**
      * Remove all the DSCP policies on this network.
      */
     public void sendRemoveAllDscpPolicies() {
-        queueOrSendMessage(ra -> ra.sendRemoveAllDscpPolicies());
+        send(reg -> reg.sendRemoveAllDscpPolicies());
     }
 
     /** @hide */
diff --git a/framework/src/android/net/NetworkAndAgentRegistryParcelable.aidl b/framework/src/android/net/NetworkAndAgentRegistryParcelable.aidl
new file mode 100644
index 0000000..8c01bbc
--- /dev/null
+++ b/framework/src/android/net/NetworkAndAgentRegistryParcelable.aidl
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.net.INetworkAgentRegistry;
+import android.net.Network;
+
+/**
+ * A pair of Network and NetworkAgentRegistry.
+ *
+ * {@hide}
+ */
+@JavaDerive(toString=true)
+parcelable NetworkAndAgentRegistryParcelable {
+  Network network;
+  INetworkAgentRegistry registry;
+}
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index 4a50397..1d9c235 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -359,6 +359,8 @@
         mSubIds = new ArraySet<>();
         mUnderlyingNetworks = null;
         mEnterpriseId = 0;
+        mReservationId = RES_ID_UNSET;
+        mMatchNonThreadLocalNetworks = false;
     }
 
     /**
@@ -393,6 +395,8 @@
         // necessary.
         mUnderlyingNetworks = nc.mUnderlyingNetworks;
         mEnterpriseId = nc.mEnterpriseId;
+        mReservationId = nc.mReservationId;
+        mMatchNonThreadLocalNetworks = nc.mMatchNonThreadLocalNetworks;
     }
 
     /**
@@ -2233,7 +2237,9 @@
                 && (onlyImmutable || satisfiedByUids(nc))
                 && (onlyImmutable || satisfiedBySSID(nc))
                 && (onlyImmutable || satisfiedByRequestor(nc))
-                && (onlyImmutable || satisfiedBySubscriptionIds(nc)));
+                && (onlyImmutable || satisfiedBySubscriptionIds(nc)))
+                && satisfiedByReservationId(nc)
+                && satisfiedByMatchNonThreadLocalNetworks(nc);
     }
 
     /**
@@ -2347,7 +2353,9 @@
                 && equalsAdministratorUids(that)
                 && equalsSubscriptionIds(that)
                 && equalsUnderlyingNetworks(that)
-                && equalsEnterpriseCapabilitiesId(that);
+                && equalsEnterpriseCapabilitiesId(that)
+                && equalsReservationId(that)
+                && equalsMatchNonThreadLocalNetworks(that);
     }
 
     @Override
@@ -2367,13 +2375,15 @@
                 + Objects.hashCode(mAllowedUids) * 41
                 + Objects.hashCode(mSSID) * 43
                 + Objects.hashCode(mTransportInfo) * 47
-                + Objects.hashCode(mPrivateDnsBroken) * 53
+                + Boolean.hashCode(mPrivateDnsBroken) * 53
                 + Objects.hashCode(mRequestorUid) * 59
                 + Objects.hashCode(mRequestorPackageName) * 61
                 + Arrays.hashCode(mAdministratorUids) * 67
                 + Objects.hashCode(mSubIds) * 71
                 + Objects.hashCode(mUnderlyingNetworks) * 73
-                + mEnterpriseId * 79;
+                + mEnterpriseId * 79
+                + mReservationId * 83
+                + Boolean.hashCode(mMatchNonThreadLocalNetworks) * 89;
     }
 
     @Override
@@ -2411,6 +2421,8 @@
         dest.writeIntArray(CollectionUtils.toIntArray(mSubIds));
         dest.writeTypedList(mUnderlyingNetworks);
         dest.writeInt(mEnterpriseId & ALL_VALID_ENTERPRISE_IDS);
+        dest.writeInt(mReservationId);
+        dest.writeBoolean(mMatchNonThreadLocalNetworks);
     }
 
     public static final @android.annotation.NonNull Creator<NetworkCapabilities> CREATOR =
@@ -2446,8 +2458,11 @@
                 }
                 netCap.setUnderlyingNetworks(in.createTypedArrayList(Network.CREATOR));
                 netCap.mEnterpriseId = in.readInt() & ALL_VALID_ENTERPRISE_IDS;
+                netCap.mReservationId = in.readInt();
+                netCap.mMatchNonThreadLocalNetworks = in.readBoolean();
                 return netCap;
             }
+
             @Override
             public NetworkCapabilities[] newArray(int size) {
                 return new NetworkCapabilities[size];
@@ -2548,6 +2563,15 @@
                     NetworkCapabilities::enterpriseIdNameOf, "&");
         }
 
+        if (mReservationId != RES_ID_UNSET) {
+            final boolean isReservationOffer = (mReservationId == RES_ID_MATCH_ALL_RESERVATIONS);
+            sb.append(" ReservationId: ").append(isReservationOffer ? "*" : mReservationId);
+        }
+
+        if (mMatchNonThreadLocalNetworks) {
+            sb.append(" MatchNonThreadLocalNetworks");
+        }
+
         sb.append(" UnderlyingNetworks: ");
         if (mUnderlyingNetworks != null) {
             sb.append("[");
@@ -2876,6 +2900,102 @@
     }
 
     /**
+     * The reservation ID used by non-reservable Networks and "regular" NetworkOffers.
+     *
+     * Note that {@code NetworkRequest#FIRST_REQUEST_ID} is 1;
+     * @hide
+     */
+    public static final int RES_ID_UNSET = 0;
+
+    /**
+     * The reservation ID used by special NetworkOffers that handle RESERVATION requests.
+     *
+     * NetworkOffers with {@code RES_ID_MATCH_ALL_RESERVATIONS} *only* receive onNetworkNeeded()
+     * callbacks for {@code NetworkRequest.Type.RESERVATION}.
+     * @hide
+     */
+    public static final int RES_ID_MATCH_ALL_RESERVATIONS = -1;
+
+    /**
+     * Unique ID that identifies the network reservation.
+     */
+    private int mReservationId;
+
+    /**
+     * Returns the reservation ID
+     * @hide
+     */
+    public int getReservationId() {
+        return mReservationId;
+    }
+
+    /**
+     * Set the reservation ID
+     * @hide
+     */
+    public void setReservationId(int resId) {
+        mReservationId = resId;
+    }
+
+    private boolean equalsReservationId(@NonNull NetworkCapabilities nc) {
+        return mReservationId == nc.mReservationId;
+    }
+
+    private boolean satisfiedByReservationId(@NonNull NetworkCapabilities nc) {
+        if (mReservationId == RES_ID_UNSET) {
+            // To maintain regular NetworkRequest semantics, a request with a zero reservationId
+            // matches an offer or network with any reservationId except MATCH_ALL_RESERVATIONS.
+            return nc.mReservationId != RES_ID_MATCH_ALL_RESERVATIONS;
+        }
+        // A request with a non-zero reservationId matches only an offer or network with that exact
+        // reservationId (required to match the network that will eventually come up) or
+        // MATCH_ALL_RESERVATIONS (required to match the blanket reservation offer).
+        if (nc.mReservationId == RES_ID_MATCH_ALL_RESERVATIONS) {
+            return true;
+        }
+        return mReservationId == nc.mReservationId;
+    }
+
+    /**
+     * Flag to control whether a NetworkRequest can match non-thread local networks.
+     * @hide
+     */
+    private boolean mMatchNonThreadLocalNetworks;
+
+    /**
+     * Returns the match non-thread local networks flag.
+     *
+     * @hide
+     */
+    public boolean getMatchNonThreadLocalNetworks() {
+        return mMatchNonThreadLocalNetworks;
+    }
+
+    /**
+     * Set the match non-thread local networks flag.
+     * @hide
+     */
+    public void setMatchNonThreadLocalNetworks(boolean enabled) {
+        mMatchNonThreadLocalNetworks = enabled;
+    }
+
+    private boolean equalsMatchNonThreadLocalNetworks(@NonNull NetworkCapabilities nc) {
+        return mMatchNonThreadLocalNetworks == nc.mMatchNonThreadLocalNetworks;
+    }
+
+    // If the flag was set, the NetworkRequest can match all local networks.
+    // Otherwise, it can only see local networks created by Thread.
+    @SuppressWarnings("FlaggedApi")
+    private boolean satisfiedByMatchNonThreadLocalNetworks(@NonNull NetworkCapabilities nc) {
+        // If the network is not a local network, out of scope.
+        if (!nc.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) return true;
+        // If there is no restriction on matching non-thread local networks, return.
+        if (mMatchNonThreadLocalNetworks) return true;
+
+        return nc.hasTransport(TRANSPORT_THREAD);
+    }
+
+    /**
      * Returns a bitmask of all the applicable redactions (based on the permissions held by the
      * receiving app) to be performed on this object.
      *
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index 89572b3..5a08d44 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -32,6 +32,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED;
+import static android.net.NetworkCapabilities.RES_ID_UNSET;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 
 import android.annotation.FlaggedApi;
@@ -193,6 +194,16 @@
      *       callbacks about the single, highest scoring current network
      *       (if any) that matches the specified NetworkCapabilities, or
      *
+     *     - RESERVATION requests behave identically to those of type REQUEST.
+     *       For example, unlike LISTEN, they cause networks to remain
+     *       connected, and they match exactly one network (the best one).
+     *       A RESERVATION generates a unique reservationId in its
+     *       NetworkCapabilities by copying the requestId which affects
+     *       matching. A NetworkProvider can register a "blanket" NetworkOffer
+     *       with reservationId = MATCH_ALL_RESERVATIONS to indicate that it
+     *       is capable of generating NetworkOffers in response to RESERVATION
+     *       requests.
+     *
      *     - TRACK_DEFAULT, which causes the framework to issue callbacks for
      *       the single, highest scoring current network (if any) that will
      *       be chosen for an app, but which cannot cause the framework to
@@ -229,6 +240,7 @@
         BACKGROUND_REQUEST,
         TRACK_SYSTEM_DEFAULT,
         LISTEN_FOR_BEST,
+        RESERVATION,
     };
 
     /**
@@ -245,8 +257,17 @@
         if (nc == null) {
             throw new NullPointerException();
         }
+        if (nc.getReservationId() != RES_ID_UNSET) {
+            throw new IllegalArgumentException("ReservationId must only be set by the system");
+        }
         requestId = rId;
         networkCapabilities = nc;
+        if (type == Type.RESERVATION) {
+            // Conceptually, the reservationId is not related to the requestId; however, the
+            // requestId fulfills the same uniqueness requirements that are needed for the
+            // reservationId, so it can be reused for this purpose.
+            networkCapabilities.setReservationId(rId);
+        }
         this.legacyType = legacyType;
         this.type = type;
     }
@@ -261,6 +282,13 @@
         this.type = that.type;
     }
 
+    private NetworkRequest(Parcel in) {
+        networkCapabilities = NetworkCapabilities.CREATOR.createFromParcel(in);
+        legacyType = in.readInt();
+        requestId = in.readInt();
+        type = Type.valueOf(in.readString());  // IllegalArgumentException if invalid.
+    }
+
     /**
      * Builder used to create {@link NetworkRequest} objects.  Specify the Network features
      * needed in terms of {@link NetworkCapabilities} features
@@ -657,12 +685,7 @@
     public static final @android.annotation.NonNull Creator<NetworkRequest> CREATOR =
         new Creator<NetworkRequest>() {
             public NetworkRequest createFromParcel(Parcel in) {
-                NetworkCapabilities nc = NetworkCapabilities.CREATOR.createFromParcel(in);
-                int legacyType = in.readInt();
-                int requestId = in.readInt();
-                Type type = Type.valueOf(in.readString());  // IllegalArgumentException if invalid.
-                NetworkRequest result = new NetworkRequest(nc, legacyType, requestId, type);
-                return result;
+                return new NetworkRequest(in);
             }
             public NetworkRequest[] newArray(int size) {
                 return new NetworkRequest[size];
@@ -703,7 +726,7 @@
      * @hide
      */
     public boolean isRequest() {
-        return type == Type.REQUEST || type == Type.BACKGROUND_REQUEST;
+        return type == Type.REQUEST || type == Type.BACKGROUND_REQUEST || type == Type.RESERVATION;
     }
 
     /**
diff --git a/framework/src/android/net/NetworkUtils.java b/framework/src/android/net/NetworkUtils.java
index 18feb84..6b2eb08 100644
--- a/framework/src/android/net/NetworkUtils.java
+++ b/framework/src/android/net/NetworkUtils.java
@@ -443,4 +443,13 @@
 
     /** Returns whether the Linux Kernel is x86 */
     public static native boolean isKernelX86();
+
+    /**
+     * Returns socket cookie.
+     *
+     * @param fd The socket file descriptor
+     * @return The socket cookie.
+     * @throws ErrnoException if retrieving the socket cookie fails.
+     */
+    public static native long getSocketCookie(FileDescriptor fd) throws ErrnoException;
 }
diff --git a/framework/src/android/net/TestNetworkManager.java b/framework/src/android/net/TestNetworkManager.java
index 416c6de..cbc7a4f 100644
--- a/framework/src/android/net/TestNetworkManager.java
+++ b/framework/src/android/net/TestNetworkManager.java
@@ -23,8 +23,10 @@
 import android.os.IBinder;
 import android.os.RemoteException;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
 import java.util.Objects;
 
 /**
@@ -196,45 +198,6 @@
     }
 
     /**
-     * Create a tap interface for testing purposes
-     *
-     * @param linkAddrs an array of LinkAddresses to assign to the TAP interface
-     * @return A TestNetworkInterface representing the underlying TAP interface. Close the contained
-     *     ParcelFileDescriptor to tear down the TAP interface.
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.MANAGE_TEST_NETWORKS)
-    @NonNull
-    public TestNetworkInterface createTapInterface(@NonNull LinkAddress[] linkAddrs) {
-        try {
-            return mService.createInterface(TAP, CARRIER_UP, BRING_UP, USE_IPV6_PROV_DELAY,
-                    linkAddrs, null /* iface */);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-    }
-
-    /**
-     * Create a tap interface for testing purposes
-     *
-     * @param bringUp whether to bring up the interface before returning it.
-     *
-     * @return A ParcelFileDescriptor of the underlying TAP interface. Close this to tear down the
-     *     TAP interface.
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.MANAGE_TEST_NETWORKS)
-    @NonNull
-    public TestNetworkInterface createTapInterface(boolean bringUp) {
-        try {
-            return mService.createInterface(TAP, CARRIER_UP, bringUp, USE_IPV6_PROV_DELAY,
-                    NO_ADDRS, null /* iface */);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-    }
-
-    /**
      * Create a tap interface with a given interface name for testing purposes
      *
      * @param bringUp whether to bring up the interface before returning it.
@@ -258,26 +221,6 @@
     }
 
     /**
-     * Create a tap interface with or without carrier for testing purposes.
-     *
-     * Note: setting carrierUp = false is not supported until kernel version 6.0.
-     *
-     * @param carrierUp whether the created interface has a carrier or not.
-     * @param bringUp whether to bring up the interface before returning it.
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.MANAGE_TEST_NETWORKS)
-    @NonNull
-    public TestNetworkInterface createTapInterface(boolean carrierUp, boolean bringUp) {
-        try {
-            return mService.createInterface(TAP, carrierUp, bringUp, USE_IPV6_PROV_DELAY, NO_ADDRS,
-                    null /* iface */);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-    }
-
-    /**
      * Create a tap interface for testing purposes.
      *
      * Note: setting carrierUp = false is not supported until kernel version 6.0.
@@ -300,27 +243,6 @@
     }
 
     /**
-     * Create a tap interface for testing purposes.
-     *
-     * @param disableIpv6ProvisioningDelay whether to disable DAD and RS delay.
-     * @param linkAddrs an array of LinkAddresses to assign to the TAP interface
-     * @return A TestNetworkInterface representing the underlying TAP interface. Close the contained
-     *     ParcelFileDescriptor to tear down the TAP interface.
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.MANAGE_TEST_NETWORKS)
-    @NonNull
-    public TestNetworkInterface createTapInterface(boolean disableIpv6ProvisioningDelay,
-            @NonNull LinkAddress[] linkAddrs) {
-        try {
-            return mService.createInterface(TAP, CARRIER_UP, BRING_UP, disableIpv6ProvisioningDelay,
-                    linkAddrs, null /* iface */);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-    }
-
-    /**
      * Enable / disable carrier on TestNetworkInterface
      *
      * Note: TUNSETCARRIER is not supported until kernel version 5.0.
@@ -337,4 +259,110 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    /**
+     * Represents a request to create a tun/tap interface for testing.
+     *
+     * @hide
+     */
+    public static class TestInterfaceRequest {
+        public final boolean isTun;
+        public final boolean hasCarrier;
+        public final boolean bringUp;
+        public final boolean disableIpv6ProvDelay;
+        @Nullable public final String ifname;
+        public final LinkAddress[] linkAddresses;
+
+        private TestInterfaceRequest(boolean isTun, boolean hasCarrier, boolean bringUp,
+                boolean disableProvDelay, @Nullable String ifname, LinkAddress[] linkAddresses) {
+            this.isTun = isTun;
+            this.hasCarrier = hasCarrier;
+            this.bringUp = bringUp;
+            this.disableIpv6ProvDelay = disableProvDelay;
+            this.ifname = ifname;
+            this.linkAddresses = linkAddresses;
+        }
+
+        /**
+         * Builder class for TestInterfaceRequest
+         *
+         * Defaults to a tap interface with carrier that has been brought up.
+         */
+        public static class Builder {
+            private boolean mIsTun = false;
+            private boolean mHasCarrier = true;
+            private boolean mBringUp = true;
+            private boolean mDisableIpv6ProvDelay = false;
+            @Nullable private String mIfname;
+            private List<LinkAddress> mLinkAddresses = new ArrayList<>();
+
+            /** Create tun interface. */
+            public Builder setTun() {
+                mIsTun = true;
+                return this;
+            }
+
+            /** Create tap interface. */
+            public Builder setTap() {
+                mIsTun = false;
+                return this;
+            }
+
+            /** Configure whether the interface has carrier. */
+            public Builder setHasCarrier(boolean hasCarrier) {
+                mHasCarrier = hasCarrier;
+                return this;
+            }
+
+            /** Configure whether the interface should be brought up. */
+            public Builder setBringUp(boolean bringUp) {
+                mBringUp = bringUp;
+                return this;
+            }
+
+            /** Disable DAD and RS delay. */
+            public Builder setDisableIpv6ProvisioningDelay(boolean disableProvDelay) {
+                mDisableIpv6ProvDelay = disableProvDelay;
+                return this;
+            }
+
+            /** Set the interface name. */
+            public Builder setInterfaceName(@Nullable String ifname) {
+                mIfname = ifname;
+                return this;
+            }
+
+            /** The addresses to configure on the interface. */
+            public Builder addLinkAddress(LinkAddress la) {
+                mLinkAddresses.add(la);
+                return this;
+            }
+
+            /** Build TestInterfaceRequest */
+            public TestInterfaceRequest build() {
+                return new TestInterfaceRequest(mIsTun, mHasCarrier, mBringUp,
+                        mDisableIpv6ProvDelay, mIfname, mLinkAddresses.toArray(new LinkAddress[0]));
+            }
+        }
+    }
+
+    /**
+     * Create a TestNetworkInterface (tun or tap) for testing purposes.
+     *
+     * @param request The request describing the interface to create.
+     * @return A TestNetworkInterface representing the underlying tun/tap interface. Close the
+     *         contained ParcelFileDescriptor to tear down the tun/tap interface.
+     * @hide
+     */
+    @RequiresPermission(Manifest.permission.MANAGE_TEST_NETWORKS)
+    @NonNull
+    public TestNetworkInterface createTestInterface(@NonNull TestInterfaceRequest request) {
+        try {
+            // TODO: Make TestInterfaceRequest parcelable and pass it instead.
+            return mService.createInterface(request.isTun, request.hasCarrier, request.bringUp,
+                    request.disableIpv6ProvDelay, request.linkAddresses, request.ifname);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
index 51df8ab..27d605a 100644
--- a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
+++ b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
@@ -16,6 +16,7 @@
 package android.net.connectivity;
 
 import android.compat.annotation.ChangeId;
+import android.compat.annotation.Disabled;
 import android.compat.annotation.EnabledAfter;
 import android.compat.annotation.EnabledSince;
 import android.os.Build;
@@ -100,9 +101,9 @@
     public static final long ENABLE_MATCH_LOCAL_NETWORK = 319212206L;
 
     /**
-     * On Android {@link android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM} or higher releases,
-     * network access from apps targeting Android 36 or higher that do not have the
-     * {@link android.Manifest.permission#INTERNET} permission is considered blocked.
+     * On Android versions starting from 37, network access from apps targeting
+     * Android 37 or higher, that do not have the {@link android.Manifest.permission#INTERNET}
+     * permission, is considered blocked.
      * This results in API behaviors change for apps without
      * {@link android.Manifest.permission#INTERNET} permission.
      * {@link android.net.NetworkInfo} returned from {@link android.net.ConnectivityManager} APIs
@@ -115,10 +116,12 @@
      * network access from apps without {@link android.Manifest.permission#INTERNET} permission is
      * considered not blocked even though apps cannot access any networks.
      *
+     * TODO: b/400903101 - Update the target SDK version once it's finalized.
+     *
      * @hide
      */
     @ChangeId
-    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @EnabledAfter(targetSdkVersion = 36)
     public static final long NETWORK_BLOCKED_WITHOUT_INTERNET_PERMISSION = 333340911L;
 
     /**
@@ -135,6 +138,28 @@
     @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public static final long ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE = 74210811L;
 
+    /**
+     * Restrict local network access.
+     * Apps targeting a release after V will require permissions to access the local network.
+     *
+     * ToDo: Update the target SDK version once it's finalized.
+     * @hide
+     */
+    @ChangeId
+    @Disabled
+    public static final long RESTRICT_LOCAL_NETWORK = 365139289L;
+
+    /**
+     * Enable match non-threads local networks.
+     *
+     * Apps targeting a release after V can have NetworkRequests matches non-thread local networks.
+     *
+     * @hide
+     */
+    @ChangeId
+    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public static final long ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS = 349487600L;
+
     private ConnectivityCompatChanges() {
     }
 }
diff --git a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
index ba39ca0..9478e91 100644
--- a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
@@ -16,11 +16,25 @@
 
 package android.net.connectivity;
 
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED;
+
+import android.annotation.SuppressLint;
 import android.content.Context;
 import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
+import android.net.NetworkAgent;
+import android.net.NetworkAgentConfig;
+import android.net.NetworkCapabilities;
+import android.net.NetworkScore;
 import android.os.Build;
 import android.os.IBinder;
+import android.os.Looper;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
 /**
@@ -57,4 +71,36 @@
         final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
         return cm.getRoutingCoordinatorService();
     }
+
+    /**
+     * Create a NetworkAgent instance to be used by Tethering.
+     * @param ctx the context
+     * @return an instance of the {@code NetworkAgent}
+     */
+    // TODO: Expose LocalNetworkConfig related APIs and delete this method. This method is
+    //  only here because on R Tethering is installed and not Connectivity, requiring all
+    //  shared classes to be public API. LocalNetworkConfig is not public yet, but it will
+    //  only be used by Tethering on V+ so it's fine.
+    @SuppressLint("WrongConstant")
+    @NonNull
+    public static NetworkAgent buildTetheringNetworkAgent(@NonNull Context ctx,
+            @NonNull Looper looper, @NonNull String logTag, int transportType,
+            @NonNull LinkProperties lp) {
+        final NetworkCapabilities.Builder builder = new NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_NOT_METERED)
+                .addCapability(NET_CAPABILITY_NOT_ROAMING)
+                .addCapability(NET_CAPABILITY_NOT_CONGESTED)
+                .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                .addTransportType(transportType);
+        // TODO: Change to use the constant definition. Flags.netCapabilityLocalNetwork() was not
+        //  fully rolled out but the service will still process this capability, set it anyway.
+        builder.addCapability(36 /* NET_CAPABILITY_LOCAL_NETWORK */);
+        final NetworkCapabilities caps = builder.build();
+        final NetworkAgentConfig nac = new NetworkAgentConfig.Builder().build();
+        return new NetworkAgent(ctx, looper, logTag, caps, lp,
+                new LocalNetworkConfig.Builder().build(), new NetworkScore.Builder()
+                .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK)
+                .build(), nac, null /* provider */) {
+        };
+    }
 }
diff --git a/nearby/framework/java/android/nearby/CredentialElement.java b/nearby/framework/java/android/nearby/CredentialElement.java
index 7a43b01..d7c63c4 100644
--- a/nearby/framework/java/android/nearby/CredentialElement.java
+++ b/nearby/framework/java/android/nearby/CredentialElement.java
@@ -50,8 +50,7 @@
                 @Override
                 public CredentialElement createFromParcel(Parcel in) {
                     String key = in.readString();
-                    byte[] value = new byte[in.readInt()];
-                    in.readByteArray(value);
+                    byte[] value = in.createByteArray();
                     return new CredentialElement(key, value);
                 }
 
@@ -69,7 +68,6 @@
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
         dest.writeString(mKey);
-        dest.writeInt(mValue.length);
         dest.writeByteArray(mValue);
     }
 
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index e78f999..9d7d144 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -46,6 +46,7 @@
 import java.util.WeakHashMap;
 import java.util.concurrent.Executor;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
 
 /**
  * This class provides a way to perform Nearby related operations such as scanning, broadcasting
@@ -503,7 +504,7 @@
                     PoweredOffFindingEphemeralId ephemeralId = new PoweredOffFindingEphemeralId();
                     ephemeralId.bytes = eid;
                     return ephemeralId;
-                }).toList();
+                }).collect(Collectors.toUnmodifiableList());
         try {
             mService.setPoweredOffFindingEphemeralIds(ephemeralIdList);
         } catch (RemoteException e) {
diff --git a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
index 66ae79c..ac381b8 100644
--- a/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
+++ b/nearby/service/java/com/android/server/nearby/provider/BleBroadcastProvider.java
@@ -89,6 +89,9 @@
                             break;
                         case BroadcastRequest.PRESENCE_VERSION_V1:
                             if (adapter.isLeExtendedAdvertisingSupported()) {
+                                if (mAdvertisingSetCallback == null) {
+                                    mAdvertisingSetCallback = getAdvertisingSetCallback();
+                                }
                                 bluetoothLeAdvertiser.startAdvertisingSet(
                                         getAdvertisingSetParameters(),
                                         advertiseData,
@@ -133,6 +136,11 @@
             }
             mBroadcastListener = null;
             mIsAdvertising = false;
+            // If called startAdvertisingSet() but onAdvertisingSetStopped() is not invoked yet,
+            // using the same mAdvertisingSetCallback will cause new advertising cann't be stopped.
+            // Therefore, release the old mAdvertisingSetCallback and
+            // create a new mAdvertisingSetCallback when calling startAdvertisingSet.
+            mAdvertisingSetCallback = null;
         }
     }
 
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
index 3291223..58d1808 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -26,7 +26,6 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
-import static org.junit.Assume.assumeTrue;
 
 import android.app.UiAutomation;
 import android.bluetooth.test_utils.EnableBluetoothRule;
@@ -79,10 +78,10 @@
 
     @ClassRule public static final EnableBluetoothRule sEnableBluetooth = new EnableBluetoothRule();
 
-    private static final byte[] SALT = new byte[]{1, 2};
-    private static final byte[] SECRET_ID = new byte[]{1, 2, 3, 4};
+    private static final byte[] SALT = new byte[] {1, 2};
+    private static final byte[] SECRET_ID = new byte[] {1, 2, 3, 4};
     private static final byte[] META_DATA_ENCRYPTION_KEY = new byte[14];
-    private static final byte[] AUTHENTICITY_KEY = new byte[]{0, 1, 1, 1};
+    private static final byte[] AUTHENTICITY_KEY = new byte[] {0, 1, 1, 1};
     private static final String DEVICE_NAME = "test_device";
     private static final int BLE_MEDIUM = 1;
 
@@ -91,43 +90,45 @@
     private UiAutomation mUiAutomation =
             InstrumentationRegistry.getInstrumentation().getUiAutomation();
 
-    private ScanRequest mScanRequest = new ScanRequest.Builder()
-            .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
-            .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
-            .setBleEnabled(true)
-            .build();
+    private ScanRequest mScanRequest =
+            new ScanRequest.Builder()
+                    .setScanType(ScanRequest.SCAN_TYPE_FAST_PAIR)
+                    .setScanMode(ScanRequest.SCAN_MODE_LOW_LATENCY)
+                    .setBleEnabled(true)
+                    .build();
     private PresenceDevice.Builder mBuilder =
             new PresenceDevice.Builder("deviceId", SALT, SECRET_ID, META_DATA_ENCRYPTION_KEY);
 
-    private  ScanCallback mScanCallback = new ScanCallback() {
-        @Override
-        public void onDiscovered(@NonNull NearbyDevice device) {
-        }
+    private ScanCallback mScanCallback =
+            new ScanCallback() {
+                @Override
+                public void onDiscovered(@NonNull NearbyDevice device) {}
 
-        @Override
-        public void onUpdated(@NonNull NearbyDevice device) {
-        }
+                @Override
+                public void onUpdated(@NonNull NearbyDevice device) {}
 
-        @Override
-        public void onLost(@NonNull NearbyDevice device) {
-        }
+                @Override
+                public void onLost(@NonNull NearbyDevice device) {}
 
-        @Override
-        public void onError(int errorCode) {
-        }
-    };
+                @Override
+                public void onError(int errorCode) {}
+            };
 
     private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
 
     @Before
     public void setUp() {
-        mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG,
-                WRITE_ALLOWLISTED_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
-        String nameSpace = SdkLevel.isAtLeastU() ? DeviceConfig.NAMESPACE_NEARBY
-                : DeviceConfig.NAMESPACE_TETHERING;
-        DeviceConfig.setProperty(nameSpace,
-                "nearby_enable_presence_broadcast_legacy",
-                "true", false);
+        mUiAutomation.adoptShellPermissionIdentity(
+                READ_DEVICE_CONFIG,
+                WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                BLUETOOTH_PRIVILEGED);
+        String nameSpace =
+                SdkLevel.isAtLeastU()
+                        ? DeviceConfig.NAMESPACE_NEARBY
+                        : DeviceConfig.NAMESPACE_TETHERING;
+        DeviceConfig.setProperty(
+                nameSpace, "nearby_enable_presence_broadcast_legacy", "true", false);
 
         mContext = InstrumentationRegistry.getContext();
         mNearbyManager = mContext.getSystemService(NearbyManager.class);
@@ -144,8 +145,9 @@
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void test_startScan_noPrivilegedPermission() {
         mUiAutomation.dropShellPermissionIdentity();
-        assertThrows(SecurityException.class, () -> mNearbyManager
-                .startScan(mScanRequest, EXECUTOR, mScanCallback));
+        assertThrows(
+                SecurityException.class,
+                () -> mNearbyManager.startScan(mScanRequest, EXECUTOR, mScanCallback));
     }
 
     @Test
@@ -159,23 +161,25 @@
     @Test
     @SdkSuppress(minSdkVersion = 32, codeName = "T")
     public void testStartStopBroadcast() throws InterruptedException {
-        PrivateCredential credential = new PrivateCredential.Builder(SECRET_ID, AUTHENTICITY_KEY,
-                META_DATA_ENCRYPTION_KEY, DEVICE_NAME)
-                .setIdentityType(IDENTITY_TYPE_PRIVATE)
-                .build();
+        PrivateCredential credential =
+                new PrivateCredential.Builder(
+                                SECRET_ID, AUTHENTICITY_KEY, META_DATA_ENCRYPTION_KEY, DEVICE_NAME)
+                        .setIdentityType(IDENTITY_TYPE_PRIVATE)
+                        .build();
         BroadcastRequest broadcastRequest =
                 new PresenceBroadcastRequest.Builder(
-                        Collections.singletonList(BLE_MEDIUM), SALT, credential)
+                                Collections.singletonList(BLE_MEDIUM), SALT, credential)
                         .addAction(123)
                         .build();
 
         CountDownLatch latch = new CountDownLatch(1);
-        BroadcastCallback callback = status -> {
-            latch.countDown();
-            assertThat(status).isEqualTo(BroadcastCallback.STATUS_OK);
-        };
-        mNearbyManager.startBroadcast(broadcastRequest, Executors.newSingleThreadExecutor(),
-                callback);
+        BroadcastCallback callback =
+                status -> {
+                    latch.countDown();
+                    assertThat(status).isEqualTo(BroadcastCallback.STATUS_OK);
+                };
+        mNearbyManager.startBroadcast(
+                broadcastRequest, Executors.newSingleThreadExecutor(), callback);
         latch.await(10, TimeUnit.SECONDS);
         mNearbyManager.stopBroadcast(callback);
     }
@@ -197,9 +201,8 @@
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testsetPoweredOffFindingEphemeralIds() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
@@ -208,24 +211,22 @@
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testsetPoweredOffFindingEphemeralIds_noPrivilegedPermission() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
 
         mUiAutomation.dropShellPermissionIdentity();
 
-        assertThrows(SecurityException.class,
+        assertThrows(
+                SecurityException.class,
                 () -> mNearbyManager.setPoweredOffFindingEphemeralIds(List.of(new byte[20])));
     }
 
-
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testSetAndGetPoweredOffFindingMode_enabled() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
@@ -234,30 +235,26 @@
         // enableLocation() has dropped shell permission identity.
         mUiAutomation.adoptShellPermissionIdentity(BLUETOOTH_PRIVILEGED);
 
-        mNearbyManager.setPoweredOffFindingMode(
-                NearbyManager.POWERED_OFF_FINDING_MODE_ENABLED);
+        mNearbyManager.setPoweredOffFindingMode(NearbyManager.POWERED_OFF_FINDING_MODE_ENABLED);
         assertThat(mNearbyManager.getPoweredOffFindingMode())
                 .isEqualTo(NearbyManager.POWERED_OFF_FINDING_MODE_ENABLED);
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testSetAndGetPoweredOffFindingMode_disabled() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
 
-        mNearbyManager.setPoweredOffFindingMode(
-                NearbyManager.POWERED_OFF_FINDING_MODE_DISABLED);
+        mNearbyManager.setPoweredOffFindingMode(NearbyManager.POWERED_OFF_FINDING_MODE_DISABLED);
         assertThat(mNearbyManager.getPoweredOffFindingMode())
                 .isEqualTo(NearbyManager.POWERED_OFF_FINDING_MODE_DISABLED);
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testSetPoweredOffFindingMode_noPrivilegedPermission() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
@@ -265,14 +262,16 @@
         enableLocation();
         mUiAutomation.dropShellPermissionIdentity();
 
-        assertThrows(SecurityException.class, () -> mNearbyManager
-                .setPoweredOffFindingMode(NearbyManager.POWERED_OFF_FINDING_MODE_ENABLED));
+        assertThrows(
+                SecurityException.class,
+                () ->
+                        mNearbyManager.setPoweredOffFindingMode(
+                                NearbyManager.POWERED_OFF_FINDING_MODE_ENABLED));
     }
 
     @Test
+    @SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testGetPoweredOffFindingMode_noPrivilegedPermission() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         // Only test supporting devices.
         if (mNearbyManager.getPoweredOffFindingMode()
                 == NearbyManager.POWERED_OFF_FINDING_MODE_UNSUPPORTED) return;
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java
index 32286e1..a36084b 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/BluetoothFinderManagerTest.java
@@ -18,7 +18,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import static org.junit.Assume.assumeTrue;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyInt;
@@ -29,12 +28,13 @@
 import android.hardware.bluetooth.finder.Eid;
 import android.hardware.bluetooth.finder.IBluetoothFinder;
 import android.nearby.PoweredOffFindingEphemeralId;
+import android.os.Build;
 import android.os.IBinder;
 import android.os.IBinder.DeathRecipient;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
 
-import com.android.modules.utils.build.SdkLevel;
+import androidx.test.filters.SdkSuppress;
 
 import org.junit.Before;
 import org.junit.Test;
@@ -44,6 +44,7 @@
 
 import java.util.List;
 
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class BluetoothFinderManagerTest {
     private BluetoothFinderManager mBluetoothFinderManager;
     private boolean mGetServiceCalled = false;
@@ -71,8 +72,6 @@
 
     @Before
     public void setup() {
-        // Replace with minSdkVersion when Build.VERSION_CODES.VANILLA_ICE_CREAM can be used.
-        assumeTrue(SdkLevel.isAtLeastV());
         MockitoAnnotations.initMocks(this);
         mBluetoothFinderManager = new BluetoothFinderManagerSpy();
     }
@@ -80,16 +79,16 @@
     @Test
     public void testSendEids() throws Exception {
         byte[] eidBytes1 = {
-                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
-                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
-                (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
-                (byte) 0xe1, (byte) 0xde
+            (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+            (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+            (byte) 0xe1, (byte) 0xde, (byte) 0x1d, (byte) 0xe1, (byte) 0xde, (byte) 0x1d,
+            (byte) 0xe1, (byte) 0xde
         };
         byte[] eidBytes2 = {
-                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
-                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
-                (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
-                (byte) 0xf2, (byte) 0xef
+            (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+            (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+            (byte) 0xf2, (byte) 0xef, (byte) 0x2e, (byte) 0xf2, (byte) 0xef, (byte) 0x2e,
+            (byte) 0xf2, (byte) 0xef
         };
         PoweredOffFindingEphemeralId ephemeralId1 = new PoweredOffFindingEphemeralId();
         PoweredOffFindingEphemeralId ephemeralId2 = new PoweredOffFindingEphemeralId();
@@ -105,8 +104,7 @@
 
     @Test
     public void testSendEids_remoteException() throws Exception {
-        doThrow(new RemoteException())
-                .when(mIBluetoothFinderMock).sendEids(any());
+        doThrow(new RemoteException()).when(mIBluetoothFinderMock).sendEids(any());
         mBluetoothFinderManager.sendEids(List.of());
 
         // Verify that we get the service again following a RemoteException.
@@ -117,8 +115,7 @@
 
     @Test
     public void testSendEids_serviceSpecificException() throws Exception {
-        doThrow(new ServiceSpecificException(1))
-                .when(mIBluetoothFinderMock).sendEids(any());
+        doThrow(new ServiceSpecificException(1)).when(mIBluetoothFinderMock).sendEids(any());
         mBluetoothFinderManager.sendEids(List.of());
     }
 
@@ -134,7 +131,8 @@
     @Test
     public void testSetPoweredOffFinderMode_remoteException() throws Exception {
         doThrow(new RemoteException())
-                .when(mIBluetoothFinderMock).setPoweredOffFinderMode(anyBoolean());
+                .when(mIBluetoothFinderMock)
+                .setPoweredOffFinderMode(anyBoolean());
         mBluetoothFinderManager.setPoweredOffFinderMode(true);
 
         // Verify that we get the service again following a RemoteException.
@@ -146,7 +144,8 @@
     @Test
     public void testSetPoweredOffFinderMode_serviceSpecificException() throws Exception {
         doThrow(new ServiceSpecificException(1))
-                .when(mIBluetoothFinderMock).setPoweredOffFinderMode(anyBoolean());
+                .when(mIBluetoothFinderMock)
+                .setPoweredOffFinderMode(anyBoolean());
         mBluetoothFinderManager.setPoweredOffFinderMode(true);
     }
 
diff --git a/networksecurity/TEST_MAPPING b/networksecurity/TEST_MAPPING
index 20ecbce..f75bf9a 100644
--- a/networksecurity/TEST_MAPPING
+++ b/networksecurity/TEST_MAPPING
@@ -1,5 +1,22 @@
 {
-  "postsubmit": [
+  "tethering-mainline-presubmit": [
+    {
+      "name": "NetworkSecurityUnitTests"
+    }
+  ],
+  "presubmit": [
+    {
+      "name": "CtsNetSecConfigCertificateTransparencyTestCases"
+    },
+    {
+      "name": "CtsNetSecConfigCertificateTransparencyDefaultTestCases"
+    },
+    {
+      "name": "NetSecConfigCertificateTransparencySctLogListTestCases"
+    },
+    {
+      "name": "NetSecConfigCertificateTransparencySctNoLogListTestCases"
+    },
     {
       "name": "NetworkSecurityUnitTests"
     }
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index a41e6a0..3c964e5 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -24,16 +24,20 @@
 
     srcs: [
         "src/**/*.java",
+        ":statslog-certificate-transparency-java-gen",
     ],
 
     libs: [
         "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "service-connectivity-pre-jarjar",
+        "framework-statsd.stubs.module_lib",
+        "ServiceConnectivityResources",
     ],
 
     static_libs: [
         "auto_value_annotations",
+        "android.security.flags-aconfig-java-export",
     ],
 
     plugins: [
@@ -48,3 +52,10 @@
     sdk_version: "system_server_current",
     apex_available: ["com.android.tethering"],
 }
+
+genrule {
+    name: "statslog-certificate-transparency-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module certificate_transparency --javaPackage com.android.server.net.ct --javaClass CertificateTransparencyStatsLog",
+    out: ["com/android/server/net/ct/CertificateTransparencyStatsLog.java"],
+}
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 56a5ee5..41b58fa 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -13,30 +13,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.android.server.net.ct;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+package com.android.server.net.ct;
 
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
 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 com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState;
 import com.android.server.net.ct.DownloadHelper.DownloadStatus;
 
-import org.json.JSONException;
-import org.json.JSONObject;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
+import java.util.Collection;
+import java.util.Optional;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -48,35 +46,26 @@
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
     private final SignatureVerifier mSignatureVerifier;
-    private final CertificateTransparencyInstaller mInstaller;
+    private final CertificateTransparencyLogger mLogger;
+    private final Collection<CompatibilityVersion> mCompatVersions;
 
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
             SignatureVerifier signatureVerifier,
-            CertificateTransparencyInstaller installer) {
+            CertificateTransparencyLogger logger,
+            Collection<CompatibilityVersion> compatVersions) {
         mContext = context;
         mSignatureVerifier = signatureVerifier;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
-        mInstaller = installer;
-    }
-
-    void initialize() {
-        mInstaller.addCompatibilityVersion(Config.COMPATIBILITY_VERSION);
-
-        IntentFilter intentFilter = new IntentFilter();
-        intentFilter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
-        mContext.registerReceiver(this, intentFilter, Context.RECEIVER_EXPORTED);
-
-        if (Config.DEBUG) {
-            Log.d(TAG, "CertificateTransparencyDownloader initialized successfully");
-        }
+        mLogger = logger;
+        mCompatVersions = compatVersions;
     }
 
     long startPublicKeyDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.PUBLIC_KEY_URL));
+        long downloadId = download(Config.URL_PUBLIC_KEY);
         if (downloadId != -1) {
             mDataStore.setPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, downloadId);
             mDataStore.store();
@@ -84,19 +73,31 @@
         return downloadId;
     }
 
-    long startMetadataDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.METADATA_URL));
+    private long startMetadataDownload(CompatibilityVersion compatVersion) {
+        long downloadId = download(compatVersion.getMetadataUrl());
         if (downloadId != -1) {
-            mDataStore.setPropertyLong(Config.METADATA_DOWNLOAD_ID, downloadId);
+            mDataStore.setPropertyLong(compatVersion.getMetadataPropertyName(), downloadId);
             mDataStore.store();
         }
         return downloadId;
     }
 
-    long startContentDownload() {
-        long downloadId = download(mDataStore.getProperty(Config.CONTENT_URL));
+    @VisibleForTesting
+    void startMetadataDownload() {
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            if (startMetadataDownload(compatVersion) == -1) {
+                Log.e(TAG, "Metadata download not started for " + compatVersion.getCompatVersion());
+            } else if (Config.DEBUG) {
+                Log.d(TAG, "Metadata download started for " + compatVersion.getCompatVersion());
+            }
+        }
+    }
+
+    @VisibleForTesting
+    long startContentDownload(CompatibilityVersion compatVersion) {
+        long downloadId = download(compatVersion.getContentUrl());
         if (downloadId != -1) {
-            mDataStore.setPropertyLong(Config.CONTENT_DOWNLOAD_ID, downloadId);
+            mDataStore.setPropertyLong(compatVersion.getContentPropertyName(), downloadId);
             mDataStore.store();
         }
         return downloadId;
@@ -110,25 +111,28 @@
             return;
         }
 
-        long completedId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
+        long completedId =
+                intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, /* defaultValue= */ -1);
         if (completedId == -1) {
             Log.e(TAG, "Invalid completed download Id");
             return;
         }
 
-        if (isPublicKeyDownloadId(completedId)) {
+        if (getPublicKeyDownloadId() == completedId) {
             handlePublicKeyDownloadCompleted(completedId);
             return;
         }
 
-        if (isMetadataDownloadId(completedId)) {
-            handleMetadataDownloadCompleted(completedId);
-            return;
-        }
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            if (getMetadataDownloadId(compatVersion) == completedId) {
+                handleMetadataDownloadCompleted(compatVersion, completedId);
+                return;
+            }
 
-        if (isContentDownloadId(completedId)) {
-            handleContentDownloadCompleted(completedId);
-            return;
+            if (getContentDownloadId(compatVersion) == completedId) {
+                handleContentDownloadCompleted(compatVersion, completedId);
+                return;
+            }
         }
 
         Log.i(TAG, "Download id " + completedId + " is not recognized.");
@@ -154,78 +158,72 @@
             return;
         }
 
-        if (startMetadataDownload() == -1) {
-            Log.e(TAG, "Metadata download not started.");
-        } else if (Config.DEBUG) {
-            Log.d(TAG, "Metadata download started successfully.");
-        }
+        startMetadataDownload();
     }
 
-    private void handleMetadataDownloadCompleted(long downloadId) {
+    private void handleMetadataDownloadCompleted(
+            CompatibilityVersion compatVersion, long downloadId) {
         DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
         if (!status.isSuccessful()) {
             handleDownloadFailed(status);
             return;
         }
-        if (startContentDownload() == -1) {
-            Log.e(TAG, "Content download not started.");
+        if (startContentDownload(compatVersion) == -1) {
+            Log.e(TAG, "Content download failed for" + compatVersion.getCompatVersion());
         } else if (Config.DEBUG) {
-            Log.d(TAG, "Content download started successfully.");
+            Log.d(TAG, "Content download started for" + compatVersion.getCompatVersion());
         }
     }
 
-    private void handleContentDownloadCompleted(long downloadId) {
+    private void handleContentDownloadCompleted(
+            CompatibilityVersion compatVersion, long downloadId) {
         DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
         if (!status.isSuccessful()) {
             handleDownloadFailed(status);
             return;
         }
 
-        Uri contentUri = getContentDownloadUri();
-        Uri metadataUri = getMetadataDownloadUri();
+        Uri contentUri = getContentDownloadUri(compatVersion);
+        Uri metadataUri = getMetadataDownloadUri(compatVersion);
         if (contentUri == null || metadataUri == null) {
             Log.e(TAG, "Invalid URIs");
             return;
         }
 
-        boolean success = false;
-        try {
-            success = mSignatureVerifier.verify(contentUri, metadataUri);
-        } catch (IOException | GeneralSecurityException e) {
-            Log.e(TAG, "Could not verify new log list", e);
-        }
-        if (!success) {
+        LogListUpdateStatus updateStatus = mSignatureVerifier.verify(contentUri, metadataUri);
+
+        if (!updateStatus.isSignatureVerified()) {
             Log.w(TAG, "Log list did not pass verification");
-            return;
-        }
 
-        String version = null;
-        try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
-            version =
-                    new JSONObject(new String(inputStream.readAllBytes(), UTF_8))
-                            .getString("version");
-        } catch (JSONException | IOException e) {
-            Log.e(TAG, "Could not extract version from log list", e);
+            mLogger.logCTLogListUpdateStateChangedEvent(updateStatus);
+
             return;
         }
 
         try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
-            success = mInstaller.install(Config.COMPATIBILITY_VERSION, inputStream, version);
+            updateStatus = compatVersion.install(inputStream, updateStatus.toBuilder());
         } catch (IOException e) {
             Log.e(TAG, "Could not install new content", e);
             return;
         }
 
-        if (success) {
-            // Update information about the stored version on successful install.
-            mDataStore.setProperty(Config.VERSION, version);
-            mDataStore.store();
-        }
+        mLogger.logCTLogListUpdateStateChangedEvent(updateStatus);
     }
 
     private void handleDownloadFailed(DownloadStatus status) {
         Log.e(TAG, "Download failed with " + status);
-        // TODO(378626065): Report failure via statsd.
+
+        LogListUpdateStatus.Builder updateStatusBuilder = LogListUpdateStatus.builder();
+        if (status.isHttpError()) {
+            updateStatusBuilder
+                    .setState(CTLogListUpdateState.HTTP_ERROR)
+                    .setHttpErrorStatusCode(status.reason());
+        } else {
+            // TODO(b/384935059): handle blocked domain logging
+            updateStatusBuilder.setDownloadStatus(Optional.of(status.reason()));
+        }
+
+        mLogger.logCTLogListUpdateStateChangedEvent(updateStatusBuilder.build());
     }
 
     private long download(String url) {
@@ -239,17 +237,19 @@
 
     @VisibleForTesting
     long getPublicKeyDownloadId() {
-        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, -1);
+        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, /* defaultValue= */ -1);
     }
 
     @VisibleForTesting
-    long getMetadataDownloadId() {
-        return mDataStore.getPropertyLong(Config.METADATA_DOWNLOAD_ID, -1);
+    long getMetadataDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getMetadataPropertyName(), /* defaultValue */ -1);
     }
 
     @VisibleForTesting
-    long getContentDownloadId() {
-        return mDataStore.getPropertyLong(Config.CONTENT_DOWNLOAD_ID, -1);
+    long getContentDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getContentPropertyName(), /* defaultValue= */ -1);
     }
 
     @VisibleForTesting
@@ -259,38 +259,27 @@
 
     @VisibleForTesting
     boolean hasMetadataDownloadId() {
-        return getMetadataDownloadId() != -1;
+        return mCompatVersions.stream()
+                .map(this::getMetadataDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
     }
 
     @VisibleForTesting
     boolean hasContentDownloadId() {
-        return getContentDownloadId() != -1;
-    }
-
-    @VisibleForTesting
-    boolean isPublicKeyDownloadId(long downloadId) {
-        return getPublicKeyDownloadId() == downloadId;
-    }
-
-    @VisibleForTesting
-    boolean isMetadataDownloadId(long downloadId) {
-        return getMetadataDownloadId() == downloadId;
-    }
-
-    @VisibleForTesting
-    boolean isContentDownloadId(long downloadId) {
-        return getContentDownloadId() == downloadId;
+        return mCompatVersions.stream()
+                .map(this::getContentDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
     }
 
     private Uri getPublicKeyDownloadUri() {
         return mDownloadHelper.getUri(getPublicKeyDownloadId());
     }
 
-    private Uri getMetadataDownloadUri() {
-        return mDownloadHelper.getUri(getMetadataDownloadId());
+    private Uri getMetadataDownloadUri(CompatibilityVersion compatVersion) {
+        return mDownloadHelper.getUri(getMetadataDownloadId(compatVersion));
     }
 
-    private Uri getContentDownloadUri() {
-        return mDownloadHelper.getUri(getContentDownloadId());
+    private Uri getContentDownloadUri(CompatibilityVersion compatVersion) {
+        return mDownloadHelper.getUri(getContentDownloadId(compatVersion));
     }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
deleted file mode 100644
index 3138ea7..0000000
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ /dev/null
@@ -1,128 +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 com.android.server.net.ct;
-
-import android.annotation.RequiresApi;
-import android.os.Build;
-import android.provider.DeviceConfig;
-import android.provider.DeviceConfig.Properties;
-import android.text.TextUtils;
-import android.util.Log;
-
-import java.security.GeneralSecurityException;
-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";
-
-    private final DataStore mDataStore;
-    private final SignatureVerifier mSignatureVerifier;
-    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
-
-    CertificateTransparencyFlagsListener(
-            DataStore dataStore,
-            SignatureVerifier signatureVerifier,
-            CertificateTransparencyDownloader certificateTransparencyDownloader) {
-        mDataStore = dataStore;
-        mSignatureVerifier = signatureVerifier;
-        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
-    }
-
-    void initialize() {
-        mDataStore.load();
-        mCertificateTransparencyDownloader.initialize();
-        DeviceConfig.addOnPropertiesChangedListener(
-                Config.NAMESPACE_NETWORK_SECURITY, Executors.newSingleThreadExecutor(), this);
-        if (Config.DEBUG) {
-            Log.d(TAG, "CertificateTransparencyFlagsListener initialized successfully");
-        }
-        // TODO: handle property changes triggering on boot before registering this listener.
-    }
-
-    @Override
-    public void onPropertiesChanged(Properties properties) {
-        if (!Config.NAMESPACE_NETWORK_SECURITY.equals(properties.getNamespace())) {
-            return;
-        }
-
-        String newPublicKey =
-                DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY,
-                        Config.FLAG_PUBLIC_KEY,
-                        /* defaultValue= */ "");
-        String newVersion =
-                DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY,
-                        Config.FLAG_VERSION,
-                        /* defaultValue= */ "");
-        String newContentUrl =
-                DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY,
-                        Config.FLAG_CONTENT_URL,
-                        /* defaultValue= */ "");
-        String newMetadataUrl =
-                DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY,
-                        Config.FLAG_METADATA_URL,
-                        /* defaultValue= */ "");
-        if (TextUtils.isEmpty(newPublicKey)
-                || TextUtils.isEmpty(newVersion)
-                || TextUtils.isEmpty(newContentUrl)
-                || TextUtils.isEmpty(newMetadataUrl)) {
-            return;
-        }
-
-        if (Config.DEBUG) {
-            Log.d(TAG, "newPublicKey=" + newPublicKey);
-            Log.d(TAG, "newVersion=" + newVersion);
-            Log.d(TAG, "newContentUrl=" + newContentUrl);
-            Log.d(TAG, "newMetadataUrl=" + newMetadataUrl);
-        }
-
-        String oldVersion = mDataStore.getProperty(Config.VERSION);
-        String oldContentUrl = mDataStore.getProperty(Config.CONTENT_URL);
-        String oldMetadataUrl = mDataStore.getProperty(Config.METADATA_URL);
-
-        if (TextUtils.equals(newVersion, oldVersion)
-                && TextUtils.equals(newContentUrl, oldContentUrl)
-                && TextUtils.equals(newMetadataUrl, oldMetadataUrl)) {
-            Log.i(TAG, "No flag changed, ignoring update");
-            return;
-        }
-
-        try {
-            mSignatureVerifier.setPublicKey(newPublicKey);
-        } catch (GeneralSecurityException | IllegalArgumentException e) {
-            Log.e(TAG, "Error setting the public Key", e);
-            return;
-        }
-
-        // TODO: handle the case where there is already a pending download.
-
-        mDataStore.setProperty(Config.CONTENT_URL, newContentUrl);
-        mDataStore.setProperty(Config.METADATA_URL, newMetadataUrl);
-        mDataStore.store();
-
-        if (mCertificateTransparencyDownloader.startMetadataDownload() == -1) {
-            Log.e(TAG, "Metadata download not started.");
-        } else if (Config.DEBUG) {
-            Log.d(TAG, "Metadata download started successfully.");
-        }
-    }
-}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
deleted file mode 100644
index 4ca97eb..0000000
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
+++ /dev/null
@@ -1,92 +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 com.android.server.net.ct;
-
-import android.util.Log;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Installer of CT log lists. */
-public class CertificateTransparencyInstaller {
-
-    private static final String TAG = "CertificateTransparencyInstaller";
-
-    private final Map<String, CompatibilityVersion> mCompatVersions = new HashMap<>();
-
-    // The CT root directory.
-    private final File mRootDirectory;
-
-    public CertificateTransparencyInstaller(File rootDirectory) {
-        mRootDirectory = rootDirectory;
-    }
-
-    public CertificateTransparencyInstaller(String rootDirectoryPath) {
-        this(new File(rootDirectoryPath));
-    }
-
-    public CertificateTransparencyInstaller() {
-        this(Config.CT_ROOT_DIRECTORY_PATH);
-    }
-
-    void addCompatibilityVersion(String versionName) {
-        removeCompatibilityVersion(versionName);
-        CompatibilityVersion newCompatVersion =
-                new CompatibilityVersion(new File(mRootDirectory, versionName));
-        mCompatVersions.put(versionName, newCompatVersion);
-    }
-
-    void removeCompatibilityVersion(String versionName) {
-        CompatibilityVersion compatVersion = mCompatVersions.remove(versionName);
-        if (compatVersion != null && !compatVersion.delete()) {
-            Log.w(TAG, "Could not delete compatibility version directory.");
-        }
-    }
-
-    CompatibilityVersion getCompatibilityVersion(String versionName) {
-        return mCompatVersions.get(versionName);
-    }
-
-    /**
-     * Install a new log list to use during SCT verification.
-     *
-     * @param compatibilityVersion the compatibility version of the new log list
-     * @param newContent an input stream providing the log list
-     * @param version the minor version of the new log list
-     * @return true if the log list was installed successfully, false otherwise.
-     * @throws IOException if the list cannot be saved in the CT directory.
-     */
-    public boolean install(String compatibilityVersion, InputStream newContent, String version)
-            throws IOException {
-        CompatibilityVersion compatVersion = mCompatVersions.get(compatibilityVersion);
-        if (compatVersion == null) {
-            Log.e(TAG, "No compatibility version for " + compatibilityVersion);
-            return false;
-        }
-        // Ensure root directory exists and is readable.
-        DirectoryUtils.makeDir(mRootDirectory);
-
-        if (!compatVersion.install(newContent, version)) {
-            Log.e(TAG, "Failed to install logs for compatibility version " + compatibilityVersion);
-            return false;
-        }
-        Log.i(TAG, "New logs installed at " + compatVersion.getLogsDir());
-        return true;
-    }
-}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
index bf23cb0..286f326 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -17,71 +17,112 @@
 
 import android.annotation.RequiresApi;
 import android.app.AlarmManager;
+import android.app.DownloadManager;
 import android.app.PendingIntent;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.os.Build;
+import android.os.ConfigUpdate;
 import android.os.SystemClock;
 import android.util.Log;
 
+import java.util.Collection;
+
 /** Implementation of the Certificate Transparency job */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyJob extends BroadcastReceiver {
 
     private static final String TAG = "CertificateTransparencyJob";
 
-    private static final String ACTION_JOB_START = "com.android.server.net.ct.action.JOB_START";
-
     private final Context mContext;
     private final DataStore mDataStore;
     private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
+    private final SignatureVerifier mSignatureVerifier;
+    private final Collection<CompatibilityVersion> mCompatVersions;
     private final AlarmManager mAlarmManager;
+    private final PendingIntent mPendingIntent;
+
+    private boolean mScheduled = false;
+    private boolean mDependenciesReady = false;
 
     /** Creates a new {@link CertificateTransparencyJob} object. */
     public CertificateTransparencyJob(
             Context context,
             DataStore dataStore,
-            CertificateTransparencyDownloader certificateTransparencyDownloader) {
+            CertificateTransparencyDownloader certificateTransparencyDownloader,
+            SignatureVerifier signatureVerifier,
+            Collection<CompatibilityVersion> compatVersions) {
         mContext = context;
         mDataStore = dataStore;
         mCertificateTransparencyDownloader = certificateTransparencyDownloader;
+        mSignatureVerifier = signatureVerifier;
+        mCompatVersions = compatVersions;
+
         mAlarmManager = context.getSystemService(AlarmManager.class);
+        mPendingIntent =
+                PendingIntent.getBroadcast(
+                        mContext,
+                        /* requestCode= */ 0,
+                        new Intent(ConfigUpdate.ACTION_UPDATE_CT_LOGS),
+                        PendingIntent.FLAG_IMMUTABLE);
     }
 
-    void initialize() {
-        mDataStore.load();
-        mCertificateTransparencyDownloader.initialize();
-
-        mContext.registerReceiver(
-                this, new IntentFilter(ACTION_JOB_START), Context.RECEIVER_EXPORTED);
-        mAlarmManager.setInexactRepeating(
-                AlarmManager.ELAPSED_REALTIME,
-                SystemClock.elapsedRealtime(), // schedule first job at earliest convenient time.
-                AlarmManager.INTERVAL_DAY,
-                PendingIntent.getBroadcast(
-                        mContext, 0, new Intent(ACTION_JOB_START), PendingIntent.FLAG_IMMUTABLE));
+    void schedule() {
+        if (!mScheduled) {
+            mContext.registerReceiver(
+                    this,
+                    new IntentFilter(ConfigUpdate.ACTION_UPDATE_CT_LOGS),
+                    Context.RECEIVER_EXPORTED);
+            mAlarmManager.setInexactRepeating(
+                    AlarmManager.ELAPSED_REALTIME,
+                    SystemClock
+                            .elapsedRealtime(), // schedule first job at earliest convenient time.
+                    AlarmManager.INTERVAL_DAY,
+                    mPendingIntent);
+        }
+        mScheduled = true;
 
         if (Config.DEBUG) {
-            Log.d(TAG, "CertificateTransparencyJob scheduled successfully.");
+            Log.d(TAG, "CertificateTransparencyJob scheduled.");
+        }
+    }
+
+    void cancel() {
+        if (mScheduled) {
+            mContext.unregisterReceiver(this);
+            mAlarmManager.cancel(mPendingIntent);
+        }
+        mScheduled = false;
+
+        if (mDependenciesReady) {
+            stopDependencies();
+        }
+        mDependenciesReady = false;
+
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            compatVersion.delete();
+        }
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob canceled.");
         }
     }
 
     @Override
     public void onReceive(Context context, Intent intent) {
-        if (!ACTION_JOB_START.equals(intent.getAction())) {
+        if (!ConfigUpdate.ACTION_UPDATE_CT_LOGS.equals(intent.getAction())) {
             Log.w(TAG, "Received unexpected broadcast with action " + intent);
             return;
         }
         if (Config.DEBUG) {
             Log.d(TAG, "Starting CT daily job.");
         }
-
-        mDataStore.setProperty(Config.CONTENT_URL, Config.URL_LOG_LIST);
-        mDataStore.setProperty(Config.METADATA_URL, Config.URL_SIGNATURE);
-        mDataStore.setProperty(Config.PUBLIC_KEY_URL, Config.URL_PUBLIC_KEY);
-        mDataStore.store();
+        if (!mDependenciesReady) {
+            startDependencies();
+            mDependenciesReady = true;
+        }
 
         if (mCertificateTransparencyDownloader.startPublicKeyDownload() == -1) {
             Log.e(TAG, "Public key download not started.");
@@ -89,4 +130,27 @@
             Log.d(TAG, "Public key download started successfully.");
         }
     }
+
+    private void startDependencies() {
+        mDataStore.load();
+        mSignatureVerifier.loadAllowedKeys();
+        mContext.registerReceiver(
+                mCertificateTransparencyDownloader,
+                new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+                Context.RECEIVER_EXPORTED);
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob dependencies ready.");
+        }
+    }
+
+    private void stopDependencies() {
+        mContext.unregisterReceiver(mCertificateTransparencyDownloader);
+        mSignatureVerifier.clearAllowedKeys();
+        mDataStore.delete();
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob dependencies stopped.");
+        }
+    }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
new file mode 100644
index 0000000..2a37d8f
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.net.ct;
+
+/** Interface with logging to statsd for Certificate Transparency. */
+public interface CertificateTransparencyLogger {
+
+    /**
+     * Logs a CTLogListUpdateStateChanged event to statsd.
+     *
+     * @param updateStatus status object containing details from this update event (e.g. log list
+     * signature, log list timestamp, failure reason if applicable)
+     */
+    void logCTLogListUpdateStateChangedEvent(LogListUpdateStatus updateStatus);
+
+    /**
+     * Intermediate enum for use with CertificateTransparencyStatsLog.
+     *
+     * This enum primarily exists to avoid 100+ char line alert fatigue.
+     */
+    enum CTLogListUpdateState {
+        UNKNOWN_STATE,
+        HTTP_ERROR,
+        LOG_LIST_INVALID,
+        PUBLIC_KEY_NOT_FOUND,
+        SIGNATURE_INVALID,
+        SIGNATURE_NOT_FOUND,
+        SIGNATURE_VERIFICATION_FAILED,
+        SUCCESS,
+        VERSION_ALREADY_EXISTS
+    }
+}
\ No newline at end of file
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java
new file mode 100644
index 0000000..f617523
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java
@@ -0,0 +1,153 @@
+/*
+ * 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.net.ct;
+
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_DEVICE_OFFLINE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_DOWNLOAD_CANNOT_RESUME;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_HTTP_ERROR;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_LOG_LIST_INVALID;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_NO_DISK_SPACE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_PUBLIC_KEY_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_INVALID;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_VERIFICATION;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_TOO_MANY_REDIRECTS;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_UNKNOWN;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_VERSION_ALREADY_EXISTS;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__PENDING_WAITING_FOR_WIFI;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__SUCCESS;
+
+import android.app.DownloadManager;
+
+/** Implementation for logging to statsd for Certificate Transparency. */
+class CertificateTransparencyLoggerImpl implements CertificateTransparencyLogger {
+
+    private final DataStore mDataStore;
+
+    CertificateTransparencyLoggerImpl(DataStore dataStore) {
+        mDataStore = dataStore;
+    }
+
+    @Override
+    public void logCTLogListUpdateStateChangedEvent(LogListUpdateStatus updateStatus) {
+        if (updateStatus.isSuccessful()) {
+            resetFailureCount();
+        } else {
+            updateFailureCount();
+        }
+
+        int updateState =
+                updateStatus
+                        .downloadStatus()
+                        .map(s -> downloadStatusToFailureReason(s))
+                        .orElseGet(() -> localEnumToStatsLogEnum(updateStatus.state()));
+        int failureCount =
+                mDataStore.getPropertyInt(
+                        Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0);
+
+        logCTLogListUpdateStateChangedEvent(
+                updateState,
+                failureCount,
+                updateStatus.httpErrorStatusCode(),
+                updateStatus.signature(),
+                updateStatus.logListTimestamp());
+    }
+
+    private void logCTLogListUpdateStateChangedEvent(
+            int updateState,
+            int failureCount,
+            int httpErrorStatusCode,
+            String signature,
+            long logListTimestamp) {
+        CertificateTransparencyStatsLog.write(
+                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED,
+                updateState,
+                failureCount,
+                httpErrorStatusCode,
+                signature,
+                logListTimestamp);
+    }
+
+    /**
+     * Resets the number of consecutive log list update failures in the data store back to zero.
+     */
+    private void resetFailureCount() {
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* value= */ 0);
+        mDataStore.store();
+    }
+
+    /**
+     * Updates the data store with the current number of consecutive log list update failures.
+     */
+    private void updateFailureCount() {
+        int failure_count =
+                mDataStore.getPropertyInt(
+                        Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0);
+        int new_failure_count = failure_count + 1;
+
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, new_failure_count);
+        mDataStore.store();
+    }
+
+    /** Converts DownloadStatus reason into failure reason to log. */
+    private int downloadStatusToFailureReason(int downloadStatusReason) {
+        switch (downloadStatusReason) {
+            case DownloadManager.PAUSED_WAITING_TO_RETRY:
+            case DownloadManager.PAUSED_WAITING_FOR_NETWORK:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_DEVICE_OFFLINE;
+            case DownloadManager.ERROR_UNHANDLED_HTTP_CODE:
+            case DownloadManager.ERROR_HTTP_DATA_ERROR:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_HTTP_ERROR;
+            case DownloadManager.ERROR_TOO_MANY_REDIRECTS:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_TOO_MANY_REDIRECTS;
+            case DownloadManager.ERROR_CANNOT_RESUME:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_DOWNLOAD_CANNOT_RESUME;
+            case DownloadManager.ERROR_INSUFFICIENT_SPACE:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_NO_DISK_SPACE;
+            case DownloadManager.PAUSED_QUEUED_FOR_WIFI:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__PENDING_WAITING_FOR_WIFI;
+            default:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_UNKNOWN;
+        }
+    }
+
+    /** Converts the local enum to the corresponding auto-generated one used by CTStatsLog. */
+    private int localEnumToStatsLogEnum(CTLogListUpdateState updateState) {
+        switch (updateState) {
+            case HTTP_ERROR:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_HTTP_ERROR;
+            case LOG_LIST_INVALID:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_LOG_LIST_INVALID;
+            case PUBLIC_KEY_NOT_FOUND:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_PUBLIC_KEY_NOT_FOUND;
+            case SIGNATURE_INVALID:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_INVALID;
+            case SIGNATURE_NOT_FOUND:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_NOT_FOUND;
+            case SIGNATURE_VERIFICATION_FAILED:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_SIGNATURE_VERIFICATION;
+            case SUCCESS:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__SUCCESS;
+            case VERSION_ALREADY_EXISTS:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_VERSION_ALREADY_EXISTS;
+            case UNKNOWN_STATE:
+            default:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_STATE_CHANGED__UPDATE_STATUS__FAILURE_UNKNOWN;
+        }
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index eef867c..5e530c7 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -16,49 +16,68 @@
 
 package com.android.server.net.ct;
 
+import static android.security.Flags.certificateTransparencyConfiguration;
+
+import static com.android.net.ct.flags.Flags.certificateTransparencyService;
+
 import android.annotation.RequiresApi;
 import android.content.Context;
 import android.net.ct.ICertificateTransparencyManager;
 import android.os.Build;
 import android.provider.DeviceConfig;
+import android.provider.DeviceConfig.Properties;
+import android.util.Log;
 
-import com.android.net.ct.flags.Flags;
 import com.android.server.SystemService;
 
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.concurrent.Executors;
+
 /** Implementation of the Certificate Transparency service. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
-public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
+public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub
+        implements DeviceConfig.OnPropertiesChangedListener {
 
-    private final CertificateTransparencyFlagsListener mFlagsListener;
+    private static final String TAG = "CertificateTransparencyService";
+
     private final CertificateTransparencyJob mCertificateTransparencyJob;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
      */
     public static boolean enabled(Context context) {
-        return DeviceConfig.getBoolean(
-                Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_SERVICE_ENABLED,
-                /* defaultValue= */ true)
-                && Flags.certificateTransparencyService();
+        return certificateTransparencyService() && certificateTransparencyConfiguration();
     }
 
     /** Creates a new {@link CertificateTransparencyService} object. */
     public CertificateTransparencyService(Context context) {
         DataStore dataStore = new DataStore(Config.PREFERENCES_FILE);
-        DownloadHelper downloadHelper = new DownloadHelper(context);
         SignatureVerifier signatureVerifier = new SignatureVerifier(context);
-        CertificateTransparencyDownloader downloader =
-                new CertificateTransparencyDownloader(
+        Collection<CompatibilityVersion> compatVersions =
+                Arrays.asList(
+                        new CompatibilityVersion(
+                                Config.COMPATIBILITY_VERSION_V1,
+                                Config.URL_SIGNATURE_V1,
+                                Config.URL_LOG_LIST_V1),
+                        new CompatibilityVersion(
+                                Config.COMPATIBILITY_VERSION_V2,
+                                Config.URL_SIGNATURE_V2,
+                                Config.URL_LOG_LIST_V2));
+
+        mCertificateTransparencyJob =
+                new CertificateTransparencyJob(
                         context,
                         dataStore,
-                        downloadHelper,
+                        new CertificateTransparencyDownloader(
+                                context,
+                                dataStore,
+                                new DownloadHelper(context),
+                                signatureVerifier,
+                                new CertificateTransparencyLoggerImpl(dataStore),
+                                compatVersions),
                         signatureVerifier,
-                        new CertificateTransparencyInstaller());
-
-        mFlagsListener =
-                new CertificateTransparencyFlagsListener(dataStore, signatureVerifier, downloader);
-        mCertificateTransparencyJob =
-                new CertificateTransparencyJob(context, dataStore, downloader);
+                        compatVersions);
     }
 
     /**
@@ -67,16 +86,46 @@
      * @see com.android.server.SystemService#onBootPhase
      */
     public void onBootPhase(int phase) {
-
         switch (phase) {
             case SystemService.PHASE_BOOT_COMPLETED:
-                if (Flags.certificateTransparencyJob()) {
-                    mCertificateTransparencyJob.initialize();
-                } else {
-                    mFlagsListener.initialize();
-                }
+                DeviceConfig.addOnPropertiesChangedListener(
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Executors.newSingleThreadExecutor(),
+                        this);
+                onPropertiesChanged(
+                        new Properties.Builder(Config.NAMESPACE_NETWORK_SECURITY).build());
                 break;
             default:
         }
     }
+
+    @Override
+    public void onPropertiesChanged(Properties properties) {
+        if (!Config.NAMESPACE_NETWORK_SECURITY.equals(properties.getNamespace())) {
+            return;
+        }
+
+        if (DeviceConfig.getBoolean(
+                Config.NAMESPACE_NETWORK_SECURITY,
+                Config.FLAG_SERVICE_ENABLED,
+                /* defaultValue= */ true)) {
+            startService();
+        } else {
+            stopService();
+        }
+    }
+
+    private void startService() {
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyService start");
+        }
+        mCertificateTransparencyJob.schedule();
+    }
+
+    private void stopService() {
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyService stop");
+        }
+        mCertificateTransparencyJob.cancel();
+    }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
index 27488b5..0a91963 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
@@ -15,58 +15,106 @@
  */
 package com.android.server.net.ct;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.annotation.RequiresApi;
+import android.os.Build;
 import android.system.ErrnoException;
 import android.system.Os;
+import android.util.Log;
 
+import androidx.annotation.VisibleForTesting;
+
+import com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 
 /** Represents a compatibility version directory. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 class CompatibilityVersion {
 
+    private static final String TAG = "CompatibilityVersion";
+
+    private static File sRootDirectory = new File(Config.CT_ROOT_DIRECTORY_PATH);
+
     static final String LOGS_DIR_PREFIX = "logs-";
     static final String LOGS_LIST_FILE_NAME = "log_list.json";
+    static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
 
-    private static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
+    private final String mCompatVersion;
 
-    private final File mRootDirectory;
+    private final String mMetadataUrl;
+    private final String mContentUrl;
+    private final File mVersionDirectory;
     private final File mCurrentLogsDirSymlink;
 
-    private File mCurrentLogsDir = null;
+    CompatibilityVersion(
+            String compatVersion, String metadataUrl, String contentUrl) {
+        mCompatVersion = compatVersion;
+        mMetadataUrl = metadataUrl;
+        mContentUrl = contentUrl;
+        mVersionDirectory = new File(sRootDirectory, compatVersion);
+        mCurrentLogsDirSymlink = new File(mVersionDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    }
 
-    CompatibilityVersion(File rootDirectory) {
-        mRootDirectory = rootDirectory;
-        mCurrentLogsDirSymlink = new File(mRootDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    @VisibleForTesting
+    static void setRootDirectoryForTesting(File rootDirectory) {
+        sRootDirectory = rootDirectory;
     }
 
     /**
      * Installs a log list within this compatibility version directory.
      *
      * @param newContent an input stream providing the log list
-     * @param version the version number of the log list
+     * @param statusBuilder status obj builder containing details of the log list update process
      * @return true if the log list was installed successfully, false otherwise.
      * @throws IOException if the list cannot be saved in the CT directory.
      */
-    boolean install(InputStream newContent, String version) throws IOException {
-        // To support atomically replacing the old configuration directory with the new there's a
-        // bunch of steps. We create a new directory with the logs and then do an atomic update of
-        // the current symlink to point to the new directory.
-        // 1. Ensure that the root directory exists and is readable.
-        DirectoryUtils.makeDir(mRootDirectory);
+    LogListUpdateStatus install(InputStream newContent, LogListUpdateStatus.Builder statusBuilder)
+            throws IOException {
+        String content = new String(newContent.readAllBytes(), UTF_8);
+        try {
+            JSONObject contentJson = new JSONObject(content);
+            return install(
+                    new ByteArrayInputStream(content.getBytes()),
+                    contentJson.getString("version"),
+                    statusBuilder.setLogListTimestamp(contentJson.getLong("log_list_timestamp")));
+        } catch (JSONException e) {
+            Log.e(TAG, "invalid log list format", e);
 
-        File newLogsDir = new File(mRootDirectory, LOGS_DIR_PREFIX + version);
+            return statusBuilder.setState(CTLogListUpdateState.LOG_LIST_INVALID).build();
+        }
+    }
+
+    LogListUpdateStatus install(
+            InputStream newContent, String version, LogListUpdateStatus.Builder statusBuilder)
+            throws IOException {
+        // To support atomically replacing the old configuration directory with the new
+        // there's a bunch of steps. We create a new directory with the logs and then do
+        // an atomic update of the current symlink to point to the new directory.
+        // 1. Ensure the path to the root and version directories exist and are readable.
+        DirectoryUtils.makeDir(sRootDirectory);
+        DirectoryUtils.makeDir(mVersionDirectory);
+
+        File newLogsDir = new File(mVersionDirectory, LOGS_DIR_PREFIX + version);
         // 2. Handle the corner case where the new directory already exists.
         if (newLogsDir.exists()) {
-            // If the symlink has already been updated then the update died between steps 6 and 7
-            // and so we cannot delete the directory since it is in use.
+            // If the symlink has already been updated then the update died between steps 6
+            // and 7 and so we cannot delete the directory since it is in use.
             if (newLogsDir.getCanonicalPath().equals(mCurrentLogsDirSymlink.getCanonicalPath())) {
+                Log.i(TAG, newLogsDir + " already exists, skipping install.");
                 deleteOldLogDirectories();
-                return false;
+                return statusBuilder.setState(CTLogListUpdateState.VERSION_ALREADY_EXISTS).build();
             }
-            // If the symlink has not been updated then the previous installation failed and this is
-            // a re-attempt. Clean-up leftover files and try again.
+            // If the symlink has not been updated then the previous installation failed and
+            // this is a re-attempt. Clean-up leftover files and try again.
             DirectoryUtils.removeDir(newLogsDir);
         }
         try {
@@ -80,8 +128,8 @@
             }
             DirectoryUtils.setWorldReadable(logListFile);
 
-            // 5. Create temp symlink. We rename this to the target symlink to get an atomic update.
-            File tempSymlink = new File(mRootDirectory, "new_symlink");
+            // 5. Create temp symlink. We rename to the target symlink for an atomic update.
+            File tempSymlink = new File(mVersionDirectory, "new_symlink");
             try {
                 Os.symlink(newLogsDir.getCanonicalPath(), tempSymlink.getCanonicalPath());
             } catch (ErrnoException e) {
@@ -95,17 +143,33 @@
             throw e;
         }
         // 7. Cleanup
-        mCurrentLogsDir = newLogsDir;
+        Log.i(TAG, "New logs installed at " + newLogsDir);
         deleteOldLogDirectories();
-        return true;
+        return statusBuilder.setState(CTLogListUpdateState.SUCCESS).build();
     }
 
-    File getRootDir() {
-        return mRootDirectory;
+    String getCompatVersion() {
+        return mCompatVersion;
     }
 
-    File getLogsDir() {
-        return mCurrentLogsDir;
+    String getMetadataUrl() {
+        return mMetadataUrl;
+    }
+
+    String getMetadataPropertyName() {
+        return mCompatVersion + "_" + Config.METADATA_DOWNLOAD_ID;
+    }
+
+    String getContentUrl() {
+        return mContentUrl;
+    }
+
+    String getContentPropertyName() {
+        return mCompatVersion + "_" + Config.CONTENT_DOWNLOAD_ID;
+    }
+
+    File getVersionDir() {
+        return mVersionDirectory;
     }
 
     File getLogsDirSymlink() {
@@ -113,19 +177,21 @@
     }
 
     File getLogsFile() {
-        return new File(mCurrentLogsDir, LOGS_LIST_FILE_NAME);
+        return new File(mCurrentLogsDirSymlink, LOGS_LIST_FILE_NAME);
     }
 
-    boolean delete() {
-        return DirectoryUtils.removeDir(mRootDirectory);
+    void delete() {
+        if (!DirectoryUtils.removeDir(mVersionDirectory)) {
+            Log.w(TAG, "Could not delete compatibility version directory " + mVersionDirectory);
+        }
     }
 
     private void deleteOldLogDirectories() throws IOException {
-        if (!mRootDirectory.exists()) {
+        if (!mVersionDirectory.exists()) {
             return;
         }
         File currentTarget = mCurrentLogsDirSymlink.getCanonicalFile();
-        for (File file : mRootDirectory.listFiles()) {
+        for (File file : mVersionDirectory.listFiles()) {
             if (!currentTarget.equals(file.getCanonicalFile())
                     && file.getName().startsWith(LOGS_DIR_PREFIX)) {
                 DirectoryUtils.removeDir(file);
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index 70d8e42..72b715a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,31 +33,34 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
-    // CT directory
+    // CT paths
     static final String CT_ROOT_DIRECTORY_PATH = "/data/misc/keychain/ct/";
-    static final String COMPATIBILITY_VERSION = "v1";
+    static final String URL_PREFIX = "https://www.gstatic.com/android/certificate_transparency/";
 
     // Phenotype flags
     static final String NAMESPACE_NETWORK_SECURITY = "network_security";
     private static final String FLAGS_PREFIX = "CertificateTransparencyLogList__";
     static final String FLAG_SERVICE_ENABLED = FLAGS_PREFIX + "service_enabled";
-    static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
-    static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
-    static final String FLAG_VERSION = FLAGS_PREFIX + "version";
-    static final String FLAG_PUBLIC_KEY = FLAGS_PREFIX + "public_key";
 
     // properties
     static final String VERSION = "version";
-    static final String CONTENT_URL = "content_url";
     static final String CONTENT_DOWNLOAD_ID = "content_download_id";
-    static final String METADATA_URL = "metadata_url";
     static final String METADATA_DOWNLOAD_ID = "metadata_download_id";
-    static final String PUBLIC_KEY_URL = "public_key_url";
     static final String PUBLIC_KEY_DOWNLOAD_ID = "public_key_download_id";
+    static final String LOG_LIST_UPDATE_FAILURE_COUNT = "log_list_update_failure_count";
 
-    // URLs
-    static final String URL_PREFIX = "https://www.gstatic.com/android/certificate_transparency/";
-    static final String URL_LOG_LIST = URL_PREFIX + "log_list.json";
-    static final String URL_SIGNATURE = URL_PREFIX + "log_list.sig";
+    // Public Key URLs
     static final String URL_PUBLIC_KEY = URL_PREFIX + "log_list.pub";
+
+    // Compatibility Version v1
+    static final String COMPATIBILITY_VERSION_V1 = "v1";
+    static final String URL_PREFIX_V1 = URL_PREFIX;
+    static final String URL_LOG_LIST_V1 = URL_PREFIX_V1 + "log_list.json";
+    static final String URL_SIGNATURE_V1 = URL_PREFIX_V1 + "log_list.sig";
+
+    // Compatibility Version v2
+    static final String COMPATIBILITY_VERSION_V2 = "v2";
+    static final String URL_PREFIX_V2 = URL_PREFIX + COMPATIBILITY_VERSION_V2 + "/";
+    static final String URL_LOG_LIST_V2 = URL_PREFIX_V2 + "log_list.json";
+    static final String URL_SIGNATURE_V2 = URL_PREFIX_V2 + "log_list.sig";
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/DataStore.java b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
index cd6aebf..1f99efa 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DataStore.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
@@ -44,8 +44,9 @@
         }
         try (InputStream in = new FileInputStream(mPropertyFile)) {
             load(in);
-        } catch (IOException e) {
+        } catch (IOException | IllegalArgumentException e) {
             Log.e(TAG, "Error loading property store", e);
+            delete();
         }
     }
 
@@ -57,6 +58,11 @@
         }
     }
 
+    boolean delete() {
+        clear();
+        return mPropertyFile.delete();
+    }
+
     long getPropertyLong(String key, long defaultValue) {
         return Optional.ofNullable(getProperty(key)).map(Long::parseLong).orElse(defaultValue);
     }
@@ -64,4 +70,12 @@
     Object setPropertyLong(String key, long value) {
         return setProperty(key, Long.toString(value));
     }
+
+    int getPropertyInt(String key, int defaultValue) {
+        return Optional.ofNullable(getProperty(key)).map(Integer::parseInt).orElse(defaultValue);
+    }
+
+    Object setPropertyInt(String key, int value) {
+        return setProperty(key, Integer.toString(value));
+    }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/LogListUpdateStatus.java b/networksecurity/service/src/com/android/server/net/ct/LogListUpdateStatus.java
new file mode 100644
index 0000000..3f9b762
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/LogListUpdateStatus.java
@@ -0,0 +1,89 @@
+/*
+ * 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.net.ct;
+
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.PUBLIC_KEY_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_INVALID;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_VERIFICATION_FAILED;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SUCCESS;
+
+import com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState;
+
+import com.google.auto.value.AutoValue;
+
+import java.util.Optional;
+
+/** Class to represent the signature verification status for Certificate Transparency. */
+@AutoValue
+public abstract class LogListUpdateStatus {
+
+    abstract CTLogListUpdateState state();
+
+    abstract String signature();
+
+    abstract long logListTimestamp();
+
+    abstract int httpErrorStatusCode();
+
+    abstract Optional<Integer> downloadStatus();
+
+    boolean isSignatureVerified() {
+        // Check that none of the signature verification failures have been set as the state
+        return state() != PUBLIC_KEY_NOT_FOUND
+                && state() != SIGNATURE_INVALID
+                && state() != SIGNATURE_NOT_FOUND
+                && state() != SIGNATURE_VERIFICATION_FAILED;
+    }
+
+    boolean hasSignature() {
+        return signature() != null && signature().length() > 0;
+    }
+
+    boolean isSuccessful() {
+        return state() == SUCCESS;
+    }
+
+    static LogListUpdateStatus getDefaultInstance() {
+        return builder().build();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+        abstract Builder setState(CTLogListUpdateState updateState);
+
+        abstract Builder setSignature(String signature);
+
+        abstract Builder setLogListTimestamp(long timestamp);
+
+        abstract Builder setHttpErrorStatusCode(int httpStatusCode);
+
+        abstract Builder setDownloadStatus(Optional<Integer> downloadStatus);
+
+        abstract LogListUpdateStatus build();
+    }
+
+    abstract LogListUpdateStatus.Builder toBuilder();
+
+    static Builder builder() {
+        return new AutoValue_LogListUpdateStatus.Builder()
+            .setState(CTLogListUpdateState.UNKNOWN_STATE)
+            .setSignature("")
+            .setLogListTimestamp(0L)
+            .setHttpErrorStatusCode(0)
+            .setDownloadStatus(Optional.empty());
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/PemReader.java b/networksecurity/service/src/com/android/server/net/ct/PemReader.java
new file mode 100644
index 0000000..56b3973
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/PemReader.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2025 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.net.ct;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.KeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+
+/** Utility class to read keys in PEM format. */
+class PemReader {
+
+    private static final String BEGIN = "-----BEGIN";
+    private static final String END = "-----END";
+
+    /**
+     * Parse the provided input stream and return the list of keys from the stream.
+     *
+     * @param input the input stream
+     * @return the keys
+     */
+    public static Collection<PublicKey> readKeysFrom(InputStream input)
+            throws IOException, GeneralSecurityException {
+        KeyFactory instance = KeyFactory.getInstance("RSA");
+        Collection<PublicKey> keys = new ArrayList<>();
+
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) {
+            String line = reader.readLine();
+            while (line != null) {
+                if (line.startsWith(BEGIN)) {
+                    keys.add(instance.generatePublic(readNextKey(reader)));
+                } else {
+                    throw new IOException("Unexpected line in the reader: " + line);
+                }
+                line = reader.readLine();
+            }
+        } catch (IllegalArgumentException e) {
+            throw new GeneralSecurityException("Invalid public key base64 encoding", e);
+        }
+
+        return keys;
+    }
+
+    private static KeySpec readNextKey(BufferedReader reader) throws IOException {
+        StringBuilder publicKeyBuilder = new StringBuilder();
+
+        String line = reader.readLine();
+        while (line != null) {
+            if (line.startsWith(END)) {
+                return new X509EncodedKeySpec(
+                        Base64.getDecoder().decode(publicKeyBuilder.toString()));
+            } else {
+                publicKeyBuilder.append(line);
+            }
+            line = reader.readLine();
+        }
+
+        throw new IOException("Unexpected end of the reader");
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
index 0b775ca..87a4973 100644
--- a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
+++ b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
@@ -15,15 +15,24 @@
  */
 package com.android.server.net.ct;
 
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.PUBLIC_KEY_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_INVALID;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SIGNATURE_VERIFICATION_FAILED;
+
 import android.annotation.NonNull;
 import android.annotation.RequiresApi;
 import android.content.ContentResolver;
 import android.content.Context;
 import android.net.Uri;
 import android.os.Build;
+import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.connectivity.resources.R;
+import com.android.server.connectivity.ConnectivityResources;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
@@ -33,20 +42,39 @@
 import java.security.Signature;
 import java.security.spec.X509EncodedKeySpec;
 import java.util.Base64;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /** Verifier of the log list signature. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SignatureVerifier {
 
+    private static final String TAG = "SignatureVerifier";
+
     private final Context mContext;
 
     @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
 
+    private final Set<PublicKey> mAllowedKeys = new HashSet<>();
+
     public SignatureVerifier(Context context) {
         mContext = context;
     }
 
+    void loadAllowedKeys() {
+        try (InputStream input =
+                new ConnectivityResources(mContext).get().openRawResource(R.raw.ct_public_keys)) {
+            mAllowedKeys.addAll(PemReader.readKeysFrom(input));
+        } catch (GeneralSecurityException | IOException e) {
+            Log.e(TAG, "Error loading public keys", e);
+        }
+    }
+
+    void clearAllowedKeys() {
+        mAllowedKeys.clear();
+    }
+
     @VisibleForTesting
     Optional<PublicKey> getPublicKey() {
         return mPublicKey;
@@ -63,29 +91,75 @@
     }
 
     void setPublicKey(String publicKey) throws GeneralSecurityException {
+        byte[] decodedPublicKey = null;
+        try {
+            decodedPublicKey = Base64.getDecoder().decode(publicKey);
+        } catch (IllegalArgumentException e) {
+            throw new GeneralSecurityException("Invalid public key base64 encoding", e);
+        }
         setPublicKey(
                 KeyFactory.getInstance("RSA")
-                        .generatePublic(
-                                new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))));
+                        .generatePublic(new X509EncodedKeySpec(decodedPublicKey)));
     }
 
     @VisibleForTesting
-    void setPublicKey(PublicKey publicKey) {
+    void setPublicKey(PublicKey publicKey) throws GeneralSecurityException {
+        if (!mAllowedKeys.contains(publicKey)) {
+            // TODO(b/400704086): add logging for this failure.
+            throw new GeneralSecurityException("Public key not in allowlist");
+        }
         mPublicKey = Optional.of(publicKey);
     }
 
-    boolean verify(Uri file, Uri signature) throws GeneralSecurityException, IOException {
+    LogListUpdateStatus verify(Uri file, Uri signature) {
+        LogListUpdateStatus.Builder statusBuilder = LogListUpdateStatus.builder();
+
         if (!mPublicKey.isPresent()) {
-            throw new InvalidKeyException("Missing public key for signature verification");
+            statusBuilder.setState(PUBLIC_KEY_NOT_FOUND);
+            Log.e(TAG, "No public key found for log list verification");
+            return statusBuilder.build();
         }
-        Signature verifier = Signature.getInstance("SHA256withRSA");
-        verifier.initVerify(mPublicKey.get());
+
         ContentResolver contentResolver = mContext.getContentResolver();
 
         try (InputStream fileStream = contentResolver.openInputStream(file);
                 InputStream signatureStream = contentResolver.openInputStream(signature)) {
+            Signature verifier = Signature.getInstance("SHA256withRSA");
+            verifier.initVerify(mPublicKey.get());
             verifier.update(fileStream.readAllBytes());
-            return verifier.verify(signatureStream.readAllBytes());
+
+            byte[] signatureBytes = signatureStream.readAllBytes();
+            statusBuilder.setSignature(new String(signatureBytes));
+
+            if (!verifier.verify(Base64.getDecoder().decode(signatureBytes))) {
+                // Leave the UpdateState as UNKNOWN_STATE if successful as there are other
+                // potential failures past the signature verification step
+                statusBuilder.setState(SIGNATURE_VERIFICATION_FAILED);
+            }
+        } catch (IllegalArgumentException e) {
+            Log.w(TAG, "Invalid signature base64 encoding", e);
+            statusBuilder.setState(SIGNATURE_INVALID);
+            return statusBuilder.build();
+        } catch (InvalidKeyException e) {
+            Log.e(TAG, "Key invalid for log list verification", e);
+            statusBuilder.setState(SIGNATURE_INVALID);
+            return statusBuilder.build();
+        } catch (IOException | GeneralSecurityException e) {
+            Log.e(TAG, "Could not verify new log list", e);
+            statusBuilder.setState(SIGNATURE_VERIFICATION_FAILED);
+            return statusBuilder.build();
         }
+
+        // Double check if the signature is empty that we set the state correctly
+        if (!statusBuilder.build().hasSignature()) {
+            statusBuilder.setState(SIGNATURE_NOT_FOUND);
+        }
+
+        return statusBuilder.build();
+    }
+
+    @VisibleForTesting
+    boolean addAllowedKey(PublicKey publicKey) {
+        return mAllowedKeys.add(publicKey);
     }
 }
diff --git a/networksecurity/tests/unit/Android.bp b/networksecurity/tests/unit/Android.bp
index 11263cf..1336acc 100644
--- a/networksecurity/tests/unit/Android.bp
+++ b/networksecurity/tests/unit/Android.bp
@@ -41,4 +41,5 @@
     ],
 
     sdk_version: "test_current",
+    min_sdk_version: "VanillaIceCream",
 }
diff --git a/networksecurity/tests/unit/AndroidTest.xml b/networksecurity/tests/unit/AndroidTest.xml
index 3c94df7..fc0943e 100644
--- a/networksecurity/tests/unit/AndroidTest.xml
+++ b/networksecurity/tests/unit/AndroidTest.xml
@@ -17,6 +17,12 @@
 <configuration description="Runs NetworkSecurity Mainline unit Tests.">
     <option name="test-tag" value="NetworkSecurityUnitTests" />
 
+    <!--
+        Only run tests if the device under test is SDK version 35 (Android 15) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk35ModuleController" />
+
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="test-file-name" value="NetworkSecurityUnitTests.apk" />
     </target_preparer>
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 ffa1283..956bad5 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
@@ -13,19 +13,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package com.android.server.net.ct;
 
+import static com.google.common.io.Files.toByteArray;
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-
 import android.app.DownloadManager;
 import android.app.DownloadManager.Query;
 import android.app.DownloadManager.Request;
@@ -37,6 +35,8 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState;
+
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.junit.After;
@@ -44,6 +44,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -59,24 +60,29 @@
 import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.security.Signature;
+import java.util.Arrays;
 import java.util.Base64;
+import java.util.Optional;
 
 /** Tests for the {@link CertificateTransparencyDownloader}. */
 @RunWith(JUnit4.class)
 public class CertificateTransparencyDownloaderTest {
 
     @Mock private DownloadManager mDownloadManager;
-    @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
+    @Mock private CertificateTransparencyLogger mLogger;
+    private ArgumentCaptor<LogListUpdateStatus> mUpdateStatusCaptor =
+            ArgumentCaptor.forClass(LogListUpdateStatus.class);
 
     private PrivateKey mPrivateKey;
     private PublicKey mPublicKey;
     private Context mContext;
-    private File mTempFile;
     private DataStore mDataStore;
     private SignatureVerifier mSignatureVerifier;
+    private CompatibilityVersion mCompatVersion;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
     private long mNextDownloadId = 666;
+    private static final long LOG_LIST_TIMESTAMP = 123456789L;
 
     @Before
     public void setUp() throws IOException, GeneralSecurityException {
@@ -87,64 +93,74 @@
         mPublicKey = keyPair.getPublic();
 
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
-        mTempFile = File.createTempFile("datastore-test", ".properties");
-        mDataStore = new DataStore(mTempFile);
+        mDataStore = new DataStore(File.createTempFile("datastore-test", ".properties"));
         mSignatureVerifier = new SignatureVerifier(mContext);
+
+        CompatibilityVersion.setRootDirectoryForTesting(mContext.getFilesDir());
+        mCompatVersion =
+                new CompatibilityVersion(
+                        /* compatVersion= */ "v666",
+                        Config.URL_SIGNATURE_V1,
+                        Config.URL_LOG_LIST_V1);
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
                         mContext,
                         mDataStore,
                         new DownloadHelper(mDownloadManager),
                         mSignatureVerifier,
-                        mCertificateTransparencyInstaller);
+                        mLogger,
+                        Arrays.asList(mCompatVersion));
 
-        prepareDataStore();
         prepareDownloadManager();
+        mSignatureVerifier.addAllowedKey(mPublicKey);
+        mDataStore.load();
     }
 
     @After
     public void tearDown() {
-        mTempFile.delete();
         mSignatureVerifier.resetPublicKey();
+        mCompatVersion.delete();
+        mDataStore.delete();
     }
 
     @Test
     public void testDownloader_startPublicKeyDownload() {
         assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isFalse();
+
         long downloadId = mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isPublicKeyDownloadId(downloadId)).isTrue();
+        assertThat(mCertificateTransparencyDownloader.getPublicKeyDownloadId())
+                .isEqualTo(downloadId);
     }
 
     @Test
     public void testDownloader_startMetadataDownload() {
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
-        long downloadId = mCertificateTransparencyDownloader.startMetadataDownload();
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isTrue();
     }
 
     @Test
     public void testDownloader_startContentDownload() {
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
-        long downloadId = mCertificateTransparencyDownloader.startContentDownload();
+
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
-        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isTrue();
     }
 
     @Test
     public void testDownloader_publicKeyDownloadSuccess_updatePublicKey_startMetadataDownload()
             throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setSuccessfulDownload(publicKeyId, writePublicKeyToFile(mPublicKey));
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext, makePublicKeyDownloadCompleteIntent(writePublicKeyToFile(mPublicKey)));
 
         assertThat(mSignatureVerifier.getPublicKey()).hasValue(mPublicKey);
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
@@ -152,16 +168,32 @@
 
     @Test
     public void
-            testDownloader_publicKeyDownloadSuccess_updatePublicKeyFail_doNotStartMetadataDownload()
+            testDownloader_publicKeyDownloadSuccess_publicKeyNotAllowed_doNotStartMetadataDownload()
                     throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setSuccessfulDownload(
-                publicKeyId, writeToFile("i_am_not_a_base64_encoded_public_key".getBytes()));
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+        PublicKey notAllowed = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext, makePublicKeyDownloadCompleteIntent(writePublicKeyToFile(notAllowed)));
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+    }
+
+    @Test
+    public void
+            testDownloader_publicKeyDownloadSuccess_updatePublicKeyFail_doNotStartMetadataDownload()
+                    throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makePublicKeyDownloadCompleteIntent(
+                        writeToFile("i_am_not_a_base64_encoded_public_key".getBytes())));
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
@@ -169,109 +201,241 @@
 
     @Test
     public void testDownloader_publicKeyDownloadFail_doNotUpdatePublicKey() throws Exception {
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
-        setFailedDownload(
-                publicKeyId, // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(publicKeyId);
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_HTTP_DATA_ERROR));
 
         assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
         assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
     }
 
     @Test
+    public void testDownloader_publicKeyDownloadFail_logsFailure() throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(
+                        LogListUpdateStatus.builder()
+                                .setDownloadStatus(
+                                        Optional.of(DownloadManager.ERROR_INSUFFICIENT_SPACE))
+                                .build());
+    }
+
+    @Test
     public void testDownloader_metadataDownloadSuccess_startContentDownload() {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, new File("log_list.sig"));
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext,
+                makeMetadataDownloadCompleteIntent(mCompatVersion, new File("log_list.sig")));
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
     }
 
     @Test
     public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setFailedDownload(
-                metadataId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
 
         assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
     }
 
     @Test
-    public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
-            throws Exception {
+    public void testDownloader_metadataDownloadFail_logsFailure() throws Exception {
+        mCertificateTransparencyDownloader.startMetadataDownload();
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(
+                        LogListUpdateStatus.builder()
+                                .setDownloadStatus(
+                                        Optional.of(DownloadManager.ERROR_INSUFFICIENT_SPACE))
+                                .build());
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installSuccess() throws Exception {
         String newVersion = "456";
         File logListFile = makeLogListFile(newVersion);
         File metadataFile = sign(logListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(true);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
         assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
         assertInstallSuccessful(newVersion);
     }
 
     @Test
     public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setFailedDownload(
-                contentId,
-                // Failure cases where we give up on the download.
-                DownloadManager.ERROR_INSUFFICIENT_SPACE,
-                DownloadManager.ERROR_HTTP_DATA_ERROR);
-        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
 
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
-        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
 
-        verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
         assertNoVersionIsInstalled();
     }
 
     @Test
-    public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
+    public void testDownloader_contentDownloadFail_logsFailure() throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(
+                        LogListUpdateStatus.builder()
+                                .setDownloadStatus(
+                                        Optional.of(DownloadManager.ERROR_INSUFFICIENT_SPACE))
+                                .build());
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_invalidLogList_installFails()
+            throws Exception {
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        mCertificateTransparencyDownloader.startMetadataDownload();
+
+        assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
+
+        assertNoVersionIsInstalled();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_noPublicKeyFound_logsSingleFailure()
             throws Exception {
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(false);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
-        assertNoVersionIsInstalled();
+        // Set the public key to be missing
+        mSignatureVerifier.resetPublicKey();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        assertNoVersionIsInstalled();
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(mUpdateStatusCaptor.capture());
+        assertThat(mUpdateStatusCaptor.getValue().state())
+                .isEqualTo(CTLogListUpdateState.PUBLIC_KEY_NOT_FOUND);
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_wrongSignatureAlgo_logsSingleFailure()
+            throws Exception {
+        // Arrange
+        File logListFile = makeLogListFile("456");
+        File metadataFile = sign(logListFile);
+
+        // Set the key to be deliberately wrong by using diff algorithm
+        PublicKey wrongAlgorithmKey =
+                KeyPairGenerator.getInstance("EC").generateKeyPair().getPublic();
+        mSignatureVerifier.addAllowedKey(wrongAlgorithmKey);
+        mSignatureVerifier.setPublicKey(wrongAlgorithmKey);
+
+        // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        // Assert
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(mUpdateStatusCaptor.capture());
+        assertThat(mUpdateStatusCaptor.getValue().state())
+                .isEqualTo(CTLogListUpdateState.SIGNATURE_INVALID);
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_signatureNotVerified_logsSingleFailure()
+            throws Exception {
+        // Arrange
+        File logListFile = makeLogListFile("456");
+        mSignatureVerifier.setPublicKey(mPublicKey);
+
+        // Sign the list with a disallowed key pair
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        File metadataFile = sign(logListFile, instance.generateKeyPair().getPrivate());
+
+        // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        // Assert
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(mUpdateStatusCaptor.capture());
+        LogListUpdateStatus statusValue = mUpdateStatusCaptor.getValue();
+        assertThat(statusValue.state())
+                .isEqualTo(CTLogListUpdateState.SIGNATURE_VERIFICATION_FAILED);
+        assertThat(statusValue.signature()).isEqualTo(new String(toByteArray(metadataFile)));
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installFail_logsFailure() throws Exception {
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
+
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(mUpdateStatusCaptor.capture());
+        LogListUpdateStatus statusValue = mUpdateStatusCaptor.getValue();
+        assertThat(statusValue.state()).isEqualTo(CTLogListUpdateState.LOG_LIST_INVALID);
+        assertThat(statusValue.signature()).isEqualTo(new String(toByteArray(metadataFile)));
     }
 
     @Test
@@ -280,17 +444,14 @@
         File logListFile = makeLogListFile("456");
         File metadataFile = File.createTempFile("log_list-wrong_metadata", "sig");
         mSignatureVerifier.setPublicKey(mPublicKey);
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
 
         assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
         assertNoVersionIsInstalled();
     }
 
@@ -300,22 +461,20 @@
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
         mSignatureVerifier.resetPublicKey();
-        long metadataId = mCertificateTransparencyDownloader.startMetadataDownload();
-        setSuccessfulDownload(metadataId, metadataFile);
-        long contentId = mCertificateTransparencyDownloader.startContentDownload();
-        setSuccessfulDownload(contentId, logListFile);
 
         assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, never())
-                .install(eq(Config.COMPATIBILITY_VERSION), any(), anyString());
         assertNoVersionIsInstalled();
     }
 
     @Test
-    public void testDownloader_endToEndSuccess_installNewVersion() throws Exception {
+    public void testDownloader_endToEndSuccess_installNewVersion_andLogsSuccess() throws Exception {
+        // Arrange
         String newVersion = "456";
         File logListFile = makeLogListFile(newVersion);
         File metadataFile = sign(logListFile);
@@ -323,53 +482,47 @@
 
         assertNoVersionIsInstalled();
 
+        // Act
         // 1. Start download of public key.
-        long publicKeyId = mCertificateTransparencyDownloader.startPublicKeyDownload();
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
-        // 2. On successful public key download, set the key and start the metatadata download.
-        setSuccessfulDownload(publicKeyId, publicKeyFile);
-
+        // 2. On successful public key download, set the key and start the metatadata
+        // download.
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(publicKeyId));
+                mContext, makePublicKeyDownloadCompleteIntent(publicKeyFile));
 
         // 3. On successful metadata download, start the content download.
-        long metadataId = mCertificateTransparencyDownloader.getMetadataDownloadId();
-        setSuccessfulDownload(metadataId, metadataFile);
-
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
 
-        // 4. On successful content download, verify the signature and install the new version.
-        long contentId = mCertificateTransparencyDownloader.getContentDownloadId();
-        setSuccessfulDownload(contentId, logListFile);
-        when(mCertificateTransparencyInstaller.install(
-                        eq(Config.COMPATIBILITY_VERSION), any(), anyString()))
-                .thenReturn(true);
-
+        // 4. On successful content download, verify the signature and install the new
+        // version.
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
+        // Assert
         assertInstallSuccessful(newVersion);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateStateChangedEvent(mUpdateStatusCaptor.capture());
+
+        LogListUpdateStatus statusValue = mUpdateStatusCaptor.getValue();
+        assertThat(statusValue.state()).isEqualTo(CTLogListUpdateState.SUCCESS);
+        assertThat(statusValue.signature()).isEqualTo(new String(toByteArray(metadataFile)));
+        assertThat(statusValue.logListTimestamp()).isEqualTo(LOG_LIST_TIMESTAMP);
     }
 
     private void assertNoVersionIsInstalled() {
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mCompatVersion.getVersionDir().exists()).isFalse();
     }
 
     private void assertInstallSuccessful(String version) {
-        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
-    }
-
-    private Intent makeDownloadCompleteIntent(long downloadId) {
-        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
-                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
-    }
-
-    private void prepareDataStore() {
-        mDataStore.load();
-        mDataStore.setProperty(Config.CONTENT_URL, Config.URL_LOG_LIST);
-        mDataStore.setProperty(Config.METADATA_URL, Config.URL_SIGNATURE);
-        mDataStore.setProperty(Config.PUBLIC_KEY_URL, Config.URL_PUBLIC_KEY);
+        File logsDir =
+                new File(
+                        mCompatVersion.getVersionDir(),
+                        CompatibilityVersion.LOGS_DIR_PREFIX + version);
+        assertThat(logsDir.exists()).isTrue();
+        File logsFile = new File(logsDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
+        assertThat(logsFile.exists()).isTrue();
     }
 
     private void prepareDownloadManager() {
@@ -377,6 +530,32 @@
                 .thenAnswer(invocation -> mNextDownloadId++);
     }
 
+    private Intent makePublicKeyDownloadCompleteIntent(File publicKeyfile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getPublicKeyDownloadId(), publicKeyfile);
+    }
+
+    private Intent makeMetadataDownloadCompleteIntent(
+            CompatibilityVersion compatVersion, File signatureFile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getMetadataDownloadId(compatVersion),
+                signatureFile);
+    }
+
+    private Intent makeContentDownloadCompleteIntent(
+            CompatibilityVersion compatVersion, File logListFile) {
+        return makeDownloadCompleteIntent(
+                mCertificateTransparencyDownloader.getContentDownloadId(compatVersion),
+                logListFile);
+    }
+
+    private Intent makeDownloadCompleteIntent(long downloadId, File file) {
+        when(mDownloadManager.query(any(Query.class))).thenReturn(makeSuccessfulDownloadCursor());
+        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(Uri.fromFile(file));
+        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
+    }
+
     private Cursor makeSuccessfulDownloadCursor() {
         MatrixCursor cursor =
                 new MatrixCursor(
@@ -387,9 +566,26 @@
         return cursor;
     }
 
-    private void setSuccessfulDownload(long downloadId, File file) {
-        when(mDownloadManager.query(any(Query.class))).thenReturn(makeSuccessfulDownloadCursor());
-        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(Uri.fromFile(file));
+    private Intent makePublicKeyDownloadFailedIntent(int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getPublicKeyDownloadId(), error);
+    }
+
+    private Intent makeMetadataDownloadFailedIntent(CompatibilityVersion compatVersion, int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getMetadataDownloadId(compatVersion), error);
+    }
+
+    private Intent makeContentDownloadFailedIntent(CompatibilityVersion compatVersion, int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getContentDownloadId(compatVersion), error);
+    }
+
+    private Intent makeDownloadFailedIntent(long downloadId, int error) {
+        when(mDownloadManager.query(any(Query.class))).thenReturn(makeFailedDownloadCursor(error));
+        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(null);
+        return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+                .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
     private Cursor makeFailedDownloadCursor(int error) {
@@ -402,16 +598,6 @@
         return cursor;
     }
 
-    private void setFailedDownload(long downloadId, int... downloadManagerErrors) {
-        Cursor first = makeFailedDownloadCursor(downloadManagerErrors[0]);
-        Cursor[] others = new Cursor[downloadManagerErrors.length - 1];
-        for (int i = 1; i < downloadManagerErrors.length; i++) {
-            others[i - 1] = makeFailedDownloadCursor(downloadManagerErrors[i]);
-        }
-        when(mDownloadManager.query(any())).thenReturn(first, others);
-        when(mDownloadManager.getUriForDownloadedFile(downloadId)).thenReturn(null);
-    }
-
     private File writePublicKeyToFile(PublicKey publicKey)
             throws IOException, GeneralSecurityException {
         return writeToFile(Base64.getEncoder().encode(publicKey.getEncoded()));
@@ -431,21 +617,30 @@
         File logListFile = File.createTempFile("log_list", "json");
 
         try (OutputStream outputStream = new FileOutputStream(logListFile)) {
-            outputStream.write(new JSONObject().put("version", version).toString().getBytes(UTF_8));
+            JSONObject contentJson =
+                    new JSONObject()
+                            .put("version", version)
+                            .put("log_list_timestamp", LOG_LIST_TIMESTAMP);
+            outputStream.write(contentJson.toString().getBytes());
         }
 
         return logListFile;
     }
 
     private File sign(File file) throws IOException, GeneralSecurityException {
+        return sign(file, mPrivateKey);
+    }
+
+    private File sign(File file, PrivateKey privateKey)
+            throws IOException, GeneralSecurityException {
         File signatureFile = File.createTempFile("log_list-metadata", "sig");
         Signature signer = Signature.getInstance("SHA256withRSA");
-        signer.initSign(mPrivateKey);
+        signer.initSign(privateKey);
 
         try (InputStream fileStream = new FileInputStream(file);
                 OutputStream outputStream = new FileOutputStream(signatureFile)) {
             signer.update(fileStream.readAllBytes());
-            outputStream.write(signer.sign());
+            outputStream.write(Base64.getEncoder().encode(signer.sign()));
         }
 
         return signatureFile;
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
deleted file mode 100644
index 50d3f23..0000000
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
+++ /dev/null
@@ -1,183 +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 com.android.server.net.ct;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import androidx.test.platform.app.InstrumentationRegistry;
-
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-/** Tests for the {@link CertificateTransparencyInstaller}. */
-@RunWith(JUnit4.class)
-public class CertificateTransparencyInstallerTest {
-
-    private static final String TEST_VERSION = "test-v1";
-
-    private File mTestDir =
-            new File(
-                    InstrumentationRegistry.getInstrumentation().getContext().getFilesDir(),
-                    "test-dir");
-    private CertificateTransparencyInstaller mCertificateTransparencyInstaller =
-            new CertificateTransparencyInstaller(mTestDir);
-
-    @Before
-    public void setUp() {
-        mCertificateTransparencyInstaller.addCompatibilityVersion(TEST_VERSION);
-    }
-
-    @After
-    public void tearDown() {
-        mCertificateTransparencyInstaller.removeCompatibilityVersion(TEST_VERSION);
-        DirectoryUtils.removeDir(mTestDir);
-    }
-
-    @Test
-    public void testCompatibilityVersion_installSuccessful() throws IOException {
-        assertThat(mTestDir.mkdir()).isTrue();
-        String content = "i_am_compatible";
-        String version = "i_am_version";
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-
-        try (InputStream inputStream = asStream(content)) {
-            assertThat(compatVersion.install(inputStream, version)).isTrue();
-        }
-        File logsDir = compatVersion.getLogsDir();
-        assertThat(logsDir.exists()).isTrue();
-        assertThat(logsDir.isDirectory()).isTrue();
-        assertThat(logsDir.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
-        File logsListFile = compatVersion.getLogsFile();
-        assertThat(logsListFile.exists()).isTrue();
-        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
-        assertThat(readAsString(logsListFile)).isEqualTo(content);
-        File logsSymlink = compatVersion.getLogsDirSymlink();
-        assertThat(logsSymlink.exists()).isTrue();
-        assertThat(logsSymlink.isDirectory()).isTrue();
-        assertThat(logsSymlink.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION + "/current");
-        assertThat(logsSymlink.getCanonicalPath()).isEqualTo(logsDir.getCanonicalPath());
-
-        assertThat(compatVersion.delete()).isTrue();
-        assertThat(logsDir.exists()).isFalse();
-        assertThat(logsSymlink.exists()).isFalse();
-        assertThat(logsListFile.exists()).isFalse();
-    }
-
-    @Test
-    public void testCompatibilityVersion_versionInstalledFailed() throws IOException {
-        assertThat(mTestDir.mkdir()).isTrue();
-
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-        File rootDir = compatVersion.getRootDir();
-        assertThat(rootDir.mkdir()).isTrue();
-
-        String existingVersion = "666";
-        File existingLogDir =
-                new File(rootDir, CompatibilityVersion.LOGS_DIR_PREFIX + existingVersion);
-        assertThat(existingLogDir.mkdir()).isTrue();
-
-        String existingContent = "somebody_tried_to_install_me_but_failed_halfway_through";
-        File logsListFile = new File(existingLogDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
-        assertThat(logsListFile.createNewFile()).isTrue();
-        writeToFile(logsListFile, existingContent);
-
-        String newContent = "i_am_the_real_content";
-        try (InputStream inputStream = asStream(newContent)) {
-            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
-        }
-
-        assertThat(readAsString(logsListFile)).isEqualTo(newContent);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_installSuccessfully() throws IOException {
-        String content = "i_am_a_certificate_and_i_am_transparent";
-        String version = "666";
-
-        try (InputStream inputStream = asStream(content)) {
-            assertThat(
-                            mCertificateTransparencyInstaller.install(
-                                    TEST_VERSION, inputStream, version))
-                    .isTrue();
-        }
-
-        assertThat(mTestDir.exists()).isTrue();
-        assertThat(mTestDir.isDirectory()).isTrue();
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-        File logsDir = compatVersion.getLogsDir();
-        assertThat(logsDir.exists()).isTrue();
-        assertThat(logsDir.isDirectory()).isTrue();
-        assertThat(logsDir.getAbsolutePath())
-                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
-        File logsListFile = compatVersion.getLogsFile();
-        assertThat(logsListFile.exists()).isTrue();
-        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
-        assertThat(readAsString(logsListFile)).isEqualTo(content);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_versionIsAlreadyInstalled()
-            throws IOException {
-        String existingVersion = "666";
-        String existingContent = "i_was_already_installed_successfully";
-        CompatibilityVersion compatVersion =
-                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
-
-        DirectoryUtils.makeDir(mTestDir);
-        try (InputStream inputStream = asStream(existingContent)) {
-            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
-        }
-
-        try (InputStream inputStream = asStream("i_will_be_ignored")) {
-            assertThat(
-                            mCertificateTransparencyInstaller.install(
-                                    TEST_VERSION, inputStream, existingVersion))
-                    .isFalse();
-        }
-
-        assertThat(readAsString(compatVersion.getLogsFile())).isEqualTo(existingContent);
-    }
-
-    private static InputStream asStream(String string) throws IOException {
-        return new ByteArrayInputStream(string.getBytes());
-    }
-
-    private static String readAsString(File file) throws IOException {
-        return new String(new FileInputStream(file).readAllBytes());
-    }
-
-    private static void writeToFile(File file, String string) throws IOException {
-        try (OutputStream out = new FileOutputStream(file)) {
-            out.write(string.getBytes());
-        }
-    }
-}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
new file mode 100644
index 0000000..0d15183
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.net.ct;
+
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.LOG_LIST_INVALID;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.SUCCESS;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.UNKNOWN_STATE;
+import static com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState.VERSION_ALREADY_EXISTS;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/** Tests for the {@link CompatibilityVersion}. */
+@RunWith(JUnit4.class)
+public class CompatibilityVersionTest {
+
+    private static final String TEST_VERSION = "v123";
+    private static final long LOG_LIST_TIMESTAMP = 123456789L;
+    private static final String SIGNATURE = "fake_signature";
+
+    private final File mTestDir =
+            InstrumentationRegistry.getInstrumentation().getContext().getFilesDir();
+
+    private CompatibilityVersion mCompatVersion;
+
+    @Before
+    public void setUp() {
+        CompatibilityVersion.setRootDirectoryForTesting(mTestDir);
+        mCompatVersion =
+                new CompatibilityVersion(
+                        TEST_VERSION, Config.URL_SIGNATURE_V1, Config.URL_LOG_LIST_V1);
+    }
+
+    @After
+    public void tearDown() {
+        mCompatVersion.delete();
+    }
+
+    @Test
+    public void testCompatibilityVersion_versionDirectory_setupSuccessful() {
+        File versionDir = mCompatVersion.getVersionDir();
+
+        assertThat(versionDir.exists()).isFalse();
+        assertThat(versionDir.getAbsolutePath()).startsWith(mTestDir.getAbsolutePath());
+        assertThat(versionDir.getAbsolutePath()).endsWith(TEST_VERSION);
+    }
+
+    @Test
+    public void testCompatibilityVersion_symlink_setupSuccessful() {
+        File dirSymlink = mCompatVersion.getLogsDirSymlink();
+
+        assertThat(dirSymlink.exists()).isFalse();
+        assertThat(dirSymlink.getAbsolutePath())
+                .startsWith(mCompatVersion.getVersionDir().getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_logsFile_setupSuccessful() {
+        File logsFile = mCompatVersion.getLogsFile();
+
+        assertThat(logsFile.exists()).isFalse();
+        assertThat(logsFile.getAbsolutePath())
+                .startsWith(mCompatVersion.getLogsDirSymlink().getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_installSuccessful_keepsStatusDetails() throws Exception {
+        String version = "i_am_version";
+        JSONObject logList = makeLogList(version, "i_am_content");
+
+        try (InputStream inputStream = asStream(logList)) {
+            assertThat(
+                            mCompatVersion.install(
+                                    inputStream,
+                                    LogListUpdateStatus.builder()
+                                            .setSignature(SIGNATURE)
+                                            .setState(UNKNOWN_STATE)))
+                    .isEqualTo(
+                            LogListUpdateStatus.builder()
+                                    .setSignature(SIGNATURE)
+                                    .setLogListTimestamp(LOG_LIST_TIMESTAMP)
+                                    // Ensure the state is correctly overridden to SUCCESS
+                                    .setState(SUCCESS)
+                                    .build());
+        }
+    }
+
+    @Test
+    public void testCompatibilityVersion_installSuccessful() throws Exception {
+        String version = "i_am_version";
+        JSONObject logList = makeLogList(version, "i_am_content");
+
+        try (InputStream inputStream = asStream(logList)) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(getSuccessfulUpdateStatus());
+        }
+
+        File logListFile = mCompatVersion.getLogsFile();
+        assertThat(logListFile.exists()).isTrue();
+        assertThat(logListFile.getCanonicalPath())
+                .isEqualTo(
+                        // <path-to-test-files>/v123/logs-i_am_version/log_list.json
+                        new File(
+                                        new File(
+                                                mCompatVersion.getVersionDir(),
+                                                CompatibilityVersion.LOGS_DIR_PREFIX + version),
+                                        CompatibilityVersion.LOGS_LIST_FILE_NAME)
+                                .getCanonicalPath());
+        assertThat(logListFile.getAbsolutePath())
+                .isEqualTo(
+                        // <path-to-test-files>/v123/current/log_list.json
+                        new File(
+                                        new File(
+                                                mCompatVersion.getVersionDir(),
+                                                CompatibilityVersion.CURRENT_LOGS_DIR_SYMLINK_NAME),
+                                        CompatibilityVersion.LOGS_LIST_FILE_NAME)
+                                .getAbsolutePath());
+    }
+
+    @Test
+    public void testCompatibilityVersion_deleteSuccessfully() throws Exception {
+        try (InputStream inputStream = asStream(makeLogList(/* version= */ "123"))) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(getSuccessfulUpdateStatus());
+        }
+
+        mCompatVersion.delete();
+
+        assertThat(mCompatVersion.getLogsFile().exists()).isFalse();
+    }
+
+    @Test
+    public void testCompatibilityVersion_invalidLogList() throws Exception {
+        try (InputStream inputStream = new ByteArrayInputStream(("not_a_valid_list".getBytes()))) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(LogListUpdateStatus.builder().setState(LOG_LIST_INVALID).build());
+        }
+
+        assertThat(mCompatVersion.getLogsFile().exists()).isFalse();
+    }
+
+    @Test
+    public void testCompatibilityVersion_incompleteVersionExists_replacesOldVersion()
+            throws Exception {
+        String existingVersion = "666";
+        File existingLogDir =
+                new File(
+                        mCompatVersion.getVersionDir(),
+                        CompatibilityVersion.LOGS_DIR_PREFIX + existingVersion);
+        assertThat(existingLogDir.mkdirs()).isTrue();
+        File logsListFile = new File(existingLogDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
+        assertThat(logsListFile.createNewFile()).isTrue();
+
+        JSONObject newLogList = makeLogList(existingVersion, "i_am_the_real_content");
+        try (InputStream inputStream = asStream(newLogList)) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(getSuccessfulUpdateStatus());
+        }
+
+        assertThat(readAsString(logsListFile)).isEqualTo(newLogList.toString());
+    }
+
+    @Test
+    public void testCompatibilityVersion_versionAlreadyExists_installFails() throws Exception {
+        String existingVersion = "666";
+        JSONObject existingLogList = makeLogList(existingVersion, "i_was_installed_successfully");
+        try (InputStream inputStream = asStream(existingLogList)) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(getSuccessfulUpdateStatus());
+        }
+
+        try (InputStream inputStream = asStream(makeLogList(existingVersion, "i_am_ignored"))) {
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
+                    .isEqualTo(
+                            LogListUpdateStatus.builder()
+                                    .setState(VERSION_ALREADY_EXISTS)
+                                    .setLogListTimestamp(LOG_LIST_TIMESTAMP)
+                                    .build());
+        }
+
+        assertThat(readAsString(mCompatVersion.getLogsFile()))
+                .isEqualTo(existingLogList.toString());
+    }
+
+    private static InputStream asStream(JSONObject logList) throws IOException {
+        return new ByteArrayInputStream(logList.toString().getBytes());
+    }
+
+    private static JSONObject makeLogList(String version) throws JSONException {
+        return new JSONObject()
+                .put("version", version)
+                .put("log_list_timestamp", LOG_LIST_TIMESTAMP);
+    }
+
+    private static JSONObject makeLogList(String version, String content) throws JSONException {
+        return makeLogList(version).put("content", content);
+    }
+
+    private static LogListUpdateStatus getSuccessfulUpdateStatus() {
+        return LogListUpdateStatus.builder()
+                .setState(SUCCESS)
+                .setLogListTimestamp(LOG_LIST_TIMESTAMP)
+                .build();
+    }
+
+    private static String readAsString(File file) throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            return new String(in.readAllBytes());
+        }
+    }
+}
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java
new file mode 100644
index 0000000..08629db
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2025 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.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.util.Base64;
+
+/** Tests for the {@link PemReader}. */
+@RunWith(JUnit4.class)
+public class PemReaderTest {
+
+    @Test
+    public void testReadKeys_singleKey() throws GeneralSecurityException, IOException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+
+        assertThat(PemReader.readKeysFrom(toInputStream(key))).containsExactly(key);
+    }
+
+    @Test
+    public void testReadKeys_multipleKeys() throws GeneralSecurityException, IOException {
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        PublicKey key1 = instance.generateKeyPair().getPublic();
+        PublicKey key2 = instance.generateKeyPair().getPublic();
+
+        assertThat(PemReader.readKeysFrom(toInputStream(key1, key2))).containsExactly(key1, key2);
+    }
+
+    @Test
+    public void testReadKeys_notSupportedKeyType() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("EC").generateKeyPair().getPublic();
+
+        assertThrows(
+                GeneralSecurityException.class, () -> PemReader.readKeysFrom(toInputStream(key)));
+    }
+
+    @Test
+    public void testReadKeys_notBase64EncodedKey() throws GeneralSecurityException {
+        InputStream inputStream =
+                new ByteArrayInputStream(
+                        (""
+                                        + "-----BEGIN PUBLIC KEY-----\n"
+                                        + KeyPairGenerator.getInstance("RSA")
+                                                .generateKeyPair()
+                                                .getPublic()
+                                                .toString()
+                                        + "\n-----END PUBLIC KEY-----\n")
+                                .getBytes());
+
+        assertThrows(GeneralSecurityException.class, () -> PemReader.readKeysFrom(inputStream));
+    }
+
+    @Test
+    public void testReadKeys_noPemBegin() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+        String base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
+        String pemNoBegin = base64Key + "\n-----END PUBLIC KEY-----\n";
+
+        assertThrows(
+                IOException.class,
+                () -> PemReader.readKeysFrom(new ByteArrayInputStream(pemNoBegin.getBytes())));
+    }
+
+    @Test
+    public void testReadKeys_noPemEnd() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+        String base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
+        String pemNoEnd = "-----BEGIN PUBLIC KEY-----\n" + base64Key;
+
+        assertThrows(
+                IOException.class,
+                () -> PemReader.readKeysFrom(new ByteArrayInputStream(pemNoEnd.getBytes())));
+    }
+
+    private InputStream toInputStream(PublicKey... keys) {
+        StringBuilder builder = new StringBuilder();
+
+        for (PublicKey key : keys) {
+            builder.append("-----BEGIN PUBLIC KEY-----\n")
+                    .append(Base64.getEncoder().encodeToString(key.getEncoded()))
+                    .append("\n-----END PUBLIC KEY-----\n");
+        }
+
+        return new ByteArrayInputStream(builder.toString().getBytes());
+    }
+}
diff --git a/remoteauth/OWNERS b/remoteauth/OWNERS
index 25a32b9..ee46c1c 100644
--- a/remoteauth/OWNERS
+++ b/remoteauth/OWNERS
@@ -2,7 +2,6 @@
 # Bug template url: http://b/new?component=1145231&template=1715387
 billyhuang@google.com
 boetger@google.com
-casbor@google.com
 derekjedral@google.com
 dlm@google.com
 igorzas@google.com
diff --git a/remoteauth/service/jni/Android.bp b/remoteauth/service/jni/Android.bp
index 57e3ec9..c7ad738 100644
--- a/remoteauth/service/jni/Android.bp
+++ b/remoteauth/service/jni/Android.bp
@@ -24,9 +24,6 @@
         "libasync_trait",
     ],
     prefer_rlib: true,
-    apex_available: [
-        "com.android.remoteauth",
-    ],
     host_supported: true,
 }
 
diff --git a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
index 9add6df..1d43d38 100644
--- a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
+++ b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
@@ -140,6 +140,7 @@
 }
 
 impl Platform for JavaPlatform {
+    #[allow(clippy::unit_arg)]
     fn send_request(
         &mut self,
         connection_id: i32,
diff --git a/remoteauth/service/jni/src/unique_jvm.rs b/remoteauth/service/jni/src/unique_jvm.rs
index 46cc361..ddbb16f 100644
--- a/remoteauth/service/jni/src/unique_jvm.rs
+++ b/remoteauth/service/jni/src/unique_jvm.rs
@@ -41,6 +41,7 @@
     Ok(())
 }
 /// Gets a 'static reference to the unique JavaVM. Returns None if set_once() was never called.
+#[allow(static_mut_refs)]
 pub(crate) fn get_static_ref() -> Option<&'static Arc<JavaVM>> {
     // Safety: follows [this pattern](https://doc.rust-lang.org/std/sync/struct.Once.html).
     // Modification to static mut is nested inside call_once.
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthConnectionCacheTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthConnectionCacheTest.java
index 00f35d3..2e39431 100644
--- a/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthConnectionCacheTest.java
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthConnectionCacheTest.java
@@ -19,7 +19,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertThrows;
-import static org.mockito.ArgumentMatchers.anyObject;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
@@ -98,7 +98,7 @@
     @Test
     public void testGetConnection_failedToConnect() {
         mConnectionCache.setConnectionInfo(mConnectionInfo);
-        doReturn(null).when(mConnectivityManager).connect(eq(mConnectionInfo), anyObject());
+        doReturn(null).when(mConnectivityManager).connect(eq(mConnectionInfo), any());
 
         assertNull(mConnectionCache.getConnection(CONNECTION_ID));
     }
@@ -108,7 +108,7 @@
         mConnectionCache.setConnectionInfo(mConnectionInfo);
         doThrow(ConnectionException.class)
                 .when(mConnectivityManager)
-                .connect(eq(mConnectionInfo), anyObject());
+                .connect(eq(mConnectionInfo), any());
 
         assertNull(mConnectionCache.getConnection(CONNECTION_ID));
     }
@@ -116,7 +116,7 @@
     @Test
     public void testGetConnection_connectionSucceed() {
         mConnectionCache.setConnectionInfo(mConnectionInfo);
-        doReturn(mConnection).when(mConnectivityManager).connect(eq(mConnectionInfo), anyObject());
+        doReturn(mConnection).when(mConnectivityManager).connect(eq(mConnectionInfo), any());
 
         assertEquals(mConnection, mConnectionCache.getConnection(CONNECTION_ID));
     }
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthPlatformTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthPlatformTest.java
index 8975d52..cce6b5e 100644
--- a/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthPlatformTest.java
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/RemoteAuthPlatformTest.java
@@ -19,7 +19,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyObject;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.times;
@@ -82,6 +82,6 @@
                             @Override
                             public void onFailure(int errorCode) {}
                         }));
-        verify(mConnection, times(1)).sendRequest(eq(REQUEST), anyObject());
+        verify(mConnection, times(1)).sendRequest(eq(REQUEST), any());
     }
 }
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingManagerTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingManagerTest.java
index 6e343bb..86e1a66 100644
--- a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingManagerTest.java
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/RangingManagerTest.java
@@ -36,7 +36,7 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
@@ -123,7 +123,7 @@
     @Test
     public void testConstruction() {
         mRangingManager = new RangingManager(mContext);
-        verifyZeroInteractions(mUwbManager);
+        verifyNoMoreInteractions(mUwbManager);
     }
 
     @Test
diff --git a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
index 91198ab..5b2e827 100644
--- a/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
+++ b/remoteauth/tests/unit/src/com/android/server/remoteauth/ranging/UwbRangingSessionTest.java
@@ -40,7 +40,7 @@
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
 import android.content.Context;
@@ -349,8 +349,8 @@
                 new UwbRangingSession(mContext, TEST_SESSION_PARAMETER_INITIATOR, mUwbServiceImpl);
         mUwbRangingSession.stop();
 
-        verifyZeroInteractions(mRangingController);
-        verifyZeroInteractions(mRangingCallback);
+        verifyNoMoreInteractions(mRangingController);
+        verifyNoMoreInteractions(mRangingCallback);
     }
 
     @Test
diff --git a/service-b/Android.bp b/service-b/Android.bp
new file mode 100644
index 0000000..47439ee
--- /dev/null
+++ b/service-b/Android.bp
@@ -0,0 +1,40 @@
+//
+// 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 {
+    default_team: "trendy_team_enigma",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// TODO: b/380331248 This lib is a non-jarjared version of "service-connectivity-b-platform"
+// It will only be included in the Tethering module when the build system flag
+// RELEASE_MOVE_VCN_TO_MAINLINE is enabled. Including "service-connectivity-b-platform"
+// in Tethering will break art branch check because that lib lives in framework/base.
+// Once VCN is moved to Connectivity/, "service-connectivity-b-platform" can be cleaned up.
+java_library {
+    name: "service-connectivity-b-pre-jarjar",
+    defaults: ["service-connectivity-b-pre-jarjar-defaults"],
+    libs: ["service-connectivity-pre-jarjar"],
+
+    sdk_version: "system_server_current",
+
+    // TODO(b/210962470): Bump this to B
+    min_sdk_version: "30",
+
+    apex_available: [
+        "com.android.tethering",
+    ],
+}
diff --git a/service-t/Android.bp b/service-t/Android.bp
index d2e2a80..81378f5 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -29,6 +29,7 @@
     name: "service-connectivity-tiramisu-sources",
     srcs: [
         "src/**/*.java",
+        ":vcn-location-sources",
     ],
     visibility: ["//visibility:private"],
 }
@@ -108,13 +109,15 @@
         ":service-mdns-droidstubs",
     ],
     exclude_srcs: [
+        "src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java",
         "src/com/android/server/connectivity/mdns/internal/SocketNetlinkMonitor.java",
-        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
         "src/com/android/server/connectivity/mdns/MdnsAdvertiser.java",
         "src/com/android/server/connectivity/mdns/MdnsAnnouncer.java",
         "src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java",
         "src/com/android/server/connectivity/mdns/MdnsProber.java",
         "src/com/android/server/connectivity/mdns/MdnsRecordRepository.java",
+        "src/com/android/server/connectivity/mdns/SchedulerFactory.java",
+        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
     ],
     static_libs: [
         "net-utils-device-common-mdns-standalone-build-test",
@@ -131,7 +134,10 @@
 
 droidstubs {
     name: "service-mdns-droidstubs",
-    srcs: ["src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java"],
+    srcs: [
+        "src/com/android/server/connectivity/mdns/SchedulerFactory.java",
+        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
+    ],
     libs: [
         "net-utils-device-common-mdns-standalone-build-test",
         "service-connectivity-tiramisu-pre-jarjar",
diff --git a/service-t/src/com/android/server/ConnectivityServiceInitializer.java b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
index 5d23fdc..5ef1aef 100644
--- a/service-t/src/com/android/server/ConnectivityServiceInitializer.java
+++ b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
@@ -30,6 +30,9 @@
 import com.android.server.nearby.NearbyService;
 import com.android.server.net.ct.CertificateTransparencyService;
 import com.android.server.thread.ThreadNetworkService;
+import com.android.server.vcn.VcnLocation;
+
+import java.lang.reflect.Constructor;
 
 /**
  * Connectivity service initializer for core networking. This is called by system server to create
@@ -37,6 +40,9 @@
  */
 public final class ConnectivityServiceInitializer extends SystemService {
     private static final String TAG = ConnectivityServiceInitializer.class.getSimpleName();
+    private static final String CONNECTIVITY_SERVICE_INITIALIZER_B_CLASS =
+            "com.android.server.ConnectivityServiceInitializerB";
+
     private final ConnectivityNativeService mConnectivityNative;
     private final ConnectivityService mConnectivity;
     private final IpSecService mIpSecService;
@@ -45,6 +51,7 @@
     private final EthernetServiceImpl mEthernetServiceImpl;
     private final ThreadNetworkService mThreadNetworkService;
     private final CertificateTransparencyService mCertificateTransparencyService;
+    private final SystemService mConnectivityServiceInitializerB;
 
     public ConnectivityServiceInitializer(Context context) {
         super(context);
@@ -58,6 +65,7 @@
         mNearbyService = createNearbyService(context);
         mThreadNetworkService = createThreadNetworkService(context);
         mCertificateTransparencyService = createCertificateTransparencyService(context);
+        mConnectivityServiceInitializerB = createConnectivityServiceInitializerB(context);
     }
 
     @Override
@@ -99,6 +107,11 @@
             publishBinderService(ThreadNetworkManager.SERVICE_NAME, mThreadNetworkService,
                     /* allowIsolated= */ false);
         }
+
+        if (mConnectivityServiceInitializerB != null) {
+            Log.i(TAG, "ConnectivityServiceInitializerB#onStart");
+            mConnectivityServiceInitializerB.onStart();
+        }
     }
 
     @Override
@@ -118,6 +131,10 @@
         if (SdkLevel.isAtLeastV() && mCertificateTransparencyService != null) {
             mCertificateTransparencyService.onBootPhase(phase);
         }
+
+        if (mConnectivityServiceInitializerB != null) {
+            mConnectivityServiceInitializerB.onBootPhase(phase);
+        }
     }
 
     /**
@@ -202,4 +219,28 @@
                 ? new CertificateTransparencyService(context)
                 : null;
     }
+
+    // TODO: b/374174952 After VCN code is moved to the Connectivity folder, merge
+    // ConnectivityServiceInitializerB into ConnectivityServiceInitializer and directly create and
+    // register VcnManagementService in ConnectivityServiceInitializer
+    /** Return ConnectivityServiceInitializerB instance if enable, otherwise null. */
+    @Nullable
+    private SystemService createConnectivityServiceInitializerB(Context context) {
+        if (!VcnLocation.IS_VCN_IN_MAINLINE || !SdkLevel.isAtLeastB()) {
+            return null;
+        }
+
+        try {
+            final Class<?> connectivityServiceInitializerBClass =
+                    Class.forName(CONNECTIVITY_SERVICE_INITIALIZER_B_CLASS);
+            final Constructor constructor =
+                    connectivityServiceInitializerBClass.getConstructor(Context.class);
+
+            return (SystemService) constructor.newInstance(context);
+        } catch (Exception e) {
+            Log.e(TAG, "Fail to load ConnectivityServiceInitializerB " + e);
+        }
+
+        return null;
+    }
 }
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 0adb290..4af8b0e 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -23,6 +23,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
+import static android.net.nsd.AdvertisingRequest.FLAG_SKIP_PROBING;
 import static android.net.nsd.NsdManager.MDNS_DISCOVERY_MANAGER_EVENT;
 import static android.net.nsd.NsdManager.MDNS_SERVICE_EVENT;
 import static android.net.nsd.NsdManager.RESOLVE_SERVICE_SUCCEEDED;
@@ -981,7 +982,7 @@
                                         NsdManager.FAILURE_INTERNAL_ERROR, false /* isLegacy */);
                                 break;
                             }
-                            boolean isUpdateOnly = (advertisingRequest.getAdvertisingConfig()
+                            boolean isUpdateOnly = (advertisingRequest.getFlags()
                                     & AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY) > 0;
                             // If it is an update request, then reuse the old transactionId
                             if (isUpdateOnly) {
@@ -1046,9 +1047,12 @@
 
                             serviceInfo.setSubtypes(subtypes);
                             maybeStartMonitoringSockets();
+                            final boolean skipProbing = (advertisingRequest.getFlags()
+                                    & FLAG_SKIP_PROBING) > 0;
                             final MdnsAdvertisingOptions mdnsAdvertisingOptions =
                                     MdnsAdvertisingOptions.newBuilder()
                                             .setIsOnlyUpdate(isUpdateOnly)
+                                            .setSkipProbing(skipProbing)
                                             .setTtl(advertisingRequest.getTtl())
                                             .build();
                             mAdvertiser.addOrUpdateService(transactionId, serviceInfo,
@@ -1938,11 +1942,13 @@
                         mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setAvoidAdvertisingEmptyTxtRecords(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS))
-                .setIsCachedServicesRemovalEnabled(mDeps.isFeatureEnabled(
+                .setIsCachedServicesRemovalEnabled(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_CACHED_SERVICES_REMOVAL))
                 .setCachedServicesRetentionTime(mDeps.getDeviceConfigPropertyInt(
                         MdnsFeatureFlags.NSD_CACHED_SERVICES_RETENTION_TIME,
                         MdnsFeatureFlags.DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS))
+                .setIsShortHostnamesEnabled(mDeps.isTetheringFeatureNotChickenedOut(
+                        mContext, MdnsFeatureFlags.NSD_USE_SHORT_HOSTNAMES))
                 .setOverrideProvider(new MdnsFeatureFlags.FlagOverrideProvider() {
                     @Override
                     public boolean isForceEnabledForTest(@NonNull String flag) {
diff --git a/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java b/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java
new file mode 100644
index 0000000..99354f8
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java
@@ -0,0 +1,169 @@
+/*
+ * 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.connectivity.mdns;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.GuardedBy;
+
+import com.android.net.module.util.HandlerUtils;
+
+import java.util.ArrayList;
+import java.util.concurrent.Executor;
+
+/**
+ * A utility class to generate a handler, optionally with a looper, and to run functions on the
+ * newly created handler.
+ */
+public class DiscoveryExecutor implements Executor {
+    private static final String TAG = DiscoveryExecutor.class.getSimpleName();
+    @Nullable
+    private final HandlerThread mHandlerThread;
+
+    @GuardedBy("mPendingTasks")
+    @Nullable
+    private Handler mHandler;
+    // Store pending tasks and associated delay time. Each Pair represents a pending task
+    // (first) and its delay time (second).
+    @GuardedBy("mPendingTasks")
+    @NonNull
+    private final ArrayList<Pair<Runnable, Long>> mPendingTasks = new ArrayList<>();
+
+    @GuardedBy("mPendingTasks")
+    @Nullable
+    Scheduler mScheduler;
+    @NonNull private final MdnsFeatureFlags mMdnsFeatureFlags;
+
+    DiscoveryExecutor(@Nullable Looper defaultLooper, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        mMdnsFeatureFlags = mdnsFeatureFlags;
+        if (defaultLooper != null) {
+            this.mHandlerThread = null;
+            synchronized (mPendingTasks) {
+                this.mHandler = new Handler(defaultLooper);
+            }
+        } else {
+            this.mHandlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName()) {
+                @Override
+                protected void onLooperPrepared() {
+                    synchronized (mPendingTasks) {
+                        mHandler = new Handler(getLooper());
+                        for (Pair<Runnable, Long> pendingTask : mPendingTasks) {
+                            executeDelayed(pendingTask.first, pendingTask.second);
+                        }
+                        mPendingTasks.clear();
+                    }
+                }
+            };
+            this.mHandlerThread.start();
+        }
+    }
+
+    /**
+     * Check if the current thread is the expected thread. If it is, run the given function.
+     * Otherwise, execute it using the handler.
+     */
+    public void checkAndRunOnHandlerThread(@NonNull Runnable function) {
+        if (this.mHandlerThread == null) {
+            // Callers are expected to already be running on the handler when a defaultLooper
+            // was provided
+            function.run();
+        } else {
+            execute(function);
+        }
+    }
+
+    /** Execute the given function */
+    @Override
+    public void execute(Runnable function) {
+        executeDelayed(function, 0L /* delayMillis */);
+    }
+
+    /** Execute the given function after the specified amount of time elapses. */
+    public void executeDelayed(Runnable function, long delayMillis) {
+        final Handler handler;
+        final Scheduler scheduler;
+        synchronized (mPendingTasks) {
+            if (this.mHandler == null) {
+                mPendingTasks.add(Pair.create(function, delayMillis));
+                return;
+            } else {
+                handler = this.mHandler;
+                if (mMdnsFeatureFlags.mIsAccurateDelayCallbackEnabled
+                        && this.mScheduler == null) {
+                    this.mScheduler = SchedulerFactory.createScheduler(mHandler);
+                }
+                scheduler = this.mScheduler;
+            }
+        }
+        if (scheduler != null) {
+            if (delayMillis == 0L) {
+                handler.post(function);
+                return;
+            }
+            if (HandlerUtils.isRunningOnHandlerThread(handler)) {
+                scheduler.postDelayed(function, delayMillis);
+            } else {
+                handler.post(() -> scheduler.postDelayed(function, delayMillis));
+            }
+        } else {
+            handler.postDelayed(function, delayMillis);
+        }
+    }
+
+    /** Shutdown the thread if necessary. */
+    public void shutDown() {
+        if (this.mHandlerThread != null) {
+            this.mHandlerThread.quitSafely();
+        }
+        synchronized (mPendingTasks) {
+            if (mScheduler != null) {
+                mScheduler.close();
+            }
+        }
+    }
+
+    /**
+     * Ensures that the current running thread is the same as the handler thread.
+     */
+    public void ensureRunningOnHandlerThread() {
+        synchronized (mPendingTasks) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        }
+    }
+
+    /**
+     * Runs the specified task synchronously for dump method.
+     */
+    public void runWithScissorsForDumpIfReady(@NonNull Runnable function) {
+        final Handler handler;
+        synchronized (mPendingTasks) {
+            if (this.mHandler == null) {
+                Log.d(TAG, "The handler is not ready. Ignore the DiscoveryManager dump");
+                return;
+            } else {
+                handler = this.mHandler;
+            }
+        }
+        HandlerUtils.runWithScissorsForDump(handler, function, 10_000);
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java b/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
index f55db93..81ba530 100644
--- a/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
+++ b/service-t/src/com/android/server/connectivity/mdns/EnqueueMdnsQueryCallable.java
@@ -139,11 +139,8 @@
                 // Base service type
                 questions.add(new MdnsPointerRecord(serviceTypeLabels, expectUnicastResponse));
                 for (String subtype : subtypes) {
-                    final String[] labels = new String[serviceTypeLabels.length + 2];
-                    labels[0] = MdnsConstants.SUBTYPE_PREFIX + subtype;
-                    labels[1] = MdnsConstants.SUBTYPE_LABEL;
-                    System.arraycopy(serviceTypeLabels, 0, labels, 2, serviceTypeLabels.length);
-
+                    final String[] labels = MdnsUtils.constructFullSubtype(serviceTypeLabels,
+                            MdnsConstants.SUBTYPE_PREFIX + subtype);
                     questions.add(new MdnsPointerRecord(labels, expectUnicastResponse));
                 }
             }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
index 9c52eca..bd00b70 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -35,6 +35,7 @@
 import android.os.Looper;
 import android.text.TextUtils;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 import android.util.Log;
 import android.util.SparseArray;
 
@@ -46,6 +47,7 @@
 import com.android.server.connectivity.ConnectivityResources;
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
+import java.security.SecureRandom;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -92,6 +94,7 @@
             new ArrayMap<>();
     private final MdnsFeatureFlags mMdnsFeatureFlags;
     private final Map<String, Integer> mServiceTypeToOffloadPriority;
+    private final ArraySet<String> mOffloadServiceTypeDenyList;
 
     /**
      * Dependencies for {@link MdnsAdvertiser}, useful for testing.
@@ -117,7 +120,7 @@
          * Generates a unique hostname to be used by the device.
          */
         @NonNull
-        public String[] generateHostname() {
+        public String[] generateHostname(boolean useShortFormat) {
             // Generate a very-probably-unique hostname. This allows minimizing possible conflicts
             // to the point that probing for it is no longer necessary (as per RFC6762 8.1 last
             // paragraph), and does not leak more information than what could already be obtained by
@@ -127,10 +130,24 @@
             // Having a different hostname per interface is an acceptable option as per RFC6762 14.
             // This hostname will change every time the interface is reconnected, so this does not
             // allow tracking the device.
-            // TODO: consider deriving a hostname from other sources, such as the IPv6 addresses
-            // (reusing the same privacy-protecting mechanics).
-            return new String[] {
-                    "Android_" + UUID.randomUUID().toString().replace("-", ""), LOCAL_TLD };
+            if (useShortFormat) {
+                // A short hostname helps reduce the size of APF mDNS filtering programs, and
+                // is necessary for compatibility with some Matter 1.0 devices which assumed
+                // 16 characters is the maximum length.
+                // Generate a hostname matching Android_[0-9A-Z]{8}, which has 36^8 possibilities.
+                // Even with 100 devices advertising the probability of collision is around 2E-9,
+                // which is negligible.
+                final SecureRandom sr = new SecureRandom();
+                final String allowedChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+                final StringBuilder sb = new StringBuilder(8);
+                for (int i = 0; i < 8; i++) {
+                    sb.append(allowedChars.charAt(sr.nextInt(allowedChars.length())));
+                }
+                return new String[]{ "Android_" + sb.toString(), LOCAL_TLD };
+            } else {
+                return new String[]{
+                        "Android_" + UUID.randomUUID().toString().replace("-", ""), LOCAL_TLD};
+            }
         }
     }
 
@@ -145,6 +162,16 @@
         return mInterfaceOffloadServices.getOrDefault(interfaceName, Collections.emptyList());
     }
 
+    private boolean isInOffloadDenyList(@NonNull String serviceType) {
+        for (int i = 0; i < mOffloadServiceTypeDenyList.size(); ++i) {
+            final String denyListServiceType = mOffloadServiceTypeDenyList.valueAt(i);
+            if (DnsUtils.equalsIgnoreDnsCase(serviceType, denyListServiceType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private final MdnsInterfaceAdvertiser.Callback mInterfaceAdvertiserCb =
             new MdnsInterfaceAdvertiser.Callback() {
         @Override
@@ -158,19 +185,25 @@
             if (mMdnsFeatureFlags.mIsMdnsOffloadFeatureEnabled
                     // TODO: Enable offload when the serviceInfo contains a custom host.
                     && TextUtils.isEmpty(registration.getServiceInfo().getHostname())) {
-                final String interfaceName = advertiser.getSocketInterfaceName();
-                final List<OffloadServiceInfoWrapper> existingOffloadServiceInfoWrappers =
-                        mInterfaceOffloadServices.computeIfAbsent(interfaceName,
-                                k -> new ArrayList<>());
-                // Remove existing offload services from cache for update.
-                existingOffloadServiceInfoWrappers.removeIf(item -> item.mServiceId == serviceId);
+                final String serviceType = registration.getServiceInfo().getServiceType();
+                if (isInOffloadDenyList(serviceType)) {
+                    mSharedLog.i("Offload denied for service type: " + serviceType);
+                } else {
+                    final String interfaceName = advertiser.getSocketInterfaceName();
+                    final List<OffloadServiceInfoWrapper> existingOffloadServiceInfoWrappers =
+                            mInterfaceOffloadServices.computeIfAbsent(interfaceName,
+                                    k -> new ArrayList<>());
+                    // Remove existing offload services from cache for update.
+                    existingOffloadServiceInfoWrappers.removeIf(
+                            item -> item.mServiceId == serviceId);
 
-                byte[] rawOffloadPacket = advertiser.getRawOffloadPayload(serviceId);
-                final OffloadServiceInfoWrapper newOffloadServiceInfoWrapper = createOffloadService(
-                        serviceId, registration, rawOffloadPacket);
-                existingOffloadServiceInfoWrappers.add(newOffloadServiceInfoWrapper);
-                mCb.onOffloadStartOrUpdate(interfaceName,
-                        newOffloadServiceInfoWrapper.mOffloadServiceInfo);
+                    byte[] rawOffloadPacket = advertiser.getRawOffloadPayload(serviceId);
+                    final OffloadServiceInfoWrapper newOffloadServiceInfoWrapper =
+                            createOffloadService(serviceId, registration, rawOffloadPacket);
+                    existingOffloadServiceInfoWrappers.add(newOffloadServiceInfoWrapper);
+                    mCb.onOffloadStartOrUpdate(interfaceName,
+                            newOffloadServiceInfoWrapper.mOffloadServiceInfo);
+                }
             }
 
             // Wait for all current interfaces to be done probing before notifying of success.
@@ -825,12 +858,14 @@
         mCb = cb;
         mSocketProvider = socketProvider;
         mDeps = deps;
-        mDeviceHostName = deps.generateHostname();
+        mDeviceHostName = deps.generateHostname(mDnsFeatureFlags.isShortHostnamesEnabled());
         mSharedLog = sharedLog;
         mMdnsFeatureFlags = mDnsFeatureFlags;
         final ConnectivityResources res = new ConnectivityResources(context);
         mServiceTypeToOffloadPriority = parseOffloadPriorityList(
                 res.get().getStringArray(R.array.config_nsdOffloadServicesPriority), sharedLog);
+        mOffloadServiceTypeDenyList = new ArraySet<>(
+                res.get().getStringArray(R.array.config_nsdOffloadServicesDenyList));
     }
 
     private static Map<String, Integer> parseOffloadPriorityList(
@@ -943,7 +978,7 @@
         mRegistrations.remove(id);
         // Regenerates host name when registrations removed.
         if (mRegistrations.size() == 0) {
-            mDeviceHostName = mDeps.generateHostname();
+            mDeviceHostName = mDeps.generateHostname(mMdnsFeatureFlags.isShortHostnamesEnabled());
         }
     }
 
@@ -992,6 +1027,19 @@
         });
     }
 
+    private List<String> getOffloadSubtype(@NonNull NsdServiceInfo nsdServiceInfo) {
+        // Workaround: Google Cast doesn't announce subtypes per DNS-SD/mDNS spec.
+        // Thus, subtypes aren't offloaded; only "_googlecast._tcp" is.
+        // Subtype responses occur when hardware offload is off.
+        // This solution works because Google Cast doesn't follow the intended usage of subtypes in
+        // the spec, as it always discovers for both the subtype+base type, and only uses the mDNS
+        // subtype as an optimization.
+        if (nsdServiceInfo.getServiceType().equals("_googlecast._tcp")) {
+            return new ArrayList<>();
+        }
+        return new ArrayList<>(nsdServiceInfo.getSubtypes());
+    }
+
     private OffloadServiceInfoWrapper createOffloadService(int serviceId,
             @NonNull Registration registration, byte[] rawOffloadPacket) {
         final NsdServiceInfo nsdServiceInfo = registration.getServiceInfo();
@@ -1002,7 +1050,7 @@
         final OffloadServiceInfo offloadServiceInfo = new OffloadServiceInfo(
                 new OffloadServiceInfo.Key(nsdServiceInfo.getServiceName(),
                         nsdServiceInfo.getServiceType()),
-                new ArrayList<>(nsdServiceInfo.getSubtypes()),
+                getOffloadSubtype(nsdServiceInfo),
                 String.join(".", mDeviceHostName),
                 rawOffloadPacket,
                 priority,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
index a81d1e4..5133d4f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertisingOptions.java
@@ -34,13 +34,15 @@
     private final boolean mIsOnlyUpdate;
     @Nullable
     private final Duration mTtl;
+    private final boolean mSkipProbing;
 
     /**
      * Parcelable constructs for a {@link MdnsAdvertisingOptions}.
      */
-    MdnsAdvertisingOptions(boolean isOnlyUpdate, @Nullable Duration ttl) {
+    MdnsAdvertisingOptions(boolean isOnlyUpdate, @Nullable Duration ttl, boolean skipProbing) {
         this.mIsOnlyUpdate = isOnlyUpdate;
         this.mTtl = ttl;
+        this.mSkipProbing = skipProbing;
     }
 
     /**
@@ -68,6 +70,13 @@
     }
 
     /**
+     * @return {@code true} if the probing step should be skipped.
+     */
+    public boolean skipProbing() {
+        return mSkipProbing;
+    }
+
+    /**
      * Returns the TTL for all records in a service.
      */
     @Nullable
@@ -104,6 +113,7 @@
      */
     public static final class Builder {
         private boolean mIsOnlyUpdate = false;
+        private boolean mSkipProbing = false;
         @Nullable
         private Duration mTtl;
 
@@ -127,10 +137,18 @@
         }
 
         /**
+         * Sets whether to skip the probing step.
+         */
+        public Builder setSkipProbing(boolean skipProbing) {
+            this.mSkipProbing = skipProbing;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsAdvertisingOptions} with the arguments supplied to this builder.
          */
         public MdnsAdvertisingOptions build() {
-            return new MdnsAdvertisingOptions(mIsOnlyUpdate, mTtl);
+            return new MdnsAdvertisingOptions(mIsOnlyUpdate, mTtl, mSkipProbing);
         }
     }
 }
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 c833422..8cd3662 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -16,24 +16,17 @@
 
 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;
 import android.annotation.RequiresPermission;
-import android.os.Handler;
-import android.os.HandlerThread;
 import android.os.Looper;
 import android.util.ArrayMap;
 import android.util.Log;
 import android.util.Pair;
 
-import androidx.annotation.GuardedBy;
-
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.DnsUtils;
-import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.SharedLog;
 
 import java.io.IOException;
@@ -41,7 +34,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.Executor;
 
 /**
  * This class keeps tracking the set of registered {@link MdnsServiceBrowserListener} instances, and
@@ -133,99 +125,7 @@
         this.sharedLog = sharedLog;
         this.perSocketServiceTypeClients = new PerSocketServiceTypeClients();
         this.mdnsFeatureFlags = mdnsFeatureFlags;
-        this.discoveryExecutor = new DiscoveryExecutor(socketClient.getLooper());
-    }
-
-    /**
-     * 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<Pair<Runnable, Long>> pendingTasks = new ArrayList<>();
-
-        DiscoveryExecutor(@Nullable Looper defaultLooper) {
-            if (defaultLooper != null) {
-                this.handlerThread = null;
-                synchronized (pendingTasks) {
-                    this.handler = new Handler(defaultLooper);
-                }
-            } else {
-                this.handlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName()) {
-                    @Override
-                    protected void onLooperPrepared() {
-                        synchronized (pendingTasks) {
-                            handler = new Handler(getLooper());
-                            for (Pair<Runnable, Long> pendingTask : pendingTasks) {
-                                handler.postDelayed(pendingTask.first, pendingTask.second);
-                            }
-                            pendingTasks.clear();
-                        }
-                    }
-                };
-                this.handlerThread.start();
-            }
-        }
-
-        public void checkAndRunOnHandlerThread(@NonNull Runnable function) {
-            if (this.handlerThread == null) {
-                // Callers are expected to already be running on the handler when a defaultLooper
-                // was provided
-                function.run();
-            } else {
-                execute(function);
-            }
-        }
-
-        @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(Pair.create(function, delayMillis));
-                    return;
-                } else {
-                    handler = this.handler;
-                }
-            }
-            handler.postDelayed(function, delayMillis);
-        }
-
-        void shutDown() {
-            if (this.handlerThread != null) {
-                this.handlerThread.quitSafely();
-            }
-        }
-
-        void ensureRunningOnHandlerThread() {
-            synchronized (pendingTasks) {
-                HandlerUtils.ensureRunningOnHandlerThread(handler);
-            }
-        }
-
-        public void runWithScissorsForDumpIfReady(@NonNull Runnable function) {
-            final Handler handler;
-            synchronized (pendingTasks) {
-                if (this.handler == null) {
-                    Log.d(TAG, "The handler is not ready. Ignore the DiscoveryManager dump");
-                    return;
-                } else {
-                    handler = this.handler;
-                }
-            }
-            HandlerUtils.runWithScissorsForDump(handler, function, 10_000);
-        }
+        this.discoveryExecutor = new DiscoveryExecutor(socketClient.getLooper(), mdnsFeatureFlags);
     }
 
     /**
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 4e27fef..11a374d 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -81,6 +81,12 @@
     public static final String NSD_CACHED_SERVICES_REMOVAL = "nsd_cached_services_removal";
 
     /**
+     * A feature flag to control whether to use shorter (16 characters + .local) hostnames, instead
+     * of Android_[32 characters] hostnames.
+     */
+    public static final String NSD_USE_SHORT_HOSTNAMES = "nsd_use_short_hostnames";
+
+    /**
      * A feature flag to control the retention time for cached services.
      *
      * <p> Making the retention time configurable allows for testing and future adjustments.
@@ -89,6 +95,11 @@
             "nsd_cached_services_retention_time";
     public static final int DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS = 10000;
 
+    /**
+     * A feature flag to control whether the accurate delay callback should be enabled.
+     */
+    public static final String NSD_ACCURATE_DELAY_CALLBACK = "nsd_accurate_delay_callback";
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
@@ -122,6 +133,12 @@
     // Retention Time for cached services
     public final long mCachedServicesRetentionTime;
 
+    // Flag for accurate delay callback
+    public final boolean mIsAccurateDelayCallbackEnabled;
+
+    // Flag to use shorter (16 characters + .local) hostnames
+    public final boolean mIsShortHostnamesEnabled;
+
     @Nullable
     private final FlagOverrideProvider mOverrideProvider;
 
@@ -217,6 +234,18 @@
                 NSD_CACHED_SERVICES_RETENTION_TIME, (int) mCachedServicesRetentionTime);
     }
 
+    public boolean isShortHostnamesEnabled() {
+        return mIsShortHostnamesEnabled || isForceEnabledForTest(NSD_USE_SHORT_HOSTNAMES);
+    }
+
+    /**
+     * Indicates whether {@link #NSD_ACCURATE_DELAY_CALLBACK} is enabled, including for testing.
+     */
+    public boolean isAccurateDelayCallbackEnabled() {
+        return mIsAccurateDelayCallbackEnabled
+                || isForceEnabledForTest(NSD_ACCURATE_DELAY_CALLBACK);
+    }
+
     /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
@@ -231,6 +260,8 @@
             boolean avoidAdvertisingEmptyTxtRecords,
             boolean isCachedServicesRemovalEnabled,
             long cachedServicesRetentionTime,
+            boolean isAccurateDelayCallbackEnabled,
+            boolean isShortHostnamesEnabled,
             @Nullable FlagOverrideProvider overrideProvider) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
         mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
@@ -243,6 +274,8 @@
         mAvoidAdvertisingEmptyTxtRecords = avoidAdvertisingEmptyTxtRecords;
         mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
         mCachedServicesRetentionTime = cachedServicesRetentionTime;
+        mIsAccurateDelayCallbackEnabled = isAccurateDelayCallbackEnabled;
+        mIsShortHostnamesEnabled = isShortHostnamesEnabled;
         mOverrideProvider = overrideProvider;
     }
 
@@ -266,6 +299,8 @@
         private boolean mAvoidAdvertisingEmptyTxtRecords;
         private boolean mIsCachedServicesRemovalEnabled;
         private long mCachedServicesRetentionTime;
+        private boolean mIsAccurateDelayCallbackEnabled;
+        private boolean mIsShortHostnamesEnabled;
         private FlagOverrideProvider mOverrideProvider;
 
         /**
@@ -281,8 +316,10 @@
             mIsAggressiveQueryModeEnabled = false;
             mIsQueryWithKnownAnswerEnabled = false;
             mAvoidAdvertisingEmptyTxtRecords = true; // Default enabled.
-            mIsCachedServicesRemovalEnabled = false;
+            mIsCachedServicesRemovalEnabled = true; // Default enabled.
             mCachedServicesRetentionTime = DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS;
+            mIsAccurateDelayCallbackEnabled = false;
+            mIsShortHostnamesEnabled = true; // Default enabled.
             mOverrideProvider = null;
         }
 
@@ -409,6 +446,26 @@
         }
 
         /**
+         * Set whether the accurate delay callback is enabled.
+         *
+         * @see #NSD_ACCURATE_DELAY_CALLBACK
+         */
+        public Builder setIsAccurateDelayCallbackEnabled(boolean isAccurateDelayCallbackEnabled) {
+            mIsAccurateDelayCallbackEnabled = isAccurateDelayCallbackEnabled;
+            return this;
+        }
+
+        /**
+         * Set whether the short hostnames feature is enabled.
+         *
+         * @see #NSD_USE_SHORT_HOSTNAMES
+         */
+        public Builder setIsShortHostnamesEnabled(boolean isShortHostnamesEnabled) {
+            mIsShortHostnamesEnabled = isShortHostnamesEnabled;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsFeatureFlags} with the arguments supplied to this builder.
          */
         public MdnsFeatureFlags build() {
@@ -423,6 +480,8 @@
                     mAvoidAdvertisingEmptyTxtRecords,
                     mIsCachedServicesRemovalEnabled,
                     mCachedServicesRetentionTime,
+                    mIsAccurateDelayCallbackEnabled,
+                    mIsShortHostnamesEnabled,
                     mOverrideProvider);
         }
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
index 58defa9..b9b09ed 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -122,28 +122,32 @@
         }
         @Override
         public void onFinished(MdnsProber.ProbingInfo info) {
-            final MdnsAnnouncer.AnnouncementInfo announcementInfo;
-            mSharedLog.i("Probing finished for service " + info.getServiceId());
-            mCbHandler.post(() -> mCb.onServiceProbingSucceeded(
-                    MdnsInterfaceAdvertiser.this, info.getServiceId()));
-            try {
-                announcementInfo = mRecordRepository.onProbingSucceeded(info);
-            } catch (IOException e) {
-                mSharedLog.e("Error building announcements", e);
-                return;
-            }
+            handleProbingFinished(info);
+        }
+    }
 
-            mAnnouncer.startSending(info.getServiceId(), announcementInfo,
-                    0L /* initialDelayMs */);
+    private void handleProbingFinished(MdnsProber.ProbingInfo info) {
+        final MdnsAnnouncer.AnnouncementInfo announcementInfo;
+        mSharedLog.i("Probing finished for service " + info.getServiceId());
+        mCbHandler.post(() -> mCb.onServiceProbingSucceeded(
+                MdnsInterfaceAdvertiser.this, info.getServiceId()));
+        try {
+            announcementInfo = mRecordRepository.onProbingSucceeded(info);
+        } catch (IOException e) {
+            mSharedLog.e("Error building announcements", e);
+            return;
+        }
 
-            // Re-announce the services which have the same custom hostname.
-            final String hostname = mRecordRepository.getHostnameForServiceId(info.getServiceId());
-            if (hostname != null) {
-                final List<MdnsAnnouncer.AnnouncementInfo> announcementInfos =
-                        new ArrayList<>(mRecordRepository.restartAnnouncingForHostname(hostname));
-                announcementInfos.removeIf((i) -> i.getServiceId() == info.getServiceId());
-                reannounceServices(announcementInfos);
-            }
+        mAnnouncer.startSending(info.getServiceId(), announcementInfo,
+                0L /* initialDelayMs */);
+
+        // Re-announce the services which have the same custom hostname.
+        final String hostname = mRecordRepository.getHostnameForServiceId(info.getServiceId());
+        if (hostname != null) {
+            final List<MdnsAnnouncer.AnnouncementInfo> announcementInfos =
+                    new ArrayList<>(mRecordRepository.restartAnnouncingForHostname(hostname));
+            announcementInfos.removeIf((i) -> i.getServiceId() == info.getServiceId());
+            reannounceServices(announcementInfos);
         }
     }
 
@@ -280,7 +284,12 @@
                     + " getting re-added, cancelling exit announcements");
             mAnnouncer.stop(replacedExitingService);
         }
-        mProber.startProbing(mRecordRepository.setServiceProbing(id));
+        final MdnsProber.ProbingInfo probingInfo = mRecordRepository.setServiceProbing(id);
+        if (advertisingOptions.skipProbing()) {
+            handleProbingFinished(probingInfo);
+        } else {
+            mProber.startProbing(probingInfo);
+        }
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
index 36fad31..4c760d6 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
@@ -101,7 +101,7 @@
                 @NonNull MdnsInterfaceSocket socket) {
             mActiveSockets.remove(socketKey);
             mSocketCreationCallback.onSocketDestroyed(socketKey);
-            maybeCleanupPacketHandler(socketKey);
+            maybeCleanupPacketHandler(socketKey, socket);
         }
 
         private void notifySocketDestroyed(@NonNull SocketKey socketKey) {
@@ -115,8 +115,9 @@
             for (int i = mActiveSockets.size() - 1; i >= 0; i--) {
                 // Iterate from the end so the socket can be removed
                 final SocketKey socketKey = mActiveSockets.keyAt(i);
+                final MdnsInterfaceSocket socket = mActiveSockets.valueAt(i);
                 notifySocketDestroyed(socketKey);
-                maybeCleanupPacketHandler(socketKey);
+                maybeCleanupPacketHandler(socketKey, socket);
             }
         }
     }
@@ -143,9 +144,13 @@
         return null;
     }
 
-    private void maybeCleanupPacketHandler(@NonNull SocketKey socketKey) {
+    private void maybeCleanupPacketHandler(@NonNull SocketKey socketKey,
+            @NonNull MdnsInterfaceSocket socket) {
         if (isSocketActive(socketKey)) return;
-        mSocketPacketHandlers.remove(socketKey);
+        final ReadPacketHandler handler = mSocketPacketHandlers.remove(socketKey);
+        if (handler != null) {
+            socket.removePacketHandler(handler);
+        }
     }
 
     private class ReadPacketHandler implements MulticastPacketReader.PacketHandler {
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
index 356b738..7495aec 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
@@ -16,9 +16,14 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
+import static com.android.server.connectivity.mdns.MdnsSearchOptions.PASSIVE_QUERY_MODE;
+
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 
+import com.android.internal.annotations.VisibleForTesting;
+
 /**
  * The query scheduler class for calculating next query tasks parameters.
  * <p>
@@ -26,6 +31,25 @@
  */
 public class MdnsQueryScheduler {
 
+    @VisibleForTesting
+    // RFC 6762 5.2: The interval between the first two queries MUST be at least one second.
+    static final int INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS = 1000;
+    private static final int INITIAL_TIME_BETWEEN_BURSTS_MS =
+            (int) MdnsConfigs.initialTimeBetweenBurstsMs();
+    private static final int MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS =
+            (int) MdnsConfigs.timeBetweenBurstsMs();
+    private static final int QUERIES_PER_BURST = (int) MdnsConfigs.queriesPerBurst();
+    private static final int TIME_BETWEEN_QUERIES_IN_BURST_MS =
+            (int) MdnsConfigs.timeBetweenQueriesInBurstMs();
+    private static final int QUERIES_PER_BURST_PASSIVE_MODE =
+            (int) MdnsConfigs.queriesPerBurstPassive();
+    @VisibleForTesting
+    // Basically this tries to send one query per typical DTIM interval 100ms, to maximize the
+    // chances that a query will be received if devices are using a DTIM multiplier (in which case
+    // they only listen once every [multiplier] DTIM intervals).
+    static final int TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS = 100;
+    static final int MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS = 60000;
+
     /**
      * The argument for tracking the query tasks status.
      */
@@ -72,19 +96,21 @@
         if (mLastScheduledQueryTaskArgs == null) {
             return null;
         }
-        if (!mLastScheduledQueryTaskArgs.config.shouldUseQueryBackoff(numOfQueriesBeforeBackoff)) {
+        final QueryTaskConfig lastConfig = mLastScheduledQueryTaskArgs.config;
+        if (!shouldUseQueryBackoff(lastConfig.queryIndex, lastConfig.queryMode,
+                numOfQueriesBeforeBackoff)) {
             return null;
         }
 
         final long timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs,
-                mLastScheduledQueryTaskArgs.config, now, minRemainingTtl, lastSentTime,
+                lastConfig.queryIndex, lastConfig.queryMode, now, minRemainingTtl, lastSentTime,
                 numOfQueriesBeforeBackoff, false /* forceEnableBackoff */);
 
         if (timeToRun <= mLastScheduledQueryTaskArgs.timeToRun) {
             return null;
         }
 
-        mLastScheduledQueryTaskArgs = new ScheduledQueryTaskArgs(mLastScheduledQueryTaskArgs.config,
+        mLastScheduledQueryTaskArgs = new ScheduledQueryTaskArgs(lastConfig,
                 timeToRun,
                 minRemainingTtl + now,
                 sessionId);
@@ -104,17 +130,19 @@
             int queryMode,
             int numOfQueriesBeforeBackoff,
             boolean forceEnableBackoff) {
-        final QueryTaskConfig nextRunConfig = currentConfig.getConfigForNextRun(queryMode);
+        final int newQueryIndex = currentConfig.getConfigForNextRun(queryMode).queryIndex;
         long timeToRun;
         if (mLastScheduledQueryTaskArgs == null && !forceEnableBackoff) {
-            timeToRun = now + nextRunConfig.getDelayBeforeTaskWithoutBackoff();
+            timeToRun = now + getDelayBeforeTaskWithoutBackoff(
+                    newQueryIndex, queryMode);
         } else {
-            timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs,
-                    nextRunConfig, now, minRemainingTtl, lastSentTime, numOfQueriesBeforeBackoff,
-                    forceEnableBackoff);
+            timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs, newQueryIndex,
+                    queryMode, now, minRemainingTtl, lastSentTime,
+                    numOfQueriesBeforeBackoff, forceEnableBackoff);
         }
-        mLastScheduledQueryTaskArgs = new ScheduledQueryTaskArgs(nextRunConfig, timeToRun,
-                minRemainingTtl + now,
+        mLastScheduledQueryTaskArgs = new ScheduledQueryTaskArgs(
+                currentConfig.getConfigForNextRun(queryMode),
+                timeToRun, minRemainingTtl + now,
                 sessionId);
         return mLastScheduledQueryTaskArgs;
     }
@@ -131,11 +159,11 @@
     }
 
     private static long calculateTimeToRun(@Nullable ScheduledQueryTaskArgs taskArgs,
-            QueryTaskConfig queryTaskConfig, long now, long minRemainingTtl, long lastSentTime,
+            int queryIndex, int queryMode, long now, long minRemainingTtl, long lastSentTime,
             int numOfQueriesBeforeBackoff, boolean forceEnableBackoff) {
-        final long baseDelayInMs = queryTaskConfig.getDelayBeforeTaskWithoutBackoff();
+        final long baseDelayInMs = getDelayBeforeTaskWithoutBackoff(queryIndex, queryMode);
         if (!(forceEnableBackoff
-                || queryTaskConfig.shouldUseQueryBackoff(numOfQueriesBeforeBackoff))) {
+                || shouldUseQueryBackoff(queryIndex, queryMode, numOfQueriesBeforeBackoff))) {
             return lastSentTime + baseDelayInMs;
         }
         if (minRemainingTtl <= 0) {
@@ -152,4 +180,93 @@
         }
         return Math.max(now + (long) (0.8 * minRemainingTtl), lastSentTime + baseDelayInMs);
     }
+
+    private static int getBurstIndex(int queryIndex, int queryMode) {
+        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
+            // In passive mode, after the first burst of QUERIES_PER_BURST queries, subsequent
+            // bursts have QUERIES_PER_BURST_PASSIVE_MODE queries.
+            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
+            return 1 + (queryIndexAfterFirstBurst / QUERIES_PER_BURST_PASSIVE_MODE);
+        } else {
+            return queryIndex / QUERIES_PER_BURST;
+        }
+    }
+
+    private static int getQueryIndexInBurst(int queryIndex, int queryMode) {
+        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
+            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
+            return queryIndexAfterFirstBurst % QUERIES_PER_BURST_PASSIVE_MODE;
+        } else {
+            return queryIndex % QUERIES_PER_BURST;
+        }
+    }
+
+    private static boolean isFirstBurst(int queryIndex, int queryMode) {
+        return getBurstIndex(queryIndex, queryMode) == 0;
+    }
+
+    static boolean isFirstQueryInBurst(int queryIndex, int queryMode) {
+        return getQueryIndexInBurst(queryIndex, queryMode) == 0;
+    }
+
+    private static long getDelayBeforeTaskWithoutBackoff(int queryIndex, int queryMode) {
+        final int burstIndex = getBurstIndex(queryIndex, queryMode);
+        final int queryIndexInBurst = getQueryIndexInBurst(queryIndex, queryMode);
+        if (queryIndexInBurst == 0) {
+            return getTimeToBurstMs(burstIndex, queryMode);
+        } else if (queryIndexInBurst == 1 && queryMode == AGGRESSIVE_QUERY_MODE) {
+            // In aggressive mode, the first 2 queries are sent without delay.
+            return 0;
+        }
+        return queryMode == AGGRESSIVE_QUERY_MODE
+                ? TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS
+                : TIME_BETWEEN_QUERIES_IN_BURST_MS;
+    }
+
+    /**
+     * Shifts a value left by the specified number of bits, coercing to at most maxValue.
+     *
+     * <p>This allows calculating min(value*2^shift, maxValue) without overflow.
+     */
+    private static int boundedLeftShift(int value, int shift, int maxValue) {
+        // There must be at least one leading zero for positive values, so the maximum left shift
+        // without overflow is the number of leading zeros minus one.
+        final int maxShift = Integer.numberOfLeadingZeros(value) - 1;
+        if (shift > maxShift) {
+            // The shift would overflow positive integers, so is greater than maxValue.
+            return maxValue;
+        }
+        return Math.min(value << shift, maxValue);
+    }
+
+    private static int getTimeToBurstMs(int burstIndex, int queryMode) {
+        if (burstIndex == 0) {
+            // No delay before the first burst
+            return 0;
+        }
+        switch (queryMode) {
+            case PASSIVE_QUERY_MODE:
+                return MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
+            case AGGRESSIVE_QUERY_MODE:
+                return boundedLeftShift(INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS,
+                        burstIndex - 1,
+                        MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS);
+            default: // ACTIVE_QUERY_MODE
+                return boundedLeftShift(INITIAL_TIME_BETWEEN_BURSTS_MS,
+                        burstIndex - 1,
+                        MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS);
+        }
+    }
+
+    /**
+     * Determine if the query backoff should be used.
+     */
+    public static boolean shouldUseQueryBackoff(int queryIndex, int queryMode,
+            int numOfQueriesBeforeBackoff) {
+        // Don't enable backoff mode during the burst or in the first burst
+        if (!isFirstQueryInBurst(queryIndex, queryMode) || isFirstBurst(queryIndex, queryMode)) {
+            return false;
+        }
+        return queryIndex > numOfQueriesBeforeBackoff;
+    }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
index c3cb776..bfef5d9 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -1482,22 +1482,14 @@
 
     private static String[] splitFullyQualifiedName(
             @NonNull NsdServiceInfo info, @NonNull String[] serviceType) {
-        final String[] split = new String[serviceType.length + 1];
-        split[0] = info.getServiceName();
-        System.arraycopy(serviceType, 0, split, 1, serviceType.length);
-
-        return split;
+        return CollectionUtils.prependArray(String.class, serviceType, info.getServiceName());
     }
 
     private static String[] splitServiceType(@NonNull NsdServiceInfo info) {
         // String.split(pattern, 0) removes trailing empty strings, which would appear when
         // splitting "domain.name." (with a dot a the end), so this is what is needed here.
         final String[] split = info.getServiceType().split("\\.", 0);
-        final String[] type = new String[split.length + 1];
-        System.arraycopy(split, 0, type, 0, split.length);
-        type[split.length] = LOCAL_TLD;
-
-        return type;
+        return CollectionUtils.appendArray(String.class, split, LOCAL_TLD);
     }
 
     /** Returns whether there will be an SRV record when registering the {@code info}. */
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index 4ae8701..7c72fb1 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -78,6 +78,17 @@
             return "CacheKey{ ServiceType=" + mUpperCaseServiceType + ", " + mSocketKey + " }";
         }
     }
+
+    public static class CachedService {
+        @NonNull final MdnsResponse mService;
+        boolean mServiceExpired;
+
+        CachedService(MdnsResponse service) {
+            mService = service;
+            mServiceExpired = false;
+        }
+    }
+
     /**
      * A map of cached services. Key is composed of service type and socket. Value is the list of
      * services which are discovered from the given CacheKey.
@@ -86,7 +97,7 @@
      * removal process to progress through the expiration check efficiently.
      */
     @NonNull
-    private final ArrayMap<CacheKey, List<MdnsResponse>> mCachedServices = new ArrayMap<>();
+    private final ArrayMap<CacheKey, List<CachedService>> mCachedServices = new ArrayMap<>();
     /**
      * A map of service expire callbacks. Key is composed of service type and socket and value is
      * the callback listener.
@@ -113,6 +124,14 @@
         mClock = clock;
     }
 
+    private List<MdnsResponse> cachedServicesToResponses(List<CachedService> cachedServices) {
+        final List<MdnsResponse> responses = new ArrayList<>();
+        for (CachedService cachedService : cachedServices) {
+            responses.add(cachedService.mService);
+        }
+        return responses;
+    }
+
     /**
      * Get the cache services which are queried from given service type and socket.
      *
@@ -126,7 +145,8 @@
             maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
         }
         return mCachedServices.containsKey(cacheKey)
-                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(cacheKey)))
+                ? Collections.unmodifiableList(
+                        cachedServicesToResponses(mCachedServices.get(cacheKey)))
                 : Collections.emptyList();
     }
 
@@ -147,6 +167,16 @@
         return null;
     }
 
+    private static CachedService findMatchedCachedService(
+            @NonNull List<CachedService> cachedServices, @NonNull String serviceName) {
+        for (CachedService cachedService : cachedServices) {
+            if (equalsIgnoreDnsCase(serviceName, cachedService.mService.getServiceInstanceName())) {
+                return cachedService;
+            }
+        }
+        return null;
+    }
+
     /**
      * Get the cache service.
      *
@@ -160,22 +190,23 @@
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
         }
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             return null;
         }
-        final MdnsResponse response = findMatchedResponse(responses, serviceName);
-        return response != null ? new MdnsResponse(response) : null;
+        final CachedService cachedService = findMatchedCachedService(cachedServices, serviceName);
+        return cachedService != null ? new MdnsResponse(cachedService.mService) : null;
     }
 
-    static void insertResponseAndSortList(
-            List<MdnsResponse> responses, MdnsResponse response, long now) {
+    static void insertServiceAndSortList(
+            List<CachedService> cachedServices, CachedService cachedService, long now) {
         // binarySearch returns "the index of the search key, if it is contained in the list;
         // otherwise, (-(insertion point) - 1)"
-        final int searchRes = Collections.binarySearch(responses, response,
+        final int searchRes = Collections.binarySearch(cachedServices, cachedService,
                 // Sort the list by ttl.
-                (o1, o2) -> Long.compare(o1.getMinRemainingTtl(now), o2.getMinRemainingTtl(now)));
-        responses.add(searchRes >= 0 ? searchRes : (-searchRes - 1), response);
+                (o1, o2) -> Long.compare(o1.mService.getMinRemainingTtl(now),
+                        o2.mService.getMinRemainingTtl(now)));
+        cachedServices.add(searchRes >= 0 ? searchRes : (-searchRes - 1), cachedService);
     }
 
     /**
@@ -186,20 +217,22 @@
      */
     public void addOrUpdateService(@NonNull CacheKey cacheKey, @NonNull MdnsResponse response) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses = mCachedServices.computeIfAbsent(
+        final List<CachedService> cachedServices = mCachedServices.computeIfAbsent(
                 cacheKey, key -> new ArrayList<>());
         // Remove existing service if present.
-        final MdnsResponse existing =
-                findMatchedResponse(responses, response.getServiceInstanceName());
-        responses.remove(existing);
+        final CachedService existing = findMatchedCachedService(cachedServices,
+                response.getServiceInstanceName());
+        cachedServices.remove(existing);
+
+        final CachedService cachedService = new CachedService(response);
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             final long now = mClock.elapsedRealtime();
             // Insert and sort service
-            insertResponseAndSortList(responses, response, now);
+            insertServiceAndSortList(cachedServices, cachedService, now);
             // Update the next expiration check time when a new service is added.
             mNextExpirationTime = getNextExpirationTime(now);
         } else {
-            responses.add(response);
+            cachedServices.add(cachedService);
         }
     }
 
@@ -212,30 +245,30 @@
     @Nullable
     public MdnsResponse removeService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             return null;
         }
-        final Iterator<MdnsResponse> iterator = responses.iterator();
-        MdnsResponse removedResponse = null;
+        final Iterator<CachedService> iterator = cachedServices.iterator();
+        CachedService removedService = null;
         while (iterator.hasNext()) {
-            final MdnsResponse response = iterator.next();
-            if (equalsIgnoreDnsCase(serviceName, response.getServiceInstanceName())) {
+            final CachedService cachedService = iterator.next();
+            if (equalsIgnoreDnsCase(serviceName, cachedService.mService.getServiceInstanceName())) {
                 iterator.remove();
-                removedResponse = response;
+                removedService = cachedService;
                 break;
             }
         }
 
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             // Remove the serviceType if no response.
-            if (responses.isEmpty()) {
+            if (cachedServices.isEmpty()) {
                 mCachedServices.remove(cacheKey);
             }
             // Update the next expiration check time when a service is removed.
             mNextExpirationTime = getNextExpirationTime(mClock.elapsedRealtime());
         }
-        return removedResponse;
+        return removedService == null ? null : removedService.mService;
     }
 
     /**
@@ -288,24 +321,25 @@
         mHandler.post(()-> callback.onServiceRecordExpired(previousResponse, newResponse));
     }
 
-    static List<MdnsResponse> removeExpiredServices(@NonNull List<MdnsResponse> responses,
+    static List<CachedService> removeExpiredServices(@NonNull List<CachedService> cachedServices,
             long now) {
-        final List<MdnsResponse> removedResponses = new ArrayList<>();
-        final Iterator<MdnsResponse> iterator = responses.iterator();
+        final List<CachedService> removedServices = new ArrayList<>();
+        final Iterator<CachedService> iterator = cachedServices.iterator();
         while (iterator.hasNext()) {
-            final MdnsResponse response = iterator.next();
+            final CachedService cachedService = iterator.next();
             // TODO: Check other records (A, AAAA, TXT) ttl time and remove the record if it's
             //  expired. Then send service update notification.
-            if (!response.hasServiceRecord() || response.getMinRemainingTtl(now) > 0) {
+            if (!cachedService.mService.hasServiceRecord()
+                    || cachedService.mService.getMinRemainingTtl(now) > 0) {
                 // The responses are sorted by the service record ttl time. Break out of loop
                 // early if service is not expired or no service record.
                 break;
             }
             // Remove the ttl expired service.
             iterator.remove();
-            removedResponses.add(response);
+            removedServices.add(cachedService);
         }
-        return removedResponses;
+        return removedServices;
     }
 
     private long getNextExpirationTime(long now) {
@@ -319,7 +353,7 @@
                     // The empty lists are not kept in the map, so there's always at least one
                     // element in the list. Therefore, it's fine to get the first element without a
                     // null check.
-                    mCachedServices.valueAt(i).get(0).getMinRemainingTtl(now));
+                    mCachedServices.valueAt(i).get(0).mService.getMinRemainingTtl(now));
         }
         return minRemainingTtl == EXPIRATION_NEVER ? EXPIRATION_NEVER : now + minRemainingTtl;
     }
@@ -334,24 +368,24 @@
             return;
         }
 
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             // No such services.
             return;
         }
 
-        final List<MdnsResponse> removedResponses = removeExpiredServices(responses, now);
-        if (removedResponses.isEmpty()) {
+        final List<CachedService> removedServices = removeExpiredServices(cachedServices, now);
+        if (removedServices.isEmpty()) {
             // No expired services.
             return;
         }
 
-        for (MdnsResponse previousResponse : removedResponses) {
-            notifyServiceExpired(cacheKey, previousResponse, null /* newResponse */);
+        for (CachedService previousService : removedServices) {
+            notifyServiceExpired(cacheKey, previousService.mService, null /* newResponse */);
         }
 
         // Remove the serviceType if no response.
-        if (responses.isEmpty()) {
+        if (cachedServices.isEmpty()) {
             mCachedServices.remove(cacheKey);
         }
 
@@ -368,8 +402,9 @@
         for (int i = 0; i < mCachedServices.size(); i++) {
             final CacheKey key = mCachedServices.keyAt(i);
             pw.println(indent + key);
-            for (MdnsResponse response : mCachedServices.valueAt(i)) {
-                pw.println(indent + "  Response{ " + response + " }");
+            for (CachedService cachedService : mCachedServices.valueAt(i)) {
+                pw.println(indent + "  Response{ " + cachedService.mService
+                        + " } Expired=" + cachedService.mServiceExpired);
             }
             pw.println();
         }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index a43486e..95f4fff 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -20,6 +20,7 @@
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.ServiceExpiredCallback;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.findMatchedResponse;
+import static com.android.server.connectivity.mdns.MdnsQueryScheduler.ScheduledQueryTaskArgs;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.Clock;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.buildMdnsServiceInfoFromResponse;
 
@@ -94,6 +95,9 @@
     private final boolean removeServiceAfterTtlExpires =
             MdnsConfigs.removeServiceAfterTtlExpires();
     private final Clock clock;
+    // Use MdnsRealtimeScheduler for query scheduling, which allows for more accurate sending of
+    // queries.
+    @Nullable private final Scheduler scheduler;
 
     @Nullable private MdnsSearchOptions searchOptions;
 
@@ -139,8 +143,7 @@
         public void handleMessage(Message msg) {
             switch (msg.what) {
                 case EVENT_START_QUERYTASK: {
-                    final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs =
-                            (MdnsQueryScheduler.ScheduledQueryTaskArgs) msg.obj;
+                    final ScheduledQueryTaskArgs taskArgs = (ScheduledQueryTaskArgs) msg.obj;
                     // QueryTask should be run immediately after being created (not be scheduled in
                     // advance). Because the result of "makeResponsesForResolve" depends on answers
                     // that were received before it is called, so to take into account all answers
@@ -174,7 +177,7 @@
                     final long now = clock.elapsedRealtime();
                     lastSentTime = now;
                     final long minRemainingTtl = getMinRemainingTtl(now);
-                    MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+                    final ScheduledQueryTaskArgs args =
                             mdnsQueryScheduler.scheduleNextRun(
                                     sentResult.taskArgs.config,
                                     minRemainingTtl,
@@ -189,10 +192,14 @@
                     sharedLog.log(String.format("Query sent with transactionId: %d. "
                                     + "Next run: sessionId: %d, in %d ms",
                             sentResult.transactionId, args.sessionId, timeToNextTaskMs));
-                    dependencies.sendMessageDelayed(
-                            handler,
-                            handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                            timeToNextTaskMs);
+                    if (scheduler != null) {
+                        setDelayedTask(args, timeToNextTaskMs);
+                    } else {
+                        dependencies.sendMessageDelayed(
+                                handler,
+                                handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                                timeToNextTaskMs);
+                    }
                     break;
                 }
                 default:
@@ -254,6 +261,14 @@
                 return List.of(new DatagramPacket(queryBuffer, 0, queryBuffer.length, address));
             }
         }
+
+        /**
+         * @see Scheduler
+         */
+        @Nullable
+        public Scheduler createScheduler(@NonNull Handler handler) {
+            return SchedulerFactory.createScheduler(handler);
+        }
     }
 
     /**
@@ -301,6 +316,8 @@
         this.mdnsQueryScheduler = new MdnsQueryScheduler();
         this.cacheKey = new MdnsServiceCache.CacheKey(serviceType, socketKey);
         this.featureFlags = featureFlags;
+        this.scheduler = featureFlags.isAccurateDelayCallbackEnabled()
+                ? dependencies.createScheduler(handler) : null;
     }
 
     /**
@@ -310,6 +327,9 @@
         removeScheduledTask();
         mdnsQueryScheduler.cancelScheduledRun();
         serviceCache.unregisterServiceExpiredCallback(cacheKey);
+        if (scheduler != null) {
+            scheduler.close();
+        }
     }
 
     private List<MdnsResponse> getExistingServices() {
@@ -317,6 +337,12 @@
                 ? serviceCache.getCachedServices(cacheKey) : Collections.emptyList();
     }
 
+    private void setDelayedTask(ScheduledQueryTaskArgs args, long timeToNextTaskMs) {
+        scheduler.removeDelayedMessage(EVENT_START_QUERYTASK);
+        scheduler.sendDelayedMessage(
+                handler.obtainMessage(EVENT_START_QUERYTASK, args), timeToNextTaskMs);
+    }
+
     /**
      * Registers {@code listener} for receiving discovery event of mDNS service instances, and
      * starts
@@ -363,7 +389,7 @@
         }
         final long minRemainingTtl = getMinRemainingTtl(now);
         if (hadReply) {
-            MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+            final ScheduledQueryTaskArgs args =
                     mdnsQueryScheduler.scheduleNextRun(
                             taskConfig,
                             minRemainingTtl,
@@ -377,10 +403,14 @@
             final long timeToNextTaskMs = calculateTimeToNextTask(args, now);
             sharedLog.log(String.format("Schedule a query. Next run: sessionId: %d, in %d ms",
                     args.sessionId, timeToNextTaskMs));
-            dependencies.sendMessageDelayed(
-                    handler,
-                    handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                    timeToNextTaskMs);
+            if (scheduler != null) {
+                setDelayedTask(args, timeToNextTaskMs);
+            } else {
+                dependencies.sendMessageDelayed(
+                        handler,
+                        handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                        timeToNextTaskMs);
+            }
         } else {
             final List<MdnsResponse> servicesToResolve = makeResponsesForResolve(socketKey);
             final QueryTask queryTask = new QueryTask(
@@ -420,7 +450,11 @@
     }
 
     private void removeScheduledTask() {
-        dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
+        if (scheduler != null) {
+            scheduler.removeDelayedMessage(EVENT_START_QUERYTASK);
+        } else {
+            dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
+        }
         sharedLog.log("Remove EVENT_START_QUERYTASK"
                 + ", current session: " + currentSessionId);
         ++currentSessionId;
@@ -506,10 +540,13 @@
                 }
             }
         }
-        if (dependencies.hasMessages(handler, EVENT_START_QUERYTASK)) {
+        final boolean hasScheduledTask = scheduler != null
+                ? scheduler.hasDelayedMessage(EVENT_START_QUERYTASK)
+                : dependencies.hasMessages(handler, EVENT_START_QUERYTASK);
+        if (hasScheduledTask) {
             final long now = clock.elapsedRealtime();
             final long minRemainingTtl = getMinRemainingTtl(now);
-            MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+            final ScheduledQueryTaskArgs args =
                     mdnsQueryScheduler.maybeRescheduleCurrentRun(now, minRemainingTtl,
                             lastSentTime, currentSessionId + 1,
                             searchOptions.numOfQueriesBeforeBackoff());
@@ -518,10 +555,14 @@
                 final long timeToNextTaskMs = calculateTimeToNextTask(args, now);
                 sharedLog.log(String.format("Reschedule a query. Next run: sessionId: %d, in %d ms",
                         args.sessionId, timeToNextTaskMs));
-                dependencies.sendMessageDelayed(
-                        handler,
-                        handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                        timeToNextTaskMs);
+                if (scheduler != null) {
+                    setDelayedTask(args, timeToNextTaskMs);
+                } else {
+                    dependencies.sendMessageDelayed(
+                            handler,
+                            handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                            timeToNextTaskMs);
+                }
             }
         }
     }
@@ -686,10 +727,10 @@
     private static class QuerySentArguments {
         private final int transactionId;
         private final List<String> subTypes = new ArrayList<>();
-        private final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs;
+        private final ScheduledQueryTaskArgs taskArgs;
 
         QuerySentArguments(int transactionId, @NonNull List<String> subTypes,
-                @NonNull MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs) {
+                @NonNull ScheduledQueryTaskArgs taskArgs) {
             this.transactionId = transactionId;
             this.subTypes.addAll(subTypes);
             this.taskArgs = taskArgs;
@@ -698,14 +739,14 @@
 
     // A FutureTask that enqueues a single query, and schedule a new FutureTask for the next task.
     private class QueryTask implements Runnable {
-        private final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs;
+        private final ScheduledQueryTaskArgs taskArgs;
         private final List<MdnsResponse> servicesToResolve = new ArrayList<>();
         private final List<String> subtypes = new ArrayList<>();
         private final boolean sendDiscoveryQueries;
         private final List<MdnsResponse> existingServices = new ArrayList<>();
         private final boolean onlyUseIpv6OnIpv6OnlyNetworks;
         private final SocketKey socketKey;
-        QueryTask(@NonNull MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs,
+        QueryTask(@NonNull ScheduledQueryTaskArgs taskArgs,
                 @NonNull Collection<MdnsResponse> servicesToResolve,
                 @NonNull Collection<String> subtypes, boolean sendDiscoveryQueries,
                 @NonNull Collection<MdnsResponse> existingServices,
@@ -730,7 +771,7 @@
                                 serviceType,
                                 subtypes,
                                 taskArgs.config.expectUnicastResponse,
-                                taskArgs.config.transactionId,
+                                taskArgs.config.getTransactionId(),
                                 socketKey,
                                 onlyUseIpv6OnIpv6OnlyNetworks,
                                 sendDiscoveryQueries,
@@ -771,7 +812,7 @@
         return minRemainingTtl == Long.MAX_VALUE ? 0 : minRemainingTtl;
     }
 
-    private static long calculateTimeToNextTask(MdnsQueryScheduler.ScheduledQueryTaskArgs args,
+    private static long calculateTimeToNextTask(ScheduledQueryTaskArgs args,
             long now) {
         return Math.max(args.timeToRun - now, 0);
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
index 1212e29..d91bd11 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
@@ -564,7 +564,6 @@
             // Never try mDNS on cellular, or on interfaces with incompatible flags
             if (CollectionUtils.contains(transports, TRANSPORT_CELLULAR)
                     || iface.isLoopback()
-                    || iface.isPointToPoint()
                     || iface.isVirtual()
                     || !iface.isUp()) {
                 return false;
diff --git a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
index dd4073f..2ac5b74 100644
--- a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
+++ b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
@@ -17,7 +17,6 @@
 package com.android.server.connectivity.mdns;
 
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
-import static com.android.server.connectivity.mdns.MdnsSearchOptions.PASSIVE_QUERY_MODE;
 
 import com.android.internal.annotations.VisibleForTesting;
 
@@ -26,136 +25,22 @@
  * Call to getConfigForNextRun returns a config that can be used to build the next query task.
  */
 public class QueryTaskConfig {
-
-    private static final int INITIAL_TIME_BETWEEN_BURSTS_MS =
-            (int) MdnsConfigs.initialTimeBetweenBurstsMs();
-    private static final int MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS =
-            (int) MdnsConfigs.timeBetweenBurstsMs();
-    private static final int QUERIES_PER_BURST = (int) MdnsConfigs.queriesPerBurst();
-    private static final int TIME_BETWEEN_QUERIES_IN_BURST_MS =
-            (int) MdnsConfigs.timeBetweenQueriesInBurstMs();
-    private static final int QUERIES_PER_BURST_PASSIVE_MODE =
-            (int) MdnsConfigs.queriesPerBurstPassive();
     private static final int UNSIGNED_SHORT_MAX_VALUE = 65536;
-    @VisibleForTesting
-    // RFC 6762 5.2: The interval between the first two queries MUST be at least one second.
-    static final int INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS = 1000;
-    @VisibleForTesting
-    // Basically this tries to send one query per typical DTIM interval 100ms, to maximize the
-    // chances that a query will be received if devices are using a DTIM multiplier (in which case
-    // they only listen once every [multiplier] DTIM intervals).
-    static final int TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS = 100;
-    static final int MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS = 60000;
     private final boolean alwaysAskForUnicastResponse =
             MdnsConfigs.alwaysAskForUnicastResponseInEachBurst();
     @VisibleForTesting
-    final int transactionId;
-    @VisibleForTesting
     final boolean expectUnicastResponse;
-    private final int queryIndex;
-    private final int queryMode;
+    final int queryIndex;
+    final int queryMode;
 
-    QueryTaskConfig(int queryMode, int queryIndex, int transactionId,
-            boolean expectUnicastResponse) {
+    QueryTaskConfig(int queryMode, int queryIndex) {
         this.queryMode = queryMode;
-        this.transactionId = transactionId;
         this.queryIndex = queryIndex;
-        this.expectUnicastResponse = expectUnicastResponse;
+        this.expectUnicastResponse = getExpectUnicastResponse();
     }
 
     QueryTaskConfig(int queryMode) {
-        this(queryMode, 0, 1, true);
-    }
-
-    private static int getBurstIndex(int queryIndex, int queryMode) {
-        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
-            // In passive mode, after the first burst of QUERIES_PER_BURST queries, subsequent
-            // bursts have QUERIES_PER_BURST_PASSIVE_MODE queries.
-            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
-            return 1 + (queryIndexAfterFirstBurst / QUERIES_PER_BURST_PASSIVE_MODE);
-        } else {
-            return queryIndex / QUERIES_PER_BURST;
-        }
-    }
-
-    private static int getQueryIndexInBurst(int queryIndex, int queryMode) {
-        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
-            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
-            return queryIndexAfterFirstBurst % QUERIES_PER_BURST_PASSIVE_MODE;
-        } else {
-            return queryIndex % QUERIES_PER_BURST;
-        }
-    }
-
-    private static boolean isFirstBurst(int queryIndex, int queryMode) {
-        return getBurstIndex(queryIndex, queryMode) == 0;
-    }
-
-    private static boolean isFirstQueryInBurst(int queryIndex, int queryMode) {
-        return getQueryIndexInBurst(queryIndex, queryMode) == 0;
-    }
-
-    // TODO: move delay calculations to MdnsQueryScheduler
-    long getDelayBeforeTaskWithoutBackoff() {
-        return getDelayBeforeTaskWithoutBackoff(queryIndex, queryMode);
-    }
-
-    private static long getDelayBeforeTaskWithoutBackoff(int queryIndex, int queryMode) {
-        final int burstIndex = getBurstIndex(queryIndex, queryMode);
-        final int queryIndexInBurst = getQueryIndexInBurst(queryIndex, queryMode);
-        if (queryIndexInBurst == 0) {
-            return getTimeToBurstMs(burstIndex, queryMode);
-        } else if (queryIndexInBurst == 1 && queryMode == AGGRESSIVE_QUERY_MODE) {
-            // In aggressive mode, the first 2 queries are sent without delay.
-            return 0;
-        }
-        return queryMode == AGGRESSIVE_QUERY_MODE
-                ? TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS
-                : TIME_BETWEEN_QUERIES_IN_BURST_MS;
-    }
-
-    private boolean getExpectUnicastResponse(int queryIndex, int queryMode) {
-        if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            if (isFirstQueryInBurst(queryIndex, queryMode)) {
-                return true;
-            }
-        }
-        return alwaysAskForUnicastResponse;
-    }
-
-    /**
-     * Shifts a value left by the specified number of bits, coercing to at most maxValue.
-     *
-     * <p>This allows calculating min(value*2^shift, maxValue) without overflow.
-     */
-    private static int boundedLeftShift(int value, int shift, int maxValue) {
-        // There must be at least one leading zero for positive values, so the maximum left shift
-        // without overflow is the number of leading zeros minus one.
-        final int maxShift = Integer.numberOfLeadingZeros(value) - 1;
-        if (shift > maxShift) {
-            // The shift would overflow positive integers, so is greater than maxValue.
-            return maxValue;
-        }
-        return Math.min(value << shift, maxValue);
-    }
-
-    private static int getTimeToBurstMs(int burstIndex, int queryMode) {
-        if (burstIndex == 0) {
-            // No delay before the first burst
-            return 0;
-        }
-        switch (queryMode) {
-            case PASSIVE_QUERY_MODE:
-                return MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-            case AGGRESSIVE_QUERY_MODE:
-                return boundedLeftShift(INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS,
-                        burstIndex - 1,
-                        MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS);
-            default: // ACTIVE_QUERY_MODE
-                return boundedLeftShift(INITIAL_TIME_BETWEEN_BURSTS_MS,
-                        burstIndex - 1,
-                        MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS);
-        }
+        this(queryMode, 0);
     }
 
     /**
@@ -163,23 +48,19 @@
      */
     public QueryTaskConfig getConfigForNextRun(int queryMode) {
         final int newQueryIndex = queryIndex + 1;
-        int newTransactionId = transactionId + 1;
-        if (newTransactionId > UNSIGNED_SHORT_MAX_VALUE) {
-            newTransactionId = 1;
-        }
-
-        return new QueryTaskConfig(queryMode, newQueryIndex, newTransactionId,
-                getExpectUnicastResponse(newQueryIndex, queryMode));
+        return new QueryTaskConfig(queryMode, newQueryIndex);
     }
 
-    /**
-     * Determine if the query backoff should be used.
-     */
-    public boolean shouldUseQueryBackoff(int numOfQueriesBeforeBackoff) {
-        // Don't enable backoff mode during the burst or in the first burst
-        if (!isFirstQueryInBurst(queryIndex, queryMode) || isFirstBurst(queryIndex, queryMode)) {
-            return false;
+    public int getTransactionId() {
+        return (queryIndex % (UNSIGNED_SHORT_MAX_VALUE - 1)) + 1;
+    }
+
+    private boolean getExpectUnicastResponse() {
+        if (queryMode == AGGRESSIVE_QUERY_MODE) {
+            if (MdnsQueryScheduler.isFirstQueryInBurst(queryIndex, queryMode)) {
+                return true;
+            }
         }
-        return queryIndex > numOfQueriesBeforeBackoff;
+        return queryIndex == 0 || alwaysAskForUnicastResponse;
     }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/Scheduler.java b/service-t/src/com/android/server/connectivity/mdns/Scheduler.java
new file mode 100644
index 0000000..85a8e76
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/Scheduler.java
@@ -0,0 +1,51 @@
+/*
+ * 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.connectivity.mdns;
+
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+
+/**
+ * The interface for scheduler.
+ */
+public interface Scheduler {
+    /**
+     * Set a message to be sent after a specified delay.
+     */
+    boolean sendDelayedMessage(@NonNull Message message, long delayMs);
+
+    /**
+     * Remove a scheduled message.
+     */
+    void removeDelayedMessage(int what);
+
+    /**
+     * Check if there is a scheduled message.
+     */
+    boolean hasDelayedMessage(int what);
+
+    /**
+     * Set a runnable to be executed after a specified delay.
+     */
+    boolean postDelayed(@NonNull Runnable runnable, long delayMs);
+
+    /**
+     * Close this object.
+     */
+    void close();
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java b/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java
new file mode 100644
index 0000000..1cc9a6b
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java
@@ -0,0 +1,38 @@
+/*
+ * 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.connectivity.mdns;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+
+import com.android.server.connectivity.mdns.internal.MdnsRealtimeScheduler;
+
+/**
+ * The factory class for creating a scheduler.
+ */
+public class SchedulerFactory {
+
+    /**
+     * Creates an realtime delay callback.
+     */
+    public static Scheduler createScheduler(@NonNull Handler handler) {
+        return new MdnsRealtimeScheduler(handler);
+    }
+
+    private SchedulerFactory() {
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java b/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java
new file mode 100644
index 0000000..eff7085
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java
@@ -0,0 +1,57 @@
+/*
+ * 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.connectivity.mdns.internal;
+
+import android.os.Handler;
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+
+import com.android.net.module.util.RealtimeScheduler;
+import com.android.server.connectivity.mdns.Scheduler;
+
+/**
+ * The delay callback for delivering scheduled tasks accurately.
+ */
+public class MdnsRealtimeScheduler extends RealtimeScheduler implements
+        Scheduler {
+    private static final String TAG = MdnsRealtimeScheduler.class.getSimpleName();
+
+    public MdnsRealtimeScheduler(@NonNull Handler handler) {
+        super(handler);
+    }
+
+    public boolean sendDelayedMessage(@NonNull Message message, long delayMs) {
+        return super.sendDelayedMessage(message, delayMs);
+    }
+
+    public void removeDelayedMessage(int what) {
+        super.removeDelayedMessage(what);
+    }
+
+    public boolean hasDelayedMessage(int what) {
+        return super.hasDelayedMessage(what);
+    }
+
+    public boolean postDelayed(@NonNull Runnable runnable, long delayMs) {
+        return super.postDelayed(runnable, delayMs);
+    }
+
+    public void close() {
+        super.close();
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
index 41b15dd..282ca9a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
+++ b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
@@ -28,6 +28,7 @@
 import android.util.ArraySet;
 import android.util.Pair;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.server.connectivity.mdns.MdnsConstants;
 import com.android.server.connectivity.mdns.MdnsInetAddressRecord;
 import com.android.server.connectivity.mdns.MdnsPacket;
@@ -273,11 +274,8 @@
      * of ["_printer", "_sub", "_http", "_tcp"].
      */
     public static String[] constructFullSubtype(String[] serviceType, String subtype) {
-        String[] fullSubtype = new String[serviceType.length + 2];
-        fullSubtype[0] = subtype;
-        fullSubtype[1] = MdnsConstants.SUBTYPE_LABEL;
-        System.arraycopy(serviceType, 0, fullSubtype, 2, serviceType.length);
-        return fullSubtype;
+        return CollectionUtils.prependArray(String.class, serviceType, subtype,
+                MdnsConstants.SUBTYPE_LABEL);
     }
 
     /** A wrapper class of {@link SystemClock} to be mocked in unit tests. */
diff --git a/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java b/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java
index b8689d6..21b9b1d 100644
--- a/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java
+++ b/service-t/src/com/android/server/ethernet/EthernetServiceImpl.java
@@ -317,6 +317,6 @@
     @Override
     public List<String> getInterfaceList() {
         PermissionUtils.enforceAccessNetworkStatePermission(mContext, TAG);
-        return mTracker.getInterfaceList();
+        return mTracker.getEthernetInterfaceList();
     }
 }
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 5228aab..c09a94d 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -18,9 +18,14 @@
 
 import static android.net.EthernetManager.ETHERNET_STATE_DISABLED;
 import static android.net.EthernetManager.ETHERNET_STATE_ENABLED;
+import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_LOWPAN;
+import static android.net.NetworkCapabilities.TRANSPORT_VPN;
+import static android.net.NetworkCapabilities.TRANSPORT_WIFI_AWARE;
 import static android.net.TestNetworkManager.TEST_TAP_PREFIX;
 
 import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE;
+import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -40,7 +45,6 @@
 import android.os.Handler;
 import android.os.RemoteCallbackList;
 import android.os.RemoteException;
-import android.os.ServiceSpecificException;
 import android.system.OsConstants;
 import android.text.TextUtils;
 import android.util.ArrayMap;
@@ -51,18 +55,21 @@
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.NetdUtils;
-import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.NetlinkMonitor;
 import com.android.net.module.util.netlink.NetlinkConstants;
 import com.android.net.module.util.netlink.NetlinkMessage;
+import com.android.net.module.util.netlink.NetlinkUtils;
 import com.android.net.module.util.netlink.RtNetlinkLinkMessage;
 import com.android.net.module.util.netlink.StructIfinfoMsg;
 import com.android.server.connectivity.ConnectivityResources;
 
 import java.io.FileDescriptor;
 import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
 import java.util.ArrayList;
+import java.util.Enumeration;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
@@ -96,6 +103,22 @@
     // TODO: consider using SharedLog consistently across ethernet service.
     private static final SharedLog sLog = new SharedLog(TAG);
 
+    @VisibleForTesting
+    public static final NetworkCapabilities DEFAULT_CAPABILITIES = new NetworkCapabilities.Builder()
+                        .addTransportType(TRANSPORT_ETHERNET)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
+                        // TODO: do not hardcode link bandwidth.
+                        .setLinkUpstreamBandwidthKbps(100 * 1000 /* 100 Mbps */)
+                        .setLinkDownstreamBandwidthKbps(100 * 1000 /* 100 Mbps */)
+                        .build();
+
+
     /**
      * Interface names we track. This is a product-dependent regular expression.
      * Use isValidEthernetInterface to check if a interface name is a valid ethernet interface (this
@@ -105,7 +128,7 @@
 
     /**
      * Track test interfaces if true, don't track otherwise.
-     * Volatile is needed as getInterfaceList() does not run on the handler thread.
+     * Volatile is needed as getEthernetInterfaceList() does not run on the handler thread.
      */
     private volatile boolean mIncludeTestInterfaces = false;
 
@@ -162,6 +185,10 @@
             return resources.get().getStringArray(
                     com.android.connectivity.resources.R.array.config_ethernet_interfaces);
         }
+
+        public boolean isAtLeastB() {
+            return SdkLevel.isAtLeastB();
+        }
     }
 
     private class EthernetNetlinkMonitor extends NetlinkMonitor {
@@ -394,30 +421,41 @@
         return mFactory.hasInterface(iface);
     }
 
+    private List<String> getAllInterfaces() {
+        final ArrayList<String> interfaces = new ArrayList<>(
+                List.of(mFactory.getAvailableInterfaces(/* includeRestricted */ true)));
+
+        if (mTetheringInterfaceMode == INTERFACE_MODE_SERVER && mTetheringInterface != null) {
+            interfaces.add(mTetheringInterface);
+        }
+        return interfaces;
+    }
+
     String[] getClientModeInterfaces(boolean includeRestricted) {
         return mFactory.getAvailableInterfaces(includeRestricted);
     }
 
-    List<String> getInterfaceList() {
+    List<String> getEthernetInterfaceList() {
         final List<String> interfaceList = new ArrayList<String>();
-        final String[] ifaces;
+        final Enumeration<NetworkInterface> ifaces;
         try {
-            ifaces = mNetd.interfaceGetList();
-        } catch (RemoteException e) {
-            Log.e(TAG, "Could not get list of interfaces " + e);
+            ifaces = NetworkInterface.getNetworkInterfaces();
+        } catch (SocketException e) {
+            Log.e(TAG, "Failed to get ethernet interfaces: ", e);
             return interfaceList;
         }
 
         // There is a possible race with setIncludeTestInterfaces() which can affect
         // isValidEthernetInterface (it returns true for test interfaces if setIncludeTestInterfaces
         // is set to true).
-        // setIncludeTestInterfaces() is only used in tests, and since getInterfaceList() does not
-        // run on the handler thread, the behavior around setIncludeTestInterfaces() is
+        // setIncludeTestInterfaces() is only used in tests, and since getEthernetInterfaceList()
+        // does not run on the handler thread, the behavior around setIncludeTestInterfaces() is
         // indeterminate either way. This can easily be circumvented by waiting on a callback from
         // a test interface after calling setIncludeTestInterfaces() before calling this function.
         // In production code, this has no effect.
-        for (String iface : ifaces) {
-            if (isValidEthernetInterface(iface)) interfaceList.add(iface);
+        while (ifaces.hasMoreElements()) {
+            NetworkInterface iface = ifaces.nextElement();
+            if (isValidEthernetInterface(iface.getName())) interfaceList.add(iface.getName());
         }
         return interfaceList;
     }
@@ -455,10 +493,16 @@
     public void setIncludeTestInterfaces(boolean include) {
         mHandler.post(() -> {
             mIncludeTestInterfaces = include;
-            if (!include) {
+            if (include) {
+                trackAvailableInterfaces();
+            } else {
                 removeTestData();
+                // remove all test interfaces
+                for (String iface : getAllInterfaces()) {
+                    if (isValidEthernetInterface(iface)) continue;
+                    stopTrackingInterface(iface);
+                }
             }
-            trackAvailableInterfaces();
         });
     }
 
@@ -589,18 +633,11 @@
         InterfaceConfigurationParcel config = null;
         // Bring up the interface so we get link status indications.
         try {
-            PermissionUtils.enforceNetworkStackPermission(mContext);
             // Read the flags before attempting to bring up the interface. If the interface is
             // already running an UP event is created after adding the interface.
             config = NetdUtils.getInterfaceConfigParcel(mNetd, iface);
-            // Only bring the interface up when ethernet is enabled.
-            if (mIsEthernetEnabled) {
-                // As a side-effect, NetdUtils#setInterfaceUp() also clears the interface's IPv4
-                // address and readds it which *could* lead to unexpected behavior in the future.
-                NetdUtils.setInterfaceUp(mNetd, iface);
-            } else {
-                NetdUtils.setInterfaceDown(mNetd, iface);
-            }
+            // Only bring the interface up when ethernet is enabled, otherwise set interface down.
+            setInterfaceUpState(iface, mIsEthernetEnabled);
         } catch (IllegalStateException e) {
             // Either the system is crashing or the interface has disappeared. Just ignore the
             // error; we haven't modified any state because we only do that if our calls succeed.
@@ -660,15 +697,7 @@
             return;
         }
 
-        if (up) {
-            // WARNING! setInterfaceUp() clears the IPv4 address and readds it. Calling
-            // enableInterface() on an active interface can lead to a provisioning failure which
-            // will cause IpClient to be restarted.
-            // TODO: use netlink directly rather than calling into netd.
-            NetdUtils.setInterfaceUp(mNetd, iface);
-        } else {
-            NetdUtils.setInterfaceDown(mNetd, iface);
-        }
+        setInterfaceUpState(iface, up);
         cb.onResult(iface);
     }
 
@@ -707,10 +736,6 @@
     }
 
     private void maybeTrackInterface(String iface) {
-        if (!isValidEthernetInterface(iface)) {
-            return;
-        }
-
         // If we don't already track this interface, and if this interface matches
         // our regex, start tracking it.
         if (mFactory.hasInterface(iface) || iface.equals(mTetheringInterface)) {
@@ -730,13 +755,9 @@
     }
 
     private void trackAvailableInterfaces() {
-        try {
-            final String[] ifaces = mNetd.interfaceGetList();
-            for (String iface : ifaces) {
-                maybeTrackInterface(iface);
-            }
-        } catch (RemoteException | ServiceSpecificException e) {
-            Log.e(TAG, "Could not get list of interfaces " + e);
+        final List<String> ifaces = getEthernetInterfaceList();
+        for (String iface : ifaces) {
+            maybeTrackInterface(iface);
         }
     }
 
@@ -756,11 +777,9 @@
      * <interface name|mac address>;[Network Capabilities];[IP config];[Override Transport]}
      */
     private void parseEthernetConfig(String configString) {
-        final EthernetTrackerConfig config = createEthernetTrackerConfig(configString);
-        NetworkCapabilities nc = createNetworkCapabilities(
-                !TextUtils.isEmpty(config.mCapabilities)  /* clear default capabilities */,
-                config.mCapabilities, config.mTransport).build();
-        mNetworkCapabilities.put(config.mIface, nc);
+        final EthernetConfigParser config =
+                new EthernetConfigParser(configString, mDeps.isAtLeastB());
+        mNetworkCapabilities.put(config.mIface, config.mCaps);
 
         if (null != config.mIpConfig) {
             IpConfiguration ipConfig = parseStaticIpConfiguration(config.mIpConfig);
@@ -768,110 +787,19 @@
         }
     }
 
-    @VisibleForTesting
-    static EthernetTrackerConfig createEthernetTrackerConfig(@NonNull final String configString) {
-        Objects.requireNonNull(configString, "EthernetTrackerConfig requires non-null config");
-        return new EthernetTrackerConfig(configString.split(";", /* limit of tokens */ 4));
-    }
-
     private static NetworkCapabilities createDefaultNetworkCapabilities(boolean isTestIface) {
-        NetworkCapabilities.Builder builder = createNetworkCapabilities(
-                false /* clear default capabilities */, null, null)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED);
-
+        final NetworkCapabilities.Builder builder =
+                new NetworkCapabilities.Builder(DEFAULT_CAPABILITIES);
         if (isTestIface) {
             builder.addTransportType(NetworkCapabilities.TRANSPORT_TEST);
-        } else {
-            builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
+            // TODO: do not remove INTERNET capability for test networks.
+            builder.removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
         }
 
         return builder.build();
     }
 
     /**
-     * Parses a static list of network capabilities
-     *
-     * @param clearDefaultCapabilities Indicates whether or not to clear any default capabilities
-     * @param commaSeparatedCapabilities A comma separated string list of integer encoded
-     *                                   NetworkCapability.NET_CAPABILITY_* values
-     * @param overrideTransport A string representing a single integer encoded override transport
-     *                          type. Must be one of the NetworkCapability.TRANSPORT_*
-     *                          values. TRANSPORT_VPN is not supported. Errors with input
-     *                          will cause the override to be ignored.
-     */
-    @VisibleForTesting
-    static NetworkCapabilities.Builder createNetworkCapabilities(
-            boolean clearDefaultCapabilities, @Nullable String commaSeparatedCapabilities,
-            @Nullable String overrideTransport) {
-
-        final NetworkCapabilities.Builder builder = clearDefaultCapabilities
-                ? NetworkCapabilities.Builder.withoutDefaultCapabilities()
-                : new NetworkCapabilities.Builder();
-
-        // Determine the transport type. If someone has tried to define an override transport then
-        // attempt to add it. Since we can only have one override, all errors with it will
-        // gracefully default back to TRANSPORT_ETHERNET and warn the user. VPN is not allowed as an
-        // override type. Wifi Aware and LoWPAN are currently unsupported as well.
-        int transport = NetworkCapabilities.TRANSPORT_ETHERNET;
-        if (!TextUtils.isEmpty(overrideTransport)) {
-            try {
-                int parsedTransport = Integer.valueOf(overrideTransport);
-                if (parsedTransport == NetworkCapabilities.TRANSPORT_VPN
-                        || parsedTransport == NetworkCapabilities.TRANSPORT_WIFI_AWARE
-                        || parsedTransport == NetworkCapabilities.TRANSPORT_LOWPAN) {
-                    Log.e(TAG, "Override transport '" + parsedTransport + "' is not supported. "
-                            + "Defaulting to TRANSPORT_ETHERNET");
-                } else {
-                    transport = parsedTransport;
-                }
-            } catch (NumberFormatException nfe) {
-                Log.e(TAG, "Override transport type '" + overrideTransport + "' "
-                        + "could not be parsed. Defaulting to TRANSPORT_ETHERNET");
-            }
-        }
-
-        // Apply the transport. If the user supplied a valid number that is not a valid transport
-        // then adding will throw an exception. Default back to TRANSPORT_ETHERNET if that happens
-        try {
-            builder.addTransportType(transport);
-        } catch (IllegalArgumentException iae) {
-            Log.e(TAG, transport + " is not a valid NetworkCapability.TRANSPORT_* value. "
-                    + "Defaulting to TRANSPORT_ETHERNET");
-            builder.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET);
-        }
-
-        builder.setLinkUpstreamBandwidthKbps(100 * 1000);
-        builder.setLinkDownstreamBandwidthKbps(100 * 1000);
-
-        if (!TextUtils.isEmpty(commaSeparatedCapabilities)) {
-            for (String strNetworkCapability : commaSeparatedCapabilities.split(",")) {
-                if (!TextUtils.isEmpty(strNetworkCapability)) {
-                    try {
-                        builder.addCapability(Integer.valueOf(strNetworkCapability));
-                    } catch (NumberFormatException nfe) {
-                        Log.e(TAG, "Capability '" + strNetworkCapability + "' could not be parsed");
-                    } catch (IllegalArgumentException iae) {
-                        Log.e(TAG, strNetworkCapability + " is not a valid "
-                                + "NetworkCapability.NET_CAPABILITY_* value");
-                    }
-                }
-            }
-        }
-        // Ethernet networks have no way to update the following capabilities, so they always
-        // have them.
-        builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING);
-        builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED);
-        builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED);
-
-        return builder;
-    }
-
-    /**
      * Parses static IP configuration.
      *
      * @param staticIpConfig represents static IP configuration in the following format: {@code
@@ -967,22 +895,8 @@
             if (mIsEthernetEnabled == enabled) return;
 
             mIsEthernetEnabled = enabled;
-
-            // Interface in server mode should also be included.
-            ArrayList<String> interfaces =
-                    new ArrayList<>(
-                    List.of(mFactory.getAvailableInterfaces(/* includeRestricted */ true)));
-
-            if (mTetheringInterfaceMode == INTERFACE_MODE_SERVER) {
-                interfaces.add(mTetheringInterface);
-            }
-
-            for (String iface : interfaces) {
-                if (enabled) {
-                    NetdUtils.setInterfaceUp(mNetd, iface);
-                } else {
-                    NetdUtils.setInterfaceDown(mNetd, iface);
-                }
+            for (String iface : getAllInterfaces()) {
+                setInterfaceUpState(iface, enabled);
             }
             broadcastEthernetStateChange(mIsEthernetEnabled);
         });
@@ -1016,6 +930,12 @@
         mListeners.finishBroadcast();
     }
 
+    private void setInterfaceUpState(@NonNull String interfaceName, boolean up) {
+        if (!NetlinkUtils.setInterfaceFlags(interfaceName, up ? IFF_UP : ~IFF_UP)) {
+            Log.e(TAG, "Failed to set interface " + interfaceName + (up ? " up" : " down"));
+        }
+    }
+
     void dump(FileDescriptor fd, IndentingPrintWriter pw, String[] args) {
         postAndWaitForRunnable(() -> {
             pw.println(getClass().getSimpleName());
@@ -1048,18 +968,92 @@
     }
 
     @VisibleForTesting
-    static class EthernetTrackerConfig {
+    static class EthernetConfigParser {
         final String mIface;
-        final String mCapabilities;
+        final NetworkCapabilities mCaps;
         final String mIpConfig;
-        final String mTransport;
 
-        EthernetTrackerConfig(@NonNull final String[] tokens) {
-            Objects.requireNonNull(tokens, "EthernetTrackerConfig requires non-null tokens");
+        private static NetworkCapabilities parseCapabilities(@Nullable String capabilitiesString,
+                boolean isAtLeastB) {
+            final NetworkCapabilities.Builder builder =
+                    NetworkCapabilities.Builder.withoutDefaultCapabilities();
+            builder.setLinkUpstreamBandwidthKbps(100 * 1000 /* 100 Mbps */);
+            builder.setLinkDownstreamBandwidthKbps(100 * 1000 /* 100 Mbps */);
+            // Ethernet networks have no way to update the following capabilities, so they always
+            // have them.
+            builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING);
+            builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED);
+            builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED);
+
+            if (capabilitiesString == null) {
+                return builder.build();
+            }
+
+            if (isAtLeastB && capabilitiesString.equals("*")) {
+                // On Android B+, a "*" string defaults to the same set of default
+                // capabilities assigned to unconfigured interfaces.
+                // Note that the transport type is populated later with the result of
+                // parseTransportType().
+                return new NetworkCapabilities.Builder(DEFAULT_CAPABILITIES)
+                        .removeTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
+                        .build();
+            }
+
+            for (String strNetworkCapability : capabilitiesString.split(",")) {
+                if (TextUtils.isEmpty(strNetworkCapability)) {
+                    continue;
+                }
+                final Integer capability;
+                try {
+                    builder.addCapability(Integer.valueOf(strNetworkCapability));
+                } catch (NumberFormatException e) {
+                    Log.e(TAG, "Failed to parse capability: " + strNetworkCapability, e);
+                    continue;
+                }
+            }
+            return builder.build();
+        }
+
+        private static int parseTransportType(@Nullable String transportString) {
+            if (TextUtils.isEmpty(transportString)) {
+                return TRANSPORT_ETHERNET;
+            }
+
+            final int parsedTransport;
+            try {
+                parsedTransport = Integer.valueOf(transportString);
+            } catch (NumberFormatException e) {
+                Log.e(TAG, "Failed to parse transport type", e);
+                return TRANSPORT_ETHERNET;
+            }
+
+            if (!NetworkCapabilities.isValidTransport(parsedTransport)) {
+                return TRANSPORT_ETHERNET;
+            }
+
+            switch (parsedTransport) {
+                case TRANSPORT_VPN:
+                case TRANSPORT_WIFI_AWARE:
+                case TRANSPORT_LOWPAN:
+                    Log.e(TAG, "Unsupported transport type '" + parsedTransport + "'");
+                    return TRANSPORT_ETHERNET;
+                default:
+                    return parsedTransport;
+            }
+        }
+
+        EthernetConfigParser(String configString, boolean isAtLeastB) {
+            Objects.requireNonNull(configString, "EthernetConfigParser requires non-null config");
+            final String[] tokens = configString.split(";", /* limit of tokens */ 4);
             mIface = tokens[0];
-            mCapabilities = tokens.length > 1 ? tokens[1] : null;
+
+            final NetworkCapabilities nc =
+                    parseCapabilities(tokens.length > 1 ? tokens[1] : null, isAtLeastB);
+            final int transportType = parseTransportType(tokens.length > 3 ? tokens[3] : null);
+            nc.addTransportType(transportType);
+            mCaps = nc;
+
             mIpConfig = tokens.length > 2 && !TextUtils.isEmpty(tokens[2]) ? tokens[2] : null;
-            mTransport = tokens.length > 3 ? tokens[3] : null;
         }
     }
 }
diff --git a/service-t/src/com/android/server/net/NetworkStatsFactory.java b/service-t/src/com/android/server/net/NetworkStatsFactory.java
index 5f66f47..c5a69c0 100644
--- a/service-t/src/com/android/server/net/NetworkStatsFactory.java
+++ b/service-t/src/com/android/server/net/NetworkStatsFactory.java
@@ -19,22 +19,34 @@
 import static android.net.NetworkStats.INTERFACES_ALL;
 import static android.net.NetworkStats.TAG_ALL;
 import static android.net.NetworkStats.UID_ALL;
+import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
 import android.annotation.NonNull;
-import android.annotation.Nullable;
 import android.content.Context;
 import android.net.NetworkStats;
 import android.net.UnderlyingNetworkInfo;
 import android.os.ServiceSpecificException;
 import android.os.SystemClock;
+import android.util.ArraySet;
+import android.util.IndentingPrintWriter;
+import android.util.Log;
+import android.util.Pair;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.DeviceConfigUtils;
 import com.android.server.BpfNetMaps;
+import com.android.server.connectivity.InterfaceTracker;
 
 import java.io.IOException;
 import java.net.ProtocolException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
@@ -65,6 +77,18 @@
     /** Set containing info about active VPNs and their underlying networks. */
     private volatile UnderlyingNetworkInfo[] mUnderlyingNetworkInfos = new UnderlyingNetworkInfo[0];
 
+    static final String CONFIG_PER_UID_TAG_THROTTLING = "per_uid_tag_throttling";
+    static final String CONFIG_PER_UID_TAG_THROTTLING_THRESHOLD =
+            "per_uid_tag_throttling_threshold";
+    private static final int DEFAULT_TAGS_PER_UID_THRESHOLD = 1000;
+    private static final int DUMP_TAGS_PER_UID_COUNT = 20;
+    private final boolean mSupportPerUidTagThrottling;
+    private final int mPerUidTagThrottlingThreshold;
+
+    // Map for set of distinct tags per uid. Used for tag count limiting.
+    @GuardedBy("mPersistentDataLock")
+    private final SparseArray<SparseBooleanArray> mUidTagSets = new SparseArray<>();
+
     // A persistent snapshot of cumulative stats since device start
     @GuardedBy("mPersistentDataLock")
     private NetworkStats mPersistSnapshot;
@@ -108,7 +132,27 @@
 
         /** Create a new {@link BpfNetMaps}. */
         public BpfNetMaps createBpfNetMaps(@NonNull Context ctx) {
-            return new BpfNetMaps(ctx);
+            return new BpfNetMaps(ctx, new InterfaceTracker(ctx));
+        }
+
+        /**
+         * Check whether one specific feature is not disabled.
+         * @param name Flag name of the experiment in the tethering namespace.
+         * @see DeviceConfigUtils#isTetheringFeatureNotChickenedOut(Context, String)
+         */
+        public boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context, name);
+        }
+
+        /**
+         * Wrapper method for DeviceConfigUtils#getDeviceConfigPropertyInt for test injections.
+         *
+         * See {@link DeviceConfigUtils#getDeviceConfigPropertyInt(String, String, int)}
+         * for more detailed information.
+         */
+        public int getDeviceConfigPropertyInt(@NonNull String name, int defaultValue) {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(
+                    NAMESPACE_TETHERING, name, defaultValue);
         }
     }
 
@@ -162,6 +206,10 @@
         }
         mContext = ctx;
         mDeps = deps;
+        mSupportPerUidTagThrottling = mDeps.isFeatureNotChickenedOut(
+            ctx, CONFIG_PER_UID_TAG_THROTTLING);
+        mPerUidTagThrottlingThreshold = mDeps.getDeviceConfigPropertyInt(
+                CONFIG_PER_UID_TAG_THROTTLING_THRESHOLD, DEFAULT_TAGS_PER_UID_THRESHOLD);
     }
 
     /**
@@ -210,10 +258,13 @@
             requestSwapActiveStatsMapLocked();
             // Stats are always read from the inactive map, so they must be read after the
             // swap
-            final NetworkStats stats = mDeps.getNetworkStatsDetail();
+            final NetworkStats diff = mDeps.getNetworkStatsDetail();
+            // Filter based on UID tag set before merging.
+            final NetworkStats filteredDiff = mSupportPerUidTagThrottling
+                    ? filterStatsByUidTagSets(diff) : diff;
             // BPF stats are incremental; fold into mPersistSnapshot.
-            mPersistSnapshot.setElapsedRealtime(stats.getElapsedRealtime());
-            mPersistSnapshot.combineAllValues(stats);
+            mPersistSnapshot.setElapsedRealtime(diff.getElapsedRealtime());
+            mPersistSnapshot.combineAllValues(filteredDiff);
 
             NetworkStats adjustedStats = adjustForTunAnd464Xlat(mPersistSnapshot, prev, vpnArray);
 
@@ -224,6 +275,41 @@
     }
 
     @GuardedBy("mPersistentDataLock")
+    private NetworkStats filterStatsByUidTagSets(NetworkStats stats) {
+        final NetworkStats filteredStats =
+                new NetworkStats(stats.getElapsedRealtime(), stats.size());
+
+        final NetworkStats.Entry entry = new NetworkStats.Entry();
+        final Set<Integer> tooManyTagsUidSet = new ArraySet<>();
+        for (int i = 0; i < stats.size(); i++) {
+            stats.getValues(i, entry);
+            final int uid = entry.uid;
+            final int tag = entry.tag;
+
+            if (tag == NetworkStats.TAG_NONE) {
+                filteredStats.combineValues(entry);
+                continue;
+            }
+
+            SparseBooleanArray tagSet = mUidTagSets.get(uid);
+            if (tagSet == null) {
+                tagSet = new SparseBooleanArray();
+            }
+            if (tagSet.size() < mPerUidTagThrottlingThreshold || tagSet.get(tag)) {
+                filteredStats.combineValues(entry);
+                tagSet.put(tag, true);
+                mUidTagSets.put(uid, tagSet);
+            } else {
+                tooManyTagsUidSet.add(uid);
+            }
+        }
+        if (tooManyTagsUidSet.size() > 0) {
+            Log.wtf(TAG, "Too many tags detected for uids: " + tooManyTagsUidSet);
+        }
+        return filteredStats;
+    }
+
+    @GuardedBy("mPersistentDataLock")
     private NetworkStats adjustForTunAnd464Xlat(NetworkStats uidDetailStats,
             NetworkStats previousStats, UnderlyingNetworkInfo[] vpnArray) {
         // Calculate delta from last snapshot
@@ -307,4 +393,34 @@
         pe.initCause(cause);
         return pe;
     }
+
+    /**
+     * Dump the contents of NetworkStatsFactory.
+     */
+    public void dump(IndentingPrintWriter pw) {
+        dumpUidTagSets(pw);
+    }
+
+    private void dumpUidTagSets(IndentingPrintWriter pw) {
+        pw.println("Top distinct tag counts in UidTagSets:");
+        pw.increaseIndent();
+        final List<Pair<Integer, Integer>> countForUidList = new ArrayList<>();
+        synchronized (mPersistentDataLock) {
+            for (int i = 0; i < mUidTagSets.size(); i++) {
+                final Pair<Integer, Integer> countForUid =
+                        new Pair<>(mUidTagSets.keyAt(i), mUidTagSets.valueAt(i).size());
+                countForUidList.add(countForUid);
+            }
+        }
+        Collections.sort(countForUidList,
+                (entry1, entry2) -> Integer.compare(entry2.second, entry1.second));
+        final int dumpSize = Math.min(countForUidList.size(), DUMP_TAGS_PER_UID_COUNT);
+        for (int j = 0; j < dumpSize; j++) {
+            final Pair<Integer, Integer> entry = countForUidList.get(j);
+            pw.print(entry.first);
+            pw.print("=");
+            pw.println(entry.second);
+        }
+        pw.decreaseIndent();
+    }
 }
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index fb712a1..75d30a9 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -183,15 +183,17 @@
 import com.android.net.module.util.NetworkStatsUtils;
 import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
+import com.android.net.module.util.SkDestroyListener;
 import com.android.net.module.util.Struct;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.Struct.U8;
 import com.android.net.module.util.bpf.CookieTagMapKey;
 import com.android.net.module.util.bpf.CookieTagMapValue;
+import com.android.net.module.util.netlink.InetDiagMessage;
+import com.android.net.module.util.netlink.StructInetDiagSockId;
 import com.android.networkstack.apishim.BroadcastOptionsShimImpl;
 import com.android.networkstack.apishim.ConstantsShim;
 import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
-import com.android.server.BpfNetMaps;
 import com.android.server.connectivity.ConnectivityResources;
 
 import java.io.File;
@@ -216,6 +218,7 @@
 import java.util.concurrent.Executor;
 import java.util.concurrent.Semaphore;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 
 /**
  * Collect and persist detailed network statistics, and provide this data to
@@ -493,7 +496,8 @@
     @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
     // A feature flag to control whether the client-side rate limit cache should be enabled.
-    static final String TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG =
+    @VisibleForTesting
+    public static final String TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG =
             "trafficstats_client_rate_limit_cache_enabled_flag";
     static final String TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG =
             "trafficstats_rate_limit_cache_enabled_flag";
@@ -725,12 +729,14 @@
             mTrafficStatsUidCache = null;
         }
 
-        // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
-        // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
-        // on the experiment flag, BpfNetMaps starts C SkDestroyListener (existing code) or
-        // NetworkStatsService starts Java SkDestroyListener (new code).
-        final BpfNetMaps bpfNetMaps = mDeps.makeBpfNetMaps(mContext);
-        mSkDestroyListener = mDeps.makeSkDestroyListener(mCookieTagMap, mHandler);
+        mSkDestroyListener = mDeps.makeSkDestroyListener((message) -> {
+            final StructInetDiagSockId sockId = message.inetDiagMsg.id;
+            try {
+                mCookieTagMap.deleteEntry(new CookieTagMapKey(sockId.cookie));
+            } catch (ErrnoException e) {
+                Log.e(TAG, "Failed to delete CookieTagMap entry for " + sockId.cookie  + ": " + e);
+            }
+        }, mHandler);
         mHandler.post(mSkDestroyListener::start);
     }
 
@@ -951,16 +957,11 @@
             return Build.isDebuggable();
         }
 
-        /** Create a new BpfNetMaps. */
-        public BpfNetMaps makeBpfNetMaps(Context ctx) {
-            return new BpfNetMaps(ctx);
-        }
-
         /** Create a new SkDestroyListener. */
-        public SkDestroyListener makeSkDestroyListener(
-                IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
-            return new SkDestroyListener(
-                    cookieTagMap, handler, new SharedLog(MAX_SOCKET_DESTROY_LISTENER_LOGS, TAG));
+        public SkDestroyListener makeSkDestroyListener(Consumer<InetDiagMessage> consumer,
+                Handler handler) {
+            return SkDestroyListener.makeSkDestroyListener(consumer, handler,
+                    new SharedLog(MAX_SOCKET_DESTROY_LISTENER_LOGS, TAG));
         }
 
         /**
@@ -3227,6 +3228,12 @@
             pw.increaseIndent();
             mSkDestroyListener.dump(pw);
             pw.decreaseIndent();
+
+            pw.println();
+            pw.println("NetworkStatsFactory logs:");
+            pw.increaseIndent();
+            mStatsFactory.dump(pw);
+            pw.decreaseIndent();
         }
     }
 
diff --git a/service-t/src/com/android/server/net/SkDestroyListener.java b/service-t/src/com/android/server/net/SkDestroyListener.java
deleted file mode 100644
index a6cc2b5..0000000
--- a/service-t/src/com/android/server/net/SkDestroyListener.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.server.net;
-
-import static android.system.OsConstants.NETLINK_INET_DIAG;
-
-import android.os.Handler;
-import android.system.ErrnoException;
-
-import com.android.net.module.util.IBpfMap;
-import com.android.net.module.util.SharedLog;
-import com.android.net.module.util.bpf.CookieTagMapKey;
-import com.android.net.module.util.bpf.CookieTagMapValue;
-import com.android.net.module.util.ip.NetlinkMonitor;
-import com.android.net.module.util.netlink.InetDiagMessage;
-import com.android.net.module.util.netlink.NetlinkMessage;
-import com.android.net.module.util.netlink.StructInetDiagSockId;
-
-import java.io.PrintWriter;
-
-/**
- * Monitor socket destroy and delete entry from cookie tag bpf map.
- */
-public class SkDestroyListener extends NetlinkMonitor {
-    private static final int SKNLGRP_INET_TCP_DESTROY = 1;
-    private static final int SKNLGRP_INET_UDP_DESTROY = 2;
-    private static final int SKNLGRP_INET6_TCP_DESTROY = 3;
-    private static final int SKNLGRP_INET6_UDP_DESTROY = 4;
-
-    // TODO: if too many sockets are closed too quickly, this can overflow the socket buffer, and
-    // some entries in mCookieTagMap will not be freed. In order to fix this it would be needed to
-    // periodically dump all sockets and remove the tag entries for sockets that have been closed.
-    // For now, set a large-enough buffer that hundreds of sockets can be closed without getting
-    // ENOBUFS and leaking mCookieTagMap entries.
-    private static final int SOCK_RCV_BUF_SIZE = 512 * 1024;
-
-    private final IBpfMap<CookieTagMapKey, CookieTagMapValue> mCookieTagMap;
-
-    SkDestroyListener(final IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap,
-            final Handler handler, final SharedLog log) {
-        super(handler, log, "SkDestroyListener", NETLINK_INET_DIAG,
-                1 << (SKNLGRP_INET_TCP_DESTROY - 1)
-                        | 1 << (SKNLGRP_INET_UDP_DESTROY - 1)
-                        | 1 << (SKNLGRP_INET6_TCP_DESTROY - 1)
-                        | 1 << (SKNLGRP_INET6_UDP_DESTROY - 1),
-                SOCK_RCV_BUF_SIZE);
-        mCookieTagMap = cookieTagMap;
-    }
-
-    @Override
-    public void processNetlinkMessage(final NetlinkMessage nlMsg, final long whenMs) {
-        if (!(nlMsg instanceof InetDiagMessage)) {
-            mLog.e("Received non InetDiagMessage");
-            return;
-        }
-        final StructInetDiagSockId sockId = ((InetDiagMessage) nlMsg).inetDiagMsg.id;
-        try {
-            mCookieTagMap.deleteEntry(new CookieTagMapKey(sockId.cookie));
-        } catch (ErrnoException e) {
-            mLog.e("Failed to delete CookieTagMap entry for " + sockId.cookie  + ": " + e);
-        }
-    }
-
-    /**
-     * Dump the contents of SkDestroyListener log.
-     */
-    public void dump(PrintWriter pw) {
-        mLog.reverseDump(pw);
-    }
-}
diff --git a/service/Android.bp b/service/Android.bp
index fd3d4a3..8b469e4 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -113,7 +113,6 @@
         ":services.connectivity-netstats-jni-sources",
         "jni/com_android_server_connectivity_ClatCoordinator.cpp",
         "jni/com_android_server_ServiceManagerWrapper.cpp",
-        "jni/com_android_server_TestNetworkService.cpp",
         "jni/onload.cpp",
     ],
     header_libs: [
@@ -125,7 +124,7 @@
         "libmodules-utils-build",
         "libnetjniutils",
         "libnet_utils_device_common_bpfjni",
-        "libnet_utils_device_common_timerfdjni",
+        "libserviceconnectivityjni",
         "netd_aidl_interface-lateststable-ndk",
     ],
     shared_libs: [
@@ -162,6 +161,7 @@
     ],
     libs: [
         "framework-annotations-lib",
+        "framework-bluetooth.stubs.module_lib",
         "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         // The framework-connectivity-t library is only available on T+ platforms
@@ -207,6 +207,7 @@
     },
     visibility: [
         "//packages/modules/Connectivity/service-t",
+        "//packages/modules/Connectivity/service-b",
         "//packages/modules/Connectivity/networksecurity:__subpackages__",
         "//packages/modules/Connectivity/tests:__subpackages__",
         "//packages/modules/Connectivity/thread/service:__subpackages__",
@@ -252,7 +253,11 @@
         "service-networksecurity-pre-jarjar",
         service_remoteauth_pre_jarjar_lib,
         "service-thread-pre-jarjar",
-    ],
+    ] + select(release_flag("RELEASE_MOVE_VCN_TO_MAINLINE"), {
+        true: ["service-connectivity-b-pre-jarjar"],
+        default: [],
+    }),
+
     // The below libraries are not actually needed to build since no source is compiled
     // (only combining prebuilt static_libs), but they are necessary so that R8 has the right
     // references to optimize the code. Without these, there will be missing class warnings and
@@ -338,6 +343,7 @@
     name: "service-connectivity-jarjar-gen",
     tool_files: [
         ":service-connectivity-pre-jarjar{.jar}",
+        ":service-connectivity-b-pre-jarjar{.jar}",
         ":service-connectivity-tiramisu-pre-jarjar{.jar}",
         "jarjar-excludes.txt",
     ],
@@ -347,6 +353,7 @@
     out: ["service_connectivity_jarjar_rules.txt"],
     cmd: "$(location jarjar-rules-generator) " +
         "$(location :service-connectivity-pre-jarjar{.jar}) " +
+        "$(location :service-connectivity-b-pre-jarjar{.jar}) " +
         "$(location :service-connectivity-tiramisu-pre-jarjar{.jar}) " +
         "--prefix android.net.connectivity " +
         "--excludes $(location jarjar-excludes.txt) " +
diff --git a/service/ServiceConnectivityResources/OWNERS b/service/ServiceConnectivityResources/OWNERS
index df41ff2..c3c08ee 100644
--- a/service/ServiceConnectivityResources/OWNERS
+++ b/service/ServiceConnectivityResources/OWNERS
@@ -1,2 +1,3 @@
+per-file res/raw/ct_public_keys.pem = file:platform/packages/modules/Connectivity:main:/networksecurity/OWNERS
 per-file res/values/config_thread.xml = file:platform/packages/modules/Connectivity:main:/thread/OWNERS
 per-file res/values/overlayable.xml = file:platform/packages/modules/Connectivity:main:/thread/OWNERS
diff --git a/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem b/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem
new file mode 100644
index 0000000..8a5ebbf
--- /dev/null
+++ b/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem
@@ -0,0 +1,56 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmDwwE2FRpVJlw58fo5Ra
+Fsocb7DP3FJwwuaghXL3xPtyZisDDXIpfVG+UwDPyIGrRuYzeu9pjZ/0xGSYSPZ0
+l/H8L2XurInoAbj+Z370HB7W3njIOqG9rw5N6u/xT4nscBj1HKeUwh+Hwc0F1UHS
+MP8J32nWAfVepHrte3Jy+w/V7BId6WjJmxtI9OoJ7WTsoTeD+jLANUJWtpbx0p1L
+OAy70BlHbB0UvAJdMH149qi7Y9KaJ74Ea2ofKY43NWGgWfR+fY6V7CCfUXCOgvNM
+qq5QGyMnFKrlP0XkoOaVJkK92VEtyNff8KUXik2ZyUzhNkg4ZplCrhESWeykckB/
+mdZpVc45KZ+6Sx3U+FF30eRwlu2Nw2h1KKHzYfa6M1bcy1f/xw+IDq4R+1rR7sPb
+J2mMKz0OPeCXwGEXWzBuMOs0IQu6gyNdyVZcRSyQ+LcUzvEwksLP6G/ycqmwVfdw
+JE28k3MPUR3IxnMDQrdcZb7M7kjBoykKW3jQfwlEoK4EcNQbMXVn8Ws8rcwgQcQJ
+MjjQnbISojsJYo2fG+TE6d9rORB6CYVzICOj4YguXm4LO89cYQlR600W32pP5y3o
+3/yAd9OjsKrNfREDlcCXUx1APc7gOF351RFdHlDI0+RF/pIHbH3sww3VMCJ+tjst
+ZldgJk9yaz0cvOdKyVWC83ECAwEAAQ==
+-----END PUBLIC KEY-----
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnmb1lacOnP5H1bwb06mG
+fEUeC9PZRwNQskSs9KaWrpfrSkLKuHXkVCbgeagbUR/Sh1OeIhyJRSS0PLCO0JjC
+UpGhYMrIGRgEET4IrP9f8aMFqxxxBUEanI+OxAhIJlP9tiWfGdKAASYcxg/DyXXz
+bqSXBEFJqBLqmnfHcLB/NhO1ejV6AiU1NMrT+iWSrJG8b6mq/LlAqWvidK8oPBsW
+87M4pPLpUoA54ultjx2wEzJ9dBy6jtnKZ/dz4DkDhYug5izRDcYtEfzQBoum0etV
+s4EoogW1AMeqW5G+L4HjPNgp3gNGZ/2RaBy7gp8Br+byYu2wHwdQIBQjS310yaKc
+nuNFOd+Q0DrzvHKB7yYzzdwo+hNocPpkvOzSw74jd09kDZQ+S2peCJ5NPU7VKT6/
+tkvc3tYA9pAu4/T+BGqRft4FjgeNANfSIX/WhWDzzVWymTUGFUvt+D0fF3Cw+XSa
+b8uTgRZ1Ma/FvSGgXHVoG9E4QAFFG4I9mmRqsnzA+8fqSNAfieL5OWecq4PU+pMa
+uNVJ9hbvmW2yXuMgEg6K9kFLdxggRn+OcxowgXJJh79L0r7RN1d8kuHelDhOzcte
+dUTtLNOb/1PA0d/I2IVJwc9xSQZXurqqT/Z+c01B3/R0BgGDkIT/yZ5iHPoZFYPD
+U8UdQzUK0KXgGkc8P5pJW8kCAwEAAQ==
+-----END PUBLIC KEY-----
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAn1ssK1m56VVurVJ/fNKe
++aXDnytBy/hWY48ZT+ZC0S29llfjkaCBlmkngoI2hgwz9BI6pHwUINS15pT1sznw
+kHoEaNKr8sANHTQ0PYlDuk5iQSjnaGz7XmqbZ3c92BQrmLG/kwX2c17YNJI2qCk3
+MaBJBeS5YErR3xcucT9M7qtNWWIT9O0sLV1lDUZVCYedWBSnNz1/mAiLhttWmU5u
+GKl/5LmjWP/piNjh8whx0abJUGeGS2HH0JAOb0pjBV6UQvj3tO+gTiNDhdrE4CKh
+Qn8SKNjW/BY320f0A5t581Q0++cQUAisRgBQos0Pkvg5vb6wgII+pJq0SnZoYFfH
+oycuR4q3eOCgJmpEAPC0MhNpIDUQS6p3QabD9ID+21ymiQa/Zf8Mv2xMM6ZItKxX
+77vSKvBbimTGmB2IU+Zi484PKI5QwxBUCHVSmNpvHyXyjhBmpqik9Op26QYYT10b
+ADnJY1L/Q+i44nI4pfwgIncqAWuLnxg/XggJDWj48Un9SMNoyN7gzQX75M7rh7/t
+F6QtKvJreP0pP2UoVSgZKjXnL9tqeZbOdZU1kBHi1HOhlUKTfq5dn2fVUeYkE769
+clFF7Y1FiI259IPhTKiOIfARJ4BL4Sn8D9c9vpxDYPFl5bCJbspmFpwfzTMDnGVS
+/IlY6Putpv2/lD1B7aQGt1sCAwEAAQ==
+-----END PUBLIC KEY-----
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAwMPNecDhLDamK9Rs8W5V
+c0LWZPbk9FNP+budBoFMX46dWyJ75O5e8xY9UhJSBFl8+Itm/rWO/h2zZ6qE3Cq8
+1LYbOg+x+rLnYOcAvD2O7EU73A3RD/vqoUDDVK3cwKMq3ry7CYu+NJW7TRIKV7Ct
+BMCBrvpmC1WlZ/jxSV0Soapza4/H+UV0hHYh/Wn3EWObGWYdI3yxwZ81AyU1QCR/
+oaO0PQPXqvo3gPBINnK3Qr0aLtYc4YCfTXe6i4g3DeAlkpqNLZtC2hyqiRB4Dg47
+zDzYECGofRAu8w9d8uI+eccacXfvI9zEcL7FAl5AzBKmMFOfBNTr08V7+aROWfGO
+7imWsj2MQ6RG17zqJak5QX/1bqDxwhG0KolB86mPZu0WeKz1B3iP5qAUlDNBHLDV
+pQIez0mrMsVsimVguuLYHMpIgijphA9WhijCJW2x7c6aocB6IpnMIV1sqnUQTwLG
+t32AMrckxqFmaKGj/8I9M+Xj+Cy4fIa5YSOdb/tlaYZSfjH5ch41xucQ2HWFyZ/9
+hkTFodvF5ajCQ5maHeIjDkS/Bc/s9CB+/fbSkstDsPMRp/ExyQcEYjKTG5o9Ewyo
++KGGXS2dSS10Ibl0Zx/S/0ZuZx8ZAxMOIIPpugdkWqHU9thh71dR8zM4KMkEfB8C
+sWLGB1yMuztn9nRUcpiEZTECAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/service/ServiceConnectivityResources/res/values-bs/strings.xml b/service/ServiceConnectivityResources/res/values-bs/strings.xml
index ae401fb..84858c8 100644
--- a/service/ServiceConnectivityResources/res/values-bs/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-bs/strings.xml
@@ -36,7 +36,7 @@
     <string name="network_switch_metered_detail" msgid="1257300152739542096">"Kada <xliff:g id="PREVIOUS_NETWORK">%2$s</xliff:g> nema pristup internetu, uređaj koristi mrežu <xliff:g id="NEW_NETWORK">%1$s</xliff:g>. Moguća je naplata usluge."</string>
     <string name="network_switch_metered_toast" msgid="70691146054130335">"Prebačeno iz mreže <xliff:g id="PREVIOUS_NETWORK">%1$s</xliff:g> u <xliff:g id="NEW_NETWORK">%2$s</xliff:g> mrežu"</string>
   <string-array name="network_switch_type_name">
-    <item msgid="3004933964374161223">"prijenos podataka na mobilnoj mreži"</item>
+    <item msgid="3004933964374161223">"prenos podataka na mobilnoj mreži"</item>
     <item msgid="5624324321165953608">"WiFi"</item>
     <item msgid="5667906231066981731">"Bluetooth"</item>
     <item msgid="346574747471703768">"Ethernet"</item>
diff --git a/service/ServiceConnectivityResources/res/values-mr/strings.xml b/service/ServiceConnectivityResources/res/values-mr/strings.xml
index 4797ff1..b0da141 100644
--- a/service/ServiceConnectivityResources/res/values-mr/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-mr/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="connectivityResourcesAppLabel" msgid="2476261877900882974">"सिस्टम कनेक्टिव्हिटी चे स्रोत"</string>
+    <string name="connectivityResourcesAppLabel" msgid="2476261877900882974">"सिस्टीम कनेक्टिव्हिटीचे स्रोत"</string>
     <string name="wifi_available_sign_in" msgid="8041178343789805553">"वाय-फाय नेटवर्कमध्‍ये साइन इन करा"</string>
     <string name="network_available_sign_in" msgid="2622520134876355561">"नेटवर्कवर साइन इन करा"</string>
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
diff --git a/service/ServiceConnectivityResources/res/values/config.xml b/service/ServiceConnectivityResources/res/values/config.xml
index 2d3647a..514563b 100644
--- a/service/ServiceConnectivityResources/res/values/config.xml
+++ b/service/ServiceConnectivityResources/res/values/config.xml
@@ -135,6 +135,15 @@
     <string-array translatable="false" name="config_nsdOffloadServicesPriority">
     </string-array>
 
+    <!-- An array of service types that shouldn't be offloaded via NsdManager#registerOffloadEngine.
+         Format is [service type], for example: "_testservice._tcp"
+         Due to limited RAM in hardware offload, we prioritize user-impacting services.
+         _googlezone._tcp, an internal Googlecast service, was therefore blocked.
+    -->
+    <string-array name="config_nsdOffloadServicesDenyList" translatable="false">
+        <item>_googlezone._tcp</item>
+    </string-array>
+
     <!-- Whether to use an ongoing notification for signing in to captive portals, instead of a
          notification that can be dismissed. -->
     <bool name="config_ongoingSignInNotification">false</bool>
@@ -184,6 +193,8 @@
                    The NOT_ROAMING, NOT_CONGESTED and NOT_SUSPENDED capabilities are always
                    added automatically because this configuration provides no way to update
                    them dynamically.
+                   On Android B+, a "*" string configures the same default capabilities as is
+                   used for interfaces without any explicit configuration.
                [IP config] Optional. If empty or not specified - DHCP will be used, otherwise
                    use the following format to specify static IP configuration:
                        ip=<ip-address/mask> gateway=<ip-address> dns=<comma-sep-ip-addresses>
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index 4027038..62b12fb 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -26,6 +26,19 @@
     -->
     <bool name="config_thread_default_enabled">true</bool>
 
+    <!-- Sets to {@code true} to enable Thread Border Router on the device by default.
+    -->
+    <bool name="config_thread_border_router_default_enabled">false</bool>
+
+    <!-- Whether to enable or disable setting Thread country code from the telephony, wifi, location,
+     etc. The country code could be used by the Thread co-processor for setting the fixed output
+	 power of Thread radio. If the device needs to dynamically change the max output power according
+	 to the user scenario to meet the requirement of Specific Absorption Rate (SAR), it should call
+	 the API `setChannelMaxPowers()` to change the max output power, and this configuration could be
+	 set to false to disable the Thread service from setting the Thread country code.
+    -->
+    <bool name="config_thread_country_code_enabled">true</bool>
+
     <!-- Whether to use location APIs in the algorithm to determine country code or not.
     If disabled, will use other sources (telephony, wifi, etc) to determine device location for
     Thread Network regulatory purposes.
@@ -33,11 +46,12 @@
     <bool name="config_thread_location_use_for_country_code_enabled">true</bool>
 
     <!-- Specifies the UTF-8 vendor name of this device. If this value is not an empty string, it
-    will be included in TXT value (key is 'vn') of the "_meshcop._udp" mDNS service which is
-    published by the Thread service. A non-empty string value must not exceed length of 24 UTF-8
-    bytes.
+    will be included in TXT value (key is 'vn') of the "_meshcop._udp" mDNS service as well as the
+    Vendor Name TLV for network diagnostic. A non-empty string value must not exceed length of 24
+    UTF-8 bytes. A special value "ro.product.manufacturer" indicates this value should be derived
+    from the `ro.product.manufacturer` system property.
     -->
-    <string translatable="false" name="config_thread_vendor_name">Android</string>
+    <string translatable="false" name="config_thread_vendor_name">ro.product.manufacturer</string>
 
     <!-- Specifies the 24 bits vendor OUI of this device. If this value is not an empty string, it
     will be included in TXT (key is 'vo') value of the "_meshcop._udp" mDNS service which is
@@ -48,11 +62,12 @@
     <string translatable="false" name="config_thread_vendor_oui"></string>
 
     <!-- Specifies the UTF-8 product model name of this device. If this value is not an empty
-    string, it will be included in TXT (key is 'mn') value of the "_meshcop._udp" mDNS service
-    which is published by the Thread service. A non-empty string value must not exceed length of 24
-    UTF-8 bytes.
+    string, it will be included in TXT (key is 'mn') value of the "_meshcop._udp" mDNS service as
+    well as the Vendor Model TLV for network diagnostic. A non-empty string value must not exceed
+    length of 24 UTF-8 bytes. A special value "ro.product.model" indicates this value should be
+    derived from the `ro.product.model` system property.
     -->
-    <string translatable="false" name="config_thread_model_name">Thread Border Router</string>
+    <string translatable="false" name="config_thread_model_name">ro.product.model</string>
 
     <!-- Specifies vendor-specific mDNS TXT entries which will be included in the "_meshcop._udp"
     service. The TXT entries list MUST conform to the format requirement in RFC 6763 section 6. For
@@ -71,4 +86,18 @@
     -->
     <string-array name="config_thread_mdns_vendor_specific_txts">
     </string-array>
+
+    <!-- Whether to enable / start SRP server only when border routing is ready. SRP server and
+    border routing are mandatory features required by a Thread Border Router, and it takes 10 to
+    20 seconds to establish border routing. Starting SRP server earlier is useful for use cases
+    where the user needs to know what are the devices in the network before actually needs to reach
+    to the devices, or reaching to Thread end devices doesn't require border routing to work.
+    -->
+    <bool name="config_thread_srp_server_wait_for_border_routing_enabled">true</bool>
+
+    <!-- Whether this border router will automatically join the previous connected network after
+    device reboots. Setting this value to false can allow the user to control the lifecycle of
+    the Thread border router state on this device.
+    -->
+    <bool name="config_thread_border_router_auto_join_enabled">true</bool>
 </resources>
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index fbaae05..28b46c1 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -31,6 +31,7 @@
             <item type="integer" name="config_networkWakeupPacketMask"/>
             <item type="integer" name="config_networkNotifySwitchType"/>
             <item type="array" name="config_networkNotifySwitches"/>
+            <item type="array" name="config_nsdOffloadServicesDenyList"/>
             <item type="array" name="config_nsdOffloadServicesPriority"/>
             <item type="bool" name="config_ongoingSignInNotification"/>
             <item type="bool" name="config_autoCancelNetworkNotifications"/>
@@ -47,11 +48,15 @@
 
             <!-- Configuration values for ThreadNetworkService -->
             <item type="bool" name="config_thread_default_enabled" />
+            <item type="bool" name="config_thread_border_router_default_enabled" />
+            <item type="bool" name="config_thread_country_code_enabled" />
             <item type="bool" name="config_thread_location_use_for_country_code_enabled" />
             <item type="string" name="config_thread_vendor_name" />
             <item type="string" name="config_thread_vendor_oui" />
             <item type="string" name="config_thread_model_name" />
             <item type="array" name="config_thread_mdns_vendor_specific_txts" />
+            <item type="bool" name="config_thread_srp_server_wait_for_border_routing_enabled" />
+            <item type="bool" name="config_thread_border_router_auto_join_enabled" />
         </policy>
     </overlayable>
 </resources>
diff --git a/service/jarjar-excludes.txt b/service/jarjar-excludes.txt
index 7bd3862..9076b53 100644
--- a/service/jarjar-excludes.txt
+++ b/service/jarjar-excludes.txt
@@ -1,3 +1,4 @@
 # Classes loaded by SystemServer via their hardcoded name, so they can't be jarjared
 com\.android\.server\.ConnectivityServiceInitializer(\$.+)?
+com\.android\.server\.ConnectivityServiceInitializerB(\$.+)?
 com\.android\.server\.NetworkStatsServiceInitializer(\$.+)?
diff --git a/service/jni/com_android_server_TestNetworkService.cpp b/service/jni/com_android_server_TestNetworkService.cpp
deleted file mode 100644
index 08d31a3..0000000
--- a/service/jni/com_android_server_TestNetworkService.cpp
+++ /dev/null
@@ -1,168 +0,0 @@
-/*
- * Copyright (C) 2018 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.
- */
-
-#define LOG_NDEBUG 0
-
-#define LOG_TAG "TestNetworkServiceJni"
-
-#include <arpa/inet.h>
-#include <errno.h>
-#include <fcntl.h>
-#include <linux/if.h>
-#include <linux/if_tun.h>
-#include <linux/ipv6_route.h>
-#include <linux/route.h>
-#include <netinet/in.h>
-#include <stdio.h>
-#include <string.h>
-#include <sys/ioctl.h>
-#include <sys/socket.h>
-#include <sys/stat.h>
-#include <sys/types.h>
-
-#include <log/log.h>
-
-#include "jni.h"
-#include <android-base/stringprintf.h>
-#include <android-base/unique_fd.h>
-#include <bpf/KernelUtils.h>
-#include <nativehelper/JNIHelp.h>
-#include <nativehelper/ScopedUtfChars.h>
-
-#ifndef IFF_NO_CARRIER
-#define IFF_NO_CARRIER 0x0040
-#endif
-
-namespace android {
-
-//------------------------------------------------------------------------------
-
-static void throwException(JNIEnv* env, int error, const char* action, const char* iface) {
-    const std::string& msg = "Error: " + std::string(action) + " " + std::string(iface) +  ": "
-                + std::string(strerror(error));
-    jniThrowException(env, "java/lang/IllegalStateException", msg.c_str());
-}
-
-// enable or disable  carrier on tun / tap interface.
-static void setTunTapCarrierEnabledImpl(JNIEnv* env, const char* iface, int tunFd, bool enabled) {
-    uint32_t carrierOn = enabled;
-    if (ioctl(tunFd, TUNSETCARRIER, &carrierOn)) {
-        throwException(env, errno, "set carrier", iface);
-    }
-}
-
-static int createTunTapImpl(JNIEnv* env, bool isTun, bool hasCarrier, bool setIffMulticast,
-                            const char* iface) {
-    base::unique_fd tun(open("/dev/tun", O_RDWR | O_NONBLOCK));
-    ifreq ifr{};
-
-    // Allocate interface.
-    ifr.ifr_flags = (isTun ? IFF_TUN : IFF_TAP) | IFF_NO_PI;
-    if (!hasCarrier) {
-        // Using IFF_NO_CARRIER is supported starting in kernel version >= 6.0
-        // Up until then, unsupported flags are ignored.
-        if (!bpf::isAtLeastKernelVersion(6, 0, 0)) {
-            throwException(env, EOPNOTSUPP, "IFF_NO_CARRIER not supported", ifr.ifr_name);
-            return -1;
-        }
-        ifr.ifr_flags |= IFF_NO_CARRIER;
-    }
-    strlcpy(ifr.ifr_name, iface, IFNAMSIZ);
-    if (ioctl(tun.get(), TUNSETIFF, &ifr)) {
-        throwException(env, errno, "allocating", ifr.ifr_name);
-        return -1;
-    }
-
-    // Mark some TAP interfaces as supporting multicast
-    if (setIffMulticast && !isTun) {
-        base::unique_fd inet6CtrlSock(socket(AF_INET6, SOCK_DGRAM, 0));
-        ifr.ifr_flags = IFF_MULTICAST;
-
-        if (ioctl(inet6CtrlSock.get(), SIOCSIFFLAGS, &ifr)) {
-            throwException(env, errno, "set IFF_MULTICAST", ifr.ifr_name);
-            return -1;
-        }
-    }
-
-    return tun.release();
-}
-
-static void bringUpInterfaceImpl(JNIEnv* env, const char* iface) {
-    // Activate interface using an unconnected datagram socket.
-    base::unique_fd inet6CtrlSock(socket(AF_INET6, SOCK_DGRAM, 0));
-
-    ifreq ifr{};
-    strlcpy(ifr.ifr_name, iface, IFNAMSIZ);
-    if (ioctl(inet6CtrlSock.get(), SIOCGIFFLAGS, &ifr)) {
-        throwException(env, errno, "read flags", iface);
-        return;
-    }
-    ifr.ifr_flags |= IFF_UP;
-    if (ioctl(inet6CtrlSock.get(), SIOCSIFFLAGS, &ifr)) {
-        throwException(env, errno, "set IFF_UP", iface);
-        return;
-    }
-}
-
-//------------------------------------------------------------------------------
-
-
-
-static void setTunTapCarrierEnabled(JNIEnv* env, jclass /* clazz */, jstring
-                                    jIface, jint tunFd, jboolean enabled) {
-    ScopedUtfChars iface(env, jIface);
-    if (!iface.c_str()) {
-        jniThrowNullPointerException(env, "iface");
-        return;
-    }
-    setTunTapCarrierEnabledImpl(env, iface.c_str(), tunFd, enabled);
-}
-
-static jint createTunTap(JNIEnv* env, jclass /* clazz */, jboolean isTun,
-                             jboolean hasCarrier, jboolean setIffMulticast, jstring jIface) {
-    ScopedUtfChars iface(env, jIface);
-    if (!iface.c_str()) {
-        jniThrowNullPointerException(env, "iface");
-        return -1;
-    }
-
-    return createTunTapImpl(env, isTun, hasCarrier, setIffMulticast, iface.c_str());
-}
-
-static void bringUpInterface(JNIEnv* env, jclass /* clazz */, jstring jIface) {
-    ScopedUtfChars iface(env, jIface);
-    if (!iface.c_str()) {
-        jniThrowNullPointerException(env, "iface");
-        return;
-    }
-    bringUpInterfaceImpl(env, iface.c_str());
-}
-
-//------------------------------------------------------------------------------
-
-static const JNINativeMethod gMethods[] = {
-    {"nativeSetTunTapCarrierEnabled", "(Ljava/lang/String;IZ)V", (void*)setTunTapCarrierEnabled},
-    {"nativeCreateTunTap", "(ZZZLjava/lang/String;)I", (void*)createTunTap},
-    {"nativeBringUpInterface", "(Ljava/lang/String;)V", (void*)bringUpInterface},
-};
-
-int register_com_android_server_TestNetworkService(JNIEnv* env) {
-    return jniRegisterNativeMethods(env,
-            "android/net/connectivity/com/android/server/TestNetworkService", gMethods,
-            NELEM(gMethods));
-}
-
-}; // namespace android
diff --git a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
index c0082bb..622fba8 100644
--- a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
+++ b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
@@ -104,7 +104,7 @@
     // First verify the clatd directory and binary,
     // since this is built into the apex file system image,
     // failures here are 99% likely to be build problems.
-    V(kClatdDir, S_IFDIR|0750, ROOT, SYSTEM, "system_file", DIR);
+    V(kClatdDir, S_IFDIR|0750, CLAT, SYSTEM, "system_file", DIR);
     V(kClatdBin, S_IFREG|S_ISUID|S_ISGID|0755, CLAT, CLAT, "clatd_exec", BIN);
 
     // Move on to verifying that the bpf programs and maps are as expected.
diff --git a/service/jni/onload.cpp b/service/jni/onload.cpp
index 8e01260..f87470d 100644
--- a/service/jni/onload.cpp
+++ b/service/jni/onload.cpp
@@ -21,12 +21,11 @@
 
 namespace android {
 
-int register_com_android_server_TestNetworkService(JNIEnv* env);
 int register_com_android_server_connectivity_ClatCoordinator(JNIEnv* env);
 int register_android_server_net_NetworkStatsFactory(JNIEnv* env);
 int register_android_server_net_NetworkStatsService(JNIEnv* env);
 int register_com_android_server_ServiceManagerWrapper(JNIEnv* env);
-int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+int register_com_android_net_module_util_ServiceConnectivityJni(JNIEnv *env,
                                                       char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
@@ -36,10 +35,6 @@
         return JNI_ERR;
     }
 
-    if (register_com_android_server_TestNetworkService(env) < 0) {
-        return JNI_ERR;
-    }
-
     if (register_com_android_server_ServiceManagerWrapper(env) < 0) {
         return JNI_ERR;
     }
@@ -58,9 +53,9 @@
         }
     }
 
-    if (register_com_android_net_module_util_TimerFdUtils(
+    if (register_com_android_net_module_util_ServiceConnectivityJni(
             env, "android/net/connectivity/com/android/net/module/util/"
-                 "TimerFdUtils") < 0) {
+                 "ServiceConnectivityJni") < 0) {
       return JNI_ERR;
     }
 
diff --git a/service/libconnectivity/include/connectivity_native.h b/service/libconnectivity/include/connectivity_native.h
index f4676a9..f264b68 100644
--- a/service/libconnectivity/include/connectivity_native.h
+++ b/service/libconnectivity/include/connectivity_native.h
@@ -20,12 +20,6 @@
 #include <sys/cdefs.h>
 #include <netinet/in.h>
 
-// For branches that do not yet have __ANDROID_API_U__ defined, like module
-// release branches.
-#ifndef __ANDROID_API_U__
-#define __ANDROID_API_U__ 34
-#endif
-
 __BEGIN_DECLS
 
 /**
@@ -41,7 +35,7 @@
  *
  * @param port Int corresponding to port number.
  */
-int AConnectivityNative_blockPortForBind(in_port_t port) __INTRODUCED_IN(__ANDROID_API_U__);
+int AConnectivityNative_blockPortForBind(in_port_t port) __INTRODUCED_IN(34);
 
 /**
  * Unblocks a port that has previously been blocked.
@@ -54,7 +48,7 @@
  *
  * @param port Int corresponding to port number.
  */
-int AConnectivityNative_unblockPortForBind(in_port_t port) __INTRODUCED_IN(__ANDROID_API_U__);
+int AConnectivityNative_unblockPortForBind(in_port_t port) __INTRODUCED_IN(34);
 
 /**
  * Unblocks all ports that have previously been blocked.
@@ -64,7 +58,7 @@
  *  - EPERM if the UID of the client doesn't have network stack permission
  *  - Other errors as per https://man7.org/linux/man-pages/man2/bpf.2.html
  */
-int AConnectivityNative_unblockAllPortsForBind() __INTRODUCED_IN(__ANDROID_API_U__);
+int AConnectivityNative_unblockAllPortsForBind() __INTRODUCED_IN(34);
 
 /**
  * Gets the list of ports that have been blocked.
@@ -79,7 +73,7 @@
  *              blocked ports, which may be larger than the ports array that was filled.
  */
 int AConnectivityNative_getPortsBlockedForBind(in_port_t* _Nonnull ports, size_t* _Nonnull count)
-    __INTRODUCED_IN(__ANDROID_API_U__);
+    __INTRODUCED_IN(34);
 
 __END_DECLS
 
diff --git a/service/native/libs/libclat/Android.bp b/service/native/libs/libclat/Android.bp
index 6c1c2c4..9554bd8 100644
--- a/service/native/libs/libclat/Android.bp
+++ b/service/native/libs/libclat/Android.bp
@@ -47,6 +47,7 @@
     srcs: [
         "clatutils_test.cpp",
     ],
+    stl: "libc++_static",
     header_libs: [
         "bpf_connectivity_headers",
     ],
diff --git a/service/src/com/android/server/BpfNetMaps.java b/service/src/com/android/server/BpfNetMaps.java
index 44868b2d..523ffee 100644
--- a/service/src/com/android/server/BpfNetMaps.java
+++ b/service/src/com/android/server/BpfNetMaps.java
@@ -25,6 +25,8 @@
 import static android.net.BpfNetMapsConstants.DATA_SAVER_ENABLED_MAP_PATH;
 import static android.net.BpfNetMapsConstants.IIF_MATCH;
 import static android.net.BpfNetMapsConstants.INGRESS_DISCARD_MAP_PATH;
+import static android.net.BpfNetMapsConstants.LOCAL_NET_ACCESS_MAP_PATH;
+import static android.net.BpfNetMapsConstants.LOCAL_NET_BLOCKED_UID_MAP_PATH;
 import static android.net.BpfNetMapsConstants.LOCKDOWN_VPN_MATCH;
 import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
 import static android.net.BpfNetMapsConstants.UID_PERMISSION_MAP_PATH;
@@ -36,17 +38,19 @@
 import static android.net.ConnectivityManager.BLOCKED_REASON_NONE;
 import static android.net.ConnectivityManager.FIREWALL_RULE_ALLOW;
 import static android.net.ConnectivityManager.FIREWALL_RULE_DENY;
-import static android.net.INetd.PERMISSION_INTERNET;
-import static android.net.INetd.PERMISSION_NONE;
-import static android.net.INetd.PERMISSION_UNINSTALLED;
-import static android.net.INetd.PERMISSION_UPDATE_DEVICE_STATS;
 import static android.system.OsConstants.EINVAL;
 import static android.system.OsConstants.ENODEV;
 import static android.system.OsConstants.ENOENT;
 import static android.system.OsConstants.EOPNOTSUPP;
 
 import static com.android.server.ConnectivityStatsLog.NETWORK_BPF_MAP_INFO;
+import static com.android.server.connectivity.NetworkPermissions.PERMISSION_NONE;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_INTERNET;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UNINSTALLED;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS;
 
+import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.app.StatsManager;
 import android.content.Context;
 import android.net.BpfNetMapsUtils;
@@ -74,6 +78,7 @@
 import com.android.net.module.util.IBpfMap;
 import com.android.net.module.util.SingleWriterBpfMap;
 import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Bool;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.Struct.U32;
 import com.android.net.module.util.Struct.U8;
@@ -81,12 +86,15 @@
 import com.android.net.module.util.bpf.CookieTagMapValue;
 import com.android.net.module.util.bpf.IngressDiscardKey;
 import com.android.net.module.util.bpf.IngressDiscardValue;
+import com.android.net.module.util.bpf.LocalNetAccessKey;
+import com.android.server.connectivity.InterfaceTracker;
 
 import java.io.FileDescriptor;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.StringJoiner;
 
@@ -131,10 +139,14 @@
     private static IBpfMap<S32, U8> sDataSaverEnabledMap = null;
     private static IBpfMap<IngressDiscardKey, IngressDiscardValue> sIngressDiscardMap = null;
 
+    private static IBpfMap<LocalNetAccessKey, Bool> sLocalNetAccessMap = null;
+    private static IBpfMap<U32, Bool> sLocalNetBlockedUidMap = null;
+
     private static final List<Pair<Integer, String>> PERMISSION_LIST = Arrays.asList(
-            Pair.create(PERMISSION_INTERNET, "PERMISSION_INTERNET"),
-            Pair.create(PERMISSION_UPDATE_DEVICE_STATS, "PERMISSION_UPDATE_DEVICE_STATS")
+            Pair.create(TRAFFIC_PERMISSION_INTERNET, "PERMISSION_INTERNET"),
+            Pair.create(TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS, "PERMISSION_UPDATE_DEVICE_STATS")
     );
+    private final InterfaceTracker mInterfaceTracker;
 
     /**
      * Set configurationMap for test.
@@ -186,6 +198,25 @@
         sIngressDiscardMap = ingressDiscardMap;
     }
 
+    /**
+     * Set localNetAccessMap for test.
+     */
+    @VisibleForTesting
+    public static void setLocalNetAccessMapForTest(
+            IBpfMap<LocalNetAccessKey, Bool> localNetAccessMap) {
+        sLocalNetAccessMap = localNetAccessMap;
+    }
+
+    /**
+     * Set localNetBlockedUidMap for test.
+     */
+    @VisibleForTesting
+    public static void setLocalNetBlockedUidMapForTest(
+            IBpfMap<U32, Bool> localNetBlockedUidMap) {
+        sLocalNetBlockedUidMap = localNetBlockedUidMap;
+    }
+
+
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     private static IBpfMap<S32, U32> getConfigurationMap() {
         try {
@@ -247,6 +278,26 @@
         }
     }
 
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    private static IBpfMap<U32, Bool> getLocalNetBlockedUidMap() {
+        try {
+            return SingleWriterBpfMap.getSingleton(LOCAL_NET_BLOCKED_UID_MAP_PATH,
+                    U32.class, Bool.class);
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Cannot open local_net_blocked_uid map", e);
+        }
+    }
+
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    private static IBpfMap<LocalNetAccessKey, Bool> getLocalNetAccessMap() {
+        try {
+            return SingleWriterBpfMap.getSingleton(LOCAL_NET_ACCESS_MAP_PATH,
+                    LocalNetAccessKey.class, Bool.class);
+        } catch (ErrnoException e) {
+            throw new IllegalStateException("Cannot open local_net_access map", e);
+        }
+    }
+
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     private static void initBpfMaps() {
         if (sConfigurationMap == null) {
@@ -299,6 +350,27 @@
         } catch (ErrnoException e) {
             throw new IllegalStateException("Failed to initialize ingress discard map", e);
         }
+
+        if (isAtLeast25Q2()) {
+            if (sLocalNetAccessMap == null) {
+                sLocalNetAccessMap = getLocalNetAccessMap();
+            }
+            try {
+                sLocalNetAccessMap.clear();
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Failed to initialize local_net_access map", e);
+            }
+
+            if (sLocalNetBlockedUidMap == null) {
+                sLocalNetBlockedUidMap = getLocalNetBlockedUidMap();
+            }
+            try {
+                sLocalNetBlockedUidMap.clear();
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Failed to initialize local_net_blocked_uid map",
+                        e);
+            }
+        }
     }
 
     /**
@@ -356,23 +428,27 @@
 
     /** Constructor used after T that doesn't need to use netd anymore. */
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-    public BpfNetMaps(final Context context) {
-        this(context, null);
+    public BpfNetMaps(final Context context, @NonNull final InterfaceTracker interfaceTracker) {
+        this(context, null, interfaceTracker);
 
         if (!SdkLevel.isAtLeastT()) throw new IllegalArgumentException("BpfNetMaps need to use netd before T");
     }
 
-    public BpfNetMaps(final Context context, final INetd netd) {
-        this(context, netd, new Dependencies());
+    public BpfNetMaps(final Context context, final INetd netd, @NonNull final InterfaceTracker
+            interfaceTracker) {
+        this(context, netd, new Dependencies(), interfaceTracker);
     }
 
     @VisibleForTesting
-    public BpfNetMaps(final Context context, final INetd netd, final Dependencies deps) {
+    public BpfNetMaps(final Context context, final INetd netd, final Dependencies deps,
+            @NonNull final  InterfaceTracker interfaceTracker) {
+        Objects.requireNonNull(interfaceTracker);
         if (SdkLevel.isAtLeastT()) {
             ensureInitialized(context);
         }
         mNetd = netd;
         mDeps = deps;
+        mInterfaceTracker = interfaceTracker;
     }
 
     private void maybeThrow(final int err, final String msg) {
@@ -387,6 +463,21 @@
         }
     }
 
+    private void throwIfPre25Q2(final String msg) {
+        if (!isAtLeast25Q2()) {
+            throw new UnsupportedOperationException(msg);
+        }
+    }
+
+    /*
+     ToDo : Remove this method when SdkLevel.isAtLeastB() is fixed, aosp is at sdk level 36 or use
+     NetworkStackUtils.isAtLeast25Q2 when it is moved to a static lib.
+     */
+    public static boolean isAtLeast25Q2() {
+        return SdkLevel.isAtLeastB()  || (SdkLevel.isAtLeastV()
+                && "Baklava".equals(Build.VERSION.CODENAME));
+    }
+
     private void removeRule(final int uid, final long match, final String caller) {
         try {
             synchronized (sUidOwnerMap) {
@@ -788,7 +879,8 @@
         }
 
         // Remove the entry if package is uninstalled or uid has only INTERNET permission.
-        if (permissions == PERMISSION_UNINSTALLED || permissions == PERMISSION_INTERNET) {
+        if (permissions == TRAFFIC_PERMISSION_UNINSTALLED
+                || permissions == TRAFFIC_PERMISSION_INTERNET) {
             for (final int uid : uids) {
                 try {
                     sUidPermissionMap.deleteEntry(new S32(uid));
@@ -810,6 +902,161 @@
     }
 
     /**
+     * Add configuration to local_net_access trie map.
+     * @param lpmBitlen prefix length that will be used for longest matching
+     * @param iface interface name
+     * @param address remote address. ipv4 addresses would be mapped to v6
+     * @param protocol required for longest match in special cases
+     * @param remotePort src/dst port for ingress/egress
+     * @param isAllowed is the local network call allowed or blocked.
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public void addLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
+            final InetAddress address, final int protocol, final int remotePort,
+            final boolean isAllowed) {
+        throwIfPre25Q2("addLocalNetAccess is not available on pre-B devices");
+        if (iface == null) {
+            Log.e(TAG, "Null iface, skip addLocalNetAccess for " + address);
+            return;
+        }
+        int ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        if (ifIndex == 0) {
+            mInterfaceTracker.addInterface(iface);
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
+        if (ifIndex == 0) {
+            Log.e(TAG, "Failed to get if index, skip addLocalNetAccess for " + address
+                    + "(" + iface + ")");
+            return;
+        }
+        final LocalNetAccessKey localNetAccessKey = new LocalNetAccessKey(lpmBitlen, ifIndex,
+                address, protocol, remotePort);
+
+        try {
+            sLocalNetAccessMap.updateEntry(localNetAccessKey, new Bool(isAllowed));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to add local network access for localNetAccessKey : "
+                    + localNetAccessKey + ", isAllowed : " + isAllowed);
+        }
+    }
+
+    /**
+     * Remove configuration to local_net_access trie map.
+     * @param lpmBitlen prefix length that will be used for longest matching
+     * @param iface interface name
+     * @param address remote address. ipv4 addresses would be mapped to v6
+     * @param protocol required for longest match in special cases
+     * @param remotePort src/dst port for ingress/egress
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public void removeLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
+            final InetAddress address, final int protocol, final int remotePort) {
+        throwIfPre25Q2("removeLocalNetAccess is not available on pre-B devices");
+        final int ifIndex;
+        if (iface == null) {
+            ifIndex = 0;
+        } else {
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
+        if (ifIndex == 0) {
+            Log.e(TAG, "Failed to get if index, skip removeLocalNetAccess for " + address
+                    + "(" + iface + ")");
+            return;
+        }
+        final LocalNetAccessKey localNetAccessKey = new LocalNetAccessKey(lpmBitlen, ifIndex,
+                address, protocol, remotePort);
+
+        try {
+            sLocalNetAccessMap.deleteEntry(localNetAccessKey);
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to remove local network access for localNetAccessKey : "
+                    + localNetAccessKey);
+        }
+    }
+
+    /**
+     * Fetches value available in local_net_access bpf map for provided configuration
+     * @param lpmBitlen  prefix length that will be used for longest matching
+     * @param iface    interface name
+     * @param address    remote address. ipv4 addresses would be mapped to v6
+     * @param protocol   required for longest match in special cases
+     * @param remotePort src/dst port for ingress/egress
+     * @return false if the configuration is disallowed, true if the configuration is absent i.e. it
+     * is not local network or if configuration is allowed like local dns servers.
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public boolean getLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
+            final InetAddress address, final int protocol, final int remotePort) {
+        throwIfPre25Q2("getLocalNetAccess is not available on pre-B devices");
+        final int ifIndex;
+        if (iface == null) {
+            ifIndex = 0;
+        } else {
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
+        if (ifIndex == 0) {
+            Log.e(TAG, "Failed to get if index, returning default from getLocalNetAccess for "
+                    + address + "(" + iface + ")");
+            return true;
+        }
+        final LocalNetAccessKey localNetAccessKey = new LocalNetAccessKey(lpmBitlen, ifIndex,
+                address, protocol, remotePort);
+        try {
+            final Bool value = sLocalNetAccessMap.getValue(localNetAccessKey);
+            return value == null ? true : value.val;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to find local network access configuration for "
+                    + "localNetAccessKey : " + localNetAccessKey);
+        }
+        return true;
+    }
+
+    /**
+     * Add uid to local_net_blocked_uid map.
+     * @param uid application uid that needs to block local network calls.
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public void addUidToLocalNetBlockMap(final int uid) {
+        throwIfPre25Q2("addUidToLocalNetBlockMap is not available on pre-B devices");
+        try {
+            sLocalNetBlockedUidMap.updateEntry(new U32(uid), new Bool(true));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to add local network blocked for uid : " + uid);
+        }
+    }
+
+    /**
+     * True if local network calls are blocked for application.
+     * @param uid application uid that needs check if local network calls are blocked.
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public boolean isUidBlockedFromUsingLocalNetwork(final int uid) {
+        throwIfPre25Q2("isUidBlockedFromUsingLocalNetwork is not available on pre-B devices");
+        try {
+            final Bool value = sLocalNetBlockedUidMap.getValue(new U32(uid));
+            return value == null ? false : value.val;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to find uid(" + uid
+                    + ") is present in local network blocked map");
+        }
+        return false;
+    }
+
+    /**
+     * Remove uid from local_net_blocked_uid map(if present).
+     * @param uid application uid that needs check if local network calls are blocked.
+     */
+    @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public void removeUidFromLocalNetBlockMap(final int uid) {
+        throwIfPre25Q2("removeUidFromLocalNetBlockMap is not available on pre-B devices");
+        try {
+            sLocalNetBlockedUidMap.deleteEntry(new U32(uid));
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to remove uid(" + uid + ") from local network blocked map");
+        }
+    }
+
+    /**
      * Get granted permissions for specified uid. If uid is not in the map, this method returns
      * {@link android.net.INetd.PERMISSION_INTERNET} since this is a default permission.
      * See {@link #setNetPermForUids}
@@ -824,10 +1071,10 @@
             // Key of uid permission map is appId
             // TODO: Rename map name
             final U8 permissions = sUidPermissionMap.getValue(new S32(appId));
-            return permissions != null ? permissions.val : PERMISSION_INTERNET;
+            return permissions != null ? permissions.val : TRAFFIC_PERMISSION_INTERNET;
         } catch (ErrnoException e) {
             Log.wtf(TAG, "Failed to get permission for uid: " + uid);
-            return PERMISSION_INTERNET;
+            return TRAFFIC_PERMISSION_INTERNET;
         }
     }
 
@@ -976,7 +1223,7 @@
         if (permissionMask == PERMISSION_NONE) {
             return "PERMISSION_NONE";
         }
-        if (permissionMask == PERMISSION_UNINSTALLED) {
+        if (permissionMask == TRAFFIC_PERMISSION_UNINSTALLED) {
             // PERMISSION_UNINSTALLED should never appear in the map
             return "PERMISSION_UNINSTALLED error!";
         }
@@ -1079,6 +1326,14 @@
                     (key, value) -> "[" + key.dstAddr + "]: "
                             + value.iif1 + "(" + mDeps.getIfName(value.iif1) + "), "
                             + value.iif2 + "(" + mDeps.getIfName(value.iif2) + ")");
+            if (sLocalNetBlockedUidMap != null) {
+                BpfDump.dumpMap(sLocalNetAccessMap, pw, "sLocalNetAccessMap (default is true meaning global)",
+                        (key, value) -> "" + key + ": " + value.val);
+            }
+            if (sLocalNetBlockedUidMap != null) {
+                BpfDump.dumpMap(sLocalNetBlockedUidMap, pw, "sLocalNetBlockedUidMap",
+                        (key, value) -> "" + key + ": " + value.val);
+            }
             dumpDataSaverConfig(pw);
             pw.decreaseIndent();
         }
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index f3b97bc..929c4dd 100644
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -48,6 +48,7 @@
 import static android.net.ConnectivityManager.CALLBACK_LOSING;
 import static android.net.ConnectivityManager.CALLBACK_LOST;
 import static android.net.ConnectivityManager.CALLBACK_PRECHECK;
+import static android.net.ConnectivityManager.CALLBACK_RESERVED;
 import static android.net.ConnectivityManager.CALLBACK_RESUMED;
 import static android.net.ConnectivityManager.CALLBACK_SUSPENDED;
 import static android.net.ConnectivityManager.CALLBACK_UNAVAIL;
@@ -108,8 +109,11 @@
 import static android.net.NetworkCapabilities.REDACT_FOR_ACCESS_FINE_LOCATION;
 import static android.net.NetworkCapabilities.REDACT_FOR_LOCAL_MAC_ADDRESS;
 import static android.net.NetworkCapabilities.REDACT_FOR_NETWORK_SETTINGS;
+import static android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS;
+import static android.net.NetworkCapabilities.RES_ID_UNSET;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
+import static android.net.NetworkCapabilities.TRANSPORT_THREAD;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkRequest.Type.LISTEN_FOR_BEST;
@@ -117,10 +121,14 @@
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST;
 import static android.net.OemNetworkPreferences.OEM_NETWORK_PREFERENCE_TEST_ONLY;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK;
+import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_SELF_CERTIFIED_CAPABILITIES_DECLARATION;
 import static android.net.connectivity.ConnectivityCompatChanges.NETWORK_BLOCKED_WITHOUT_INTERNET_PERMISSION;
 import static android.os.Process.INVALID_UID;
 import static android.os.Process.VPN_UID;
+import static android.system.OsConstants.ENOENT;
+import static android.system.OsConstants.ENOTCONN;
+import static android.system.OsConstants.EOPNOTSUPP;
 import static android.system.OsConstants.ETH_P_ALL;
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
@@ -140,6 +148,8 @@
 import static com.android.net.module.util.BpfUtils.BPF_CGROUP_UDP6_RECVMSG;
 import static com.android.net.module.util.BpfUtils.BPF_CGROUP_UDP6_SENDMSG;
 import static com.android.net.module.util.NetworkMonitorUtils.isPrivateDnsValidationRequired;
+import static com.android.net.module.util.NetworkStackConstants.IPV4_LOCAL_PREFIXES;
+import static com.android.net.module.util.NetworkStackConstants.MULTICAST_AND_BROADCAST_PREFIXES;
 import static com.android.net.module.util.PermissionUtils.enforceAnyPermissionOf;
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermission;
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermissionOr;
@@ -150,6 +160,7 @@
 import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.server.connectivity.ConnectivityFlags.NAMESPACE_TETHERING_BOOT;
 import static com.android.server.connectivity.ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS;
+import static com.android.server.connectivity.ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER;
 import static com.android.server.connectivity.ConnectivityFlags.REQUEST_RESTRICTED_WIFI;
 import static com.android.server.connectivity.ConnectivityFlags.WIFI_DATA_INACTIVITY_TIMEOUT;
 
@@ -195,6 +206,7 @@
 import android.net.IConnectivityDiagnosticsCallback;
 import android.net.IConnectivityManager;
 import android.net.IDnsResolver;
+import android.net.IIntResultListener;
 import android.net.INetd;
 import android.net.INetworkActivityListener;
 import android.net.INetworkAgent;
@@ -207,6 +219,7 @@
 import android.net.InetAddresses;
 import android.net.IpMemoryStore;
 import android.net.IpPrefix;
+import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.LocalNetworkConfig;
 import android.net.LocalNetworkInfo;
@@ -218,6 +231,7 @@
 import android.net.Network;
 import android.net.NetworkAgent;
 import android.net.NetworkAgentConfig;
+import android.net.NetworkAndAgentRegistryParcelable;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
 import android.net.NetworkInfo.DetailedState;
@@ -355,6 +369,7 @@
 import com.android.server.connectivity.DnsManager.PrivateDnsValidationUpdate;
 import com.android.server.connectivity.DscpPolicyTracker;
 import com.android.server.connectivity.FullScore;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.server.connectivity.InvalidTagException;
 import com.android.server.connectivity.KeepaliveResourceUtil;
 import com.android.server.connectivity.KeepaliveTracker;
@@ -413,14 +428,15 @@
 import java.util.concurrent.CopyOnWriteArraySet;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
+import java.util.function.Predicate;
 
 /**
  * @hide
  */
-public class ConnectivityService extends IConnectivityManager.Stub
-        implements PendingIntent.OnFinished {
+public class ConnectivityService extends IConnectivityManager.Stub {
     private static final String TAG = ConnectivityService.class.getSimpleName();
 
     private static final String DIAG_ARG = "--diag";
@@ -515,6 +531,7 @@
     private final boolean mBackgroundFirewallChainEnabled;
 
     private final boolean mUseDeclaredMethodsForCallbacksEnabled;
+    private final boolean mQueueNetworkAgentEventsInSystemServer;
 
     // Flag to delay callbacks for frozen apps, suppressing duplicate and stale callbacks.
     private final boolean mQueueCallbacksForFrozenApps;
@@ -554,6 +571,7 @@
     // The Context is created for UserHandle.ALL.
     private final Context mUserAllContext;
     private final Dependencies mDeps;
+    private final PermissionMonitor.Dependencies mPermissionMonitorDeps;
     private final ConnectivityFlags mFlags;
     // 0 is full bad, 100 is full good
     private int mDefaultInetConditionPublished = 0;
@@ -566,6 +584,7 @@
     private final NetworkStatsManager mStatsManager;
     private final NetworkPolicyManager mPolicyManager;
     private final BpfNetMaps mBpfNetMaps;
+    private final InterfaceTracker mInterfaceTracker;
 
     /**
      * TestNetworkService (lazily) created upon first usage. Locked to prevent creation of multiple
@@ -1013,6 +1032,8 @@
     private final LingerMonitor mLingerMonitor;
     private final SatelliteAccessController mSatelliteAccessController;
 
+    private final L2capNetworkProvider mL2capNetworkProvider;
+
     // sequence number of NetworkRequests
     private int mNextNetworkRequestId = NetworkRequest.FIRST_REQUEST_ID;
 
@@ -1453,6 +1474,10 @@
             return SdkLevel.isAtLeastV();
         }
 
+        public boolean isAtLeastB() {
+            return SdkLevel.isAtLeastB();
+        }
+
         /**
          * Get system properties to use in ConnectivityService.
          */
@@ -1613,6 +1638,11 @@
                     connectivityServiceInternalHandler);
         }
 
+        /** Creates an L2capNetworkProvider */
+        public L2capNetworkProvider makeL2capNetworkProvider(Context context) {
+            return new L2capNetworkProvider(context);
+        }
+
         /** Returns the data inactivity timeout to be used for cellular networks */
         public int getDefaultCellularDataInactivityTimeout() {
             return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING_BOOT,
@@ -1644,8 +1674,17 @@
          * @param netd a netd binder
          * @return BpfNetMaps implementation.
          */
-        public BpfNetMaps getBpfNetMaps(Context context, INetd netd) {
-            return new BpfNetMaps(context, netd);
+        public BpfNetMaps getBpfNetMaps(Context context, INetd netd,
+                InterfaceTracker interfaceTracker) {
+            return new BpfNetMaps(context, netd, interfaceTracker);
+        }
+
+        /**
+         * Get the InterfaceTracker implementation to use in ConnectivityService.
+         * @return InterfaceTracker implementation.
+         */
+        public InterfaceTracker getInterfaceTracker(Context context) {
+            return new InterfaceTracker(context);
         }
 
         /**
@@ -1791,12 +1830,13 @@
     public ConnectivityService(Context context) {
         this(context, getDnsResolver(context), new IpConnectivityLog(),
                 INetd.Stub.asInterface((IBinder) context.getSystemService(Context.NETD_SERVICE)),
-                new Dependencies());
+                new Dependencies(), new PermissionMonitor.Dependencies());
     }
 
     @VisibleForTesting
     protected ConnectivityService(Context context, IDnsResolver dnsresolver,
-            IpConnectivityLog logger, INetd netd, Dependencies deps) {
+            IpConnectivityLog logger, INetd netd, Dependencies deps,
+            PermissionMonitor.Dependencies mPermDeps) {
         if (DBG) log("ConnectivityService starting up");
 
         mDeps = Objects.requireNonNull(deps, "missing Dependencies");
@@ -1867,10 +1907,13 @@
         mWakeUpMask = mask;
 
         mNetd = netd;
-        mBpfNetMaps = mDeps.getBpfNetMaps(mContext, netd);
+        mInterfaceTracker = mDeps.getInterfaceTracker(mContext);
+        mBpfNetMaps = mDeps.getBpfNetMaps(mContext, netd, mInterfaceTracker);
         mHandlerThread = mDeps.makeHandlerThread("ConnectivityServiceThread");
+        mPermissionMonitorDeps = mPermDeps;
         mPermissionMonitor =
-                new PermissionMonitor(mContext, mNetd, mBpfNetMaps, mHandlerThread);
+                new PermissionMonitor(mContext, mNetd, mBpfNetMaps, mPermissionMonitorDeps,
+                        mHandlerThread);
         mHandlerThread.start();
         mHandler = new InternalHandler(mHandlerThread.getLooper());
         mTrackerHandler = new NetworkStateTrackerHandler(mHandlerThread.getLooper());
@@ -1892,11 +1935,15 @@
                 && mDeps.isFeatureEnabled(context, REQUEST_RESTRICTED_WIFI);
         mBackgroundFirewallChainEnabled = mDeps.isAtLeastV() && mDeps.isFeatureNotChickenedOut(
                 context, ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN);
-        mUseDeclaredMethodsForCallbacksEnabled = mDeps.isFeatureEnabled(context,
-                ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS);
+        mUseDeclaredMethodsForCallbacksEnabled =
+                mDeps.isFeatureNotChickenedOut(context,
+                        ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS);
+        mQueueNetworkAgentEventsInSystemServer = mDeps.isAtLeastB()
+                && mDeps.isFeatureNotChickenedOut(context,
+                        ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER);
         // registerUidFrozenStateChangedCallback is only available on U+
         mQueueCallbacksForFrozenApps = mDeps.isAtLeastU()
-                && mDeps.isFeatureEnabled(context, QUEUE_CALLBACKS_FOR_FROZEN_APPS);
+                && mDeps.isFeatureNotChickenedOut(context, QUEUE_CALLBACKS_FOR_FROZEN_APPS);
         mCarrierPrivilegeAuthenticator = mDeps.makeCarrierPrivilegeAuthenticator(
                 mContext, mTelephonyManager, mRequestRestrictedWifiEnabled,
                 this::handleUidCarrierPrivilegesLost, mHandler);
@@ -2084,6 +2131,8 @@
         }
         mIngressToVpnAddressFiltering = mDeps.isAtLeastT()
                 && mDeps.isFeatureNotChickenedOut(mContext, INGRESS_TO_VPN_ADDRESS_FILTERING);
+
+        mL2capNetworkProvider = mDeps.makeL2capNetworkProvider(mContext);
     }
 
     /**
@@ -3142,6 +3191,11 @@
     }
 
     private void maybeDisableLocalNetworkMatching(NetworkCapabilities nc, int callingUid) {
+        // If disabled, NetworkRequest cannot match non-thread local networks even if
+        // specified explicitly. Compat change is enabled by default on apps targeting B+.
+        // Agent should not be visible on U- even if it's rolled out.
+        nc.setMatchNonThreadLocalNetworks(mDeps.isAtLeastV() && mDeps.isChangeEnabled(
+                ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS, callingUid));
         if (mDeps.isChangeEnabled(ENABLE_MATCH_LOCAL_NETWORK, callingUid)) {
             return;
         }
@@ -4119,6 +4173,10 @@
             mCarrierPrivilegeAuthenticator.start();
         }
 
+        if (mL2capNetworkProvider != null) {
+            mL2capNetworkProvider.start();
+        }
+
         // On T+ devices, register callback for statsd to pull NETWORK_BPF_MAP_INFO atom
         if (mDeps.isAtLeastT()) {
             mBpfNetMaps.setPullAtomCallback(mContext);
@@ -4648,15 +4706,35 @@
         private void maybeHandleNetworkAgentMessage(Message msg) {
             final Pair<NetworkAgentInfo, Object> arg = (Pair<NetworkAgentInfo, Object>) msg.obj;
             final NetworkAgentInfo nai = arg.first;
-            if (!mNetworkAgentInfos.contains(nai)) {
-                if (VDBG) {
-                    log(String.format("%s from unknown NetworkAgent", eventName(msg.what)));
+
+            // If the network has been destroyed, the only thing that it can do is disconnect.
+            if (nai.isDestroyed() && !isDisconnectRequest(msg)) {
+                if (DBG) {
+                    log("Message " + eventName(msg.what) + " from destroyed agent with netId "
+                            + nai.network.netId);
                 }
                 return;
             }
 
-            // If the network has been destroyed, the only thing that it can do is disconnect.
-            if (nai.isDestroyed() && !isDisconnectRequest(msg)) {
+            if (mQueueNetworkAgentEventsInSystemServer && nai.maybeEnqueueMessage(msg)) {
+                // If the message is enqueued, the NAI will replay it immediately
+                // when registration is complete. It does this by sending all the
+                // messages in the order received immediately after the
+                // EVENT_AGENT_REGISTERED message.
+                if (DBG) {
+                    log("Message " + eventName(msg.what) + " enqueued for agent with netId "
+                            + nai.network.netId);
+                }
+                return;
+            }
+
+            // If the nai has been registered (and doesn't enqueue), it should now be
+            // in the list of NAIs.
+            if (!mNetworkAgentInfos.contains(nai)) {
+                // TODO : this is supposed to be impossible
+                if (VDBG) {
+                    log(String.format("%s from unknown NetworkAgent", eventName(msg.what)));
+                }
                 return;
             }
 
@@ -4827,7 +4905,11 @@
                     // the destroyed flag is only just above the "current satisfier wins"
                     // tie-breaker. But technically anything that affects scoring should rematch.
                     rematchAllNetworksAndRequests();
-                    mHandler.postDelayed(() -> nai.disconnect(), timeoutMs);
+                    if (mQueueNetworkAgentEventsInSystemServer) {
+                        mHandler.postDelayed(() -> disconnectAndDestroyNetwork(nai), timeoutMs);
+                    } else {
+                        mHandler.postDelayed(() -> nai.disconnect(), timeoutMs);
+                    }
                     break;
                 }
             }
@@ -5257,12 +5339,12 @@
     private void handlePrivateDnsSettingsChanged() {
         final PrivateDnsConfig cfg = mDnsManager.getPrivateDnsConfig();
 
-        for (NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             handlePerNetworkPrivateDnsConfig(nai, cfg);
             if (networkRequiresPrivateDnsValidation(nai)) {
                 handleUpdateLinkProperties(nai, new LinkProperties(nai.linkProperties));
             }
-        }
+        });
     }
 
     private void handlePerNetworkPrivateDnsConfig(NetworkAgentInfo nai, PrivateDnsConfig cfg) {
@@ -5377,26 +5459,29 @@
     }
 
     @VisibleForTesting
-    protected static boolean shouldCreateNetworksImmediately() {
+    protected boolean shouldCreateNetworksImmediately(@NonNull NetworkCapabilities caps) {
         // The feature of creating the networks immediately was slated for U, but race conditions
         // detected late required this was flagged off.
-        // TODO : enable this in a Mainline update or in V, and re-enable the test for this
-        // in NetworkAgentTest.
-        return false;
+        // TODO : remove when it's determined that the code is stable
+        return mQueueNetworkAgentEventsInSystemServer
+                // Local network agents for Thread used to not create networks immediately,
+                // but other local agents (tethering, P2P) require this to function.
+                || (caps.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                && !caps.hasTransport(TRANSPORT_THREAD));
     }
 
-    private static boolean shouldCreateNativeNetwork(@NonNull NetworkAgentInfo nai,
+    private boolean shouldCreateNativeNetwork(@NonNull NetworkAgentInfo nai,
             @NonNull NetworkInfo.State state) {
         if (nai.isCreated()) return false;
         if (state == NetworkInfo.State.CONNECTED) return true;
         if (state != NetworkInfo.State.CONNECTING) {
             // TODO: throw if no WTFs are observed in the field.
-            if (shouldCreateNetworksImmediately()) {
+            if (shouldCreateNetworksImmediately(nai.getCapsNoCopy())) {
                 Log.wtf(TAG, "Uncreated network in invalid state: " + state);
             }
             return false;
         }
-        return nai.isVPN() || shouldCreateNetworksImmediately();
+        return nai.isVPN() || shouldCreateNetworksImmediately(nai.getCapsNoCopy());
     }
 
     private static boolean shouldDestroyNativeNetwork(@NonNull NetworkAgentInfo nai) {
@@ -5442,6 +5527,11 @@
         if (DBG) {
             log(nai.toShortString() + " disconnected, was satisfying " + nai.numNetworkRequests());
         }
+
+        if (mQueueNetworkAgentEventsInSystemServer) {
+            nai.disconnect();
+        }
+
         // Clear all notifications of this network.
         mNotifier.clearNotification(nai.network.getNetId());
         // A network agent has disconnected.
@@ -5585,16 +5675,16 @@
     private void maybeDisableForwardRulesForDisconnectingNai(
             @NonNull final NetworkAgentInfo disconnecting, final boolean sendCallbacks) {
         // Step 1 : maybe this network was the upstream for one or more local networks.
-        for (final NetworkAgentInfo local : mNetworkAgentInfos) {
-            if (!local.isLocalNetwork()) continue;
+        forEachNetworkAgentInfo(local -> {
+            if (!local.isLocalNetwork()) return; // return@forEach
             final NetworkRequest selector = local.localNetworkConfig.getUpstreamSelector();
-            if (null == selector) continue;
+            if (null == selector) return; // return@forEach
             final NetworkRequestInfo nri = mNetworkRequests.get(selector);
             // null == nri can happen while disconnecting a network, because destroyNetwork() is
             // called after removing all associated NRIs from mNetworkRequests.
-            if (null == nri) continue;
+            if (null == nri) return; // return@forEach
             final NetworkAgentInfo satisfier = nri.getSatisfier();
-            if (disconnecting != satisfier) continue;
+            if (disconnecting != satisfier) return; // return@forEach
             removeLocalNetworkUpstream(local, disconnecting);
             // Set the satisfier to null immediately so that the LOCAL_NETWORK_CHANGED callback
             // correctly contains null as an upstream.
@@ -5602,7 +5692,7 @@
                 nri.setSatisfier(null, null);
                 notifyNetworkCallbacks(local, CALLBACK_LOCAL_NETWORK_INFO_CHANGED);
             }
-        }
+        });
 
         // Step 2 : maybe this is a local network that had an upstream.
         if (!disconnecting.isLocalNetwork()) return;
@@ -5676,6 +5766,7 @@
         // destroyed pending replacement they will be sent when it is disconnected.
         maybeDisableForwardRulesForDisconnectingNai(nai, false /* sendCallbacks */);
         updateIngressToVpnAddressFiltering(null, nai.linkProperties, nai);
+        updateLocalNetworkAddresses(null, nai.linkProperties);
         try {
             mNetd.networkDestroy(nai.network.getNetId());
         } catch (RemoteException | ServiceSpecificException e) {
@@ -5774,12 +5865,12 @@
                 mNetworkRequests.put(req, nri);
                 // TODO: Consider update signal strength for other types.
                 if (req.isListen()) {
-                    for (final NetworkAgentInfo network : mNetworkAgentInfos) {
+                    forEachNetworkAgentInfo(network -> {
                         if (req.networkCapabilities.hasSignalStrength()
                                 && network.satisfiesImmutableCapabilitiesOf(req)) {
                             updateSignalStrengthThresholds(network, "REGISTER", req);
                         }
-                    }
+                    });
                 } else if (req.isRequest() && mNetworkRequestStateStatsMetrics != null) {
                     mNetworkRequestStateStatsMetrics.onNetworkRequestReceived(req);
                 }
@@ -5794,7 +5885,7 @@
             }
 
             if (shouldTrackUidsForBlockedStatusCallbacks()
-                    && isAppRequest(nri)
+                    && nri.mMessenger != null
                     && !nri.mUidTrackedForBlockedStatus) {
                 Log.wtf(TAG, "Registered nri is not tracked for sending blocked status: " + nri);
             }
@@ -6074,13 +6165,13 @@
     private void removeListenRequestFromNetworks(@NonNull final NetworkRequest req) {
         // listens don't have a singular affected Network. Check all networks to see
         // if this listen request applies and remove it.
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             nai.removeRequest(req.requestId);
             if (req.networkCapabilities.hasSignalStrength()
                     && nai.satisfiesImmutableCapabilitiesOf(req)) {
                 updateSignalStrengthThresholds(nai, "RELEASE", req);
             }
-        }
+        });
     }
 
     /**
@@ -6143,6 +6234,43 @@
         }
     }
 
+    /**
+     * Perform the specified operation on all networks.
+     *
+     * This method will run |op| exactly once for each network currently registered at the
+     * time it is called, even if |op| adds or removes networks.
+     *
+     * @param op the operation to perform. The operation is allowed to disconnect any number of
+     *           networks.
+     */
+    private void forEachNetworkAgentInfo(final Consumer<NetworkAgentInfo> op) {
+        // Create a copy instead of iterating over the set so |op| is allowed to disconnect any
+        // number of networks (which removes it from mNetworkAgentInfos). The copy is cheap
+        // because there are at most a handful of NetworkAgents connected at any given time.
+        final NetworkAgentInfo[] nais = new NetworkAgentInfo[mNetworkAgentInfos.size()];
+        mNetworkAgentInfos.toArray(nais);
+        for (NetworkAgentInfo nai : nais) {
+            op.accept(nai);
+        }
+    }
+
+    /**
+     * Check whether the specified condition is true for any network.
+     *
+     * This method will stop evaluating as soon as the condition returns true for any network.
+     * The order of iteration is not contractual.
+     *
+     * @param condition the condition to verify. This method must not modify the set of networks in
+     *                  any way.
+     * @return whether {@code condition} returned true for any network
+     */
+    private boolean anyNetworkAgentInfo(final Predicate<NetworkAgentInfo> condition) {
+        for (int i = mNetworkAgentInfos.size() - 1; i >= 0; i--) {
+            if (condition.test(mNetworkAgentInfos.valueAt(i))) return true;
+        }
+        return false;
+    }
+
     private RequestInfoPerUidCounter getRequestCounter(NetworkRequestInfo nri) {
         return hasAnyPermissionOf(mContext,
                 nri.mPid, nri.mUid, NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
@@ -6340,8 +6468,20 @@
         }
     }
 
-    private class CaptivePortalImpl extends ICaptivePortal.Stub {
+    public class CaptivePortalImpl extends ICaptivePortal.Stub implements IBinder.DeathRecipient {
         private final Network mNetwork;
+        // Binder object to track the lifetime of the setDelegateUid caller for cleanup purposes.
+        //
+        // Note that in theory it can happen that there are multiple callers for a given
+        // object. For example, the app that receives the CaptivePortal object from the Intent
+        // fired by startCaptivePortalAppInternal could send the object to another process, or
+        // clone it. Only the first of these objects that calls setDelegateUid will properly
+        // register a death recipient. Calls from the other objects will work, but only the
+        // first object's death will cause the death recipient to fire.
+        // TODO: track all callers by callerBinder instead of CaptivePortalImpl, store callerBinder
+        // in a Set. When the death recipient fires, we can remove the callingBinder from the set,
+        // and when the set is empty, we can clear the delegated UID.
+        private IBinder mDelegateUidCaller;
 
         private CaptivePortalImpl(Network network) {
             mNetwork = network;
@@ -6381,6 +6521,55 @@
             }
         }
 
+        private int handleSetDelegateUid(int uid, @NonNull final IBinder callerBinder) {
+            if (mDelegateUidCaller == null) {
+                mDelegateUidCaller = callerBinder;
+                try {
+                    // While technically unnecessary, it is safe to register a DeathRecipient for
+                    // a cleanup operation (where uid = INVALID_UID).
+                    mDelegateUidCaller.linkToDeath(this, 0);
+                } catch (RemoteException e) {
+                    // remote has died, return early.
+                    return ENOTCONN;
+                }
+            }
+
+            final NetworkAgentInfo nai = getNetworkAgentInfoForNetwork(mNetwork);
+            if (nai == null) return ENOENT; // network does not exist anymore.
+            if (nai.isDestroyed()) return ENOENT; // network has already been destroyed.
+
+            // TODO: consider allowing the uid to bypass VPN on all networks before V.
+            if (!mDeps.isAtLeastV()) return EOPNOTSUPP;
+
+            // Check whether there has already been a delegate UID configured, if so, perform
+            // cleanup and disallow bypassing VPN for that UID if no other caller is delegating
+            // this UID.
+            // TODO: consider using exceptions instead of errnos.
+            final int errno = nai.removeCaptivePortalDelegateUid(this);
+            if (errno != 0) return errno;
+
+            // If uid == INVALID_UID, we are done.
+            if (uid == INVALID_UID) return 0;
+            return nai.setCaptivePortalDelegateUid(this, uid);
+        }
+
+        @Override
+        public void setDelegateUid(int uid, @NonNull final IBinder callerBinder,
+                @NonNull final IIntResultListener listener) {
+            Objects.requireNonNull(callerBinder);
+            Objects.requireNonNull(listener);
+            enforceAnyPermissionOf(mContext, NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK);
+
+            mHandler.post(() -> {
+                final int errno = handleSetDelegateUid(uid, callerBinder);
+                try {
+                    listener.onResult(errno);
+                } catch (RemoteException e) {
+                    // remote has died, nothing to do.
+                }
+            });
+        }
+
         @Nullable
         private NetworkMonitorManager getNetworkMonitorManager(final Network network) {
             // getNetworkAgentInfoForNetwork is thread-safe
@@ -6390,6 +6579,13 @@
             // nai.networkMonitor() is thread-safe
             return nai.networkMonitor();
         }
+
+        @Override
+        public void binderDied() {
+            // Cleanup invalid UID and restore the VPN bypass rule. Because mDelegateUidCaller is
+            // never reset, it cannot be null in this context.
+            mHandler.post(() -> handleSetDelegateUid(INVALID_UID, mDelegateUidCaller));
+        }
     }
 
     public boolean avoidBadWifi() {
@@ -6416,14 +6612,14 @@
         ensureRunningOnConnectivityServiceThread();
         // Agent info scores and offer scores depend on whether cells yields to bad wifi.
         final boolean avoidBadWifi = avoidBadWifi();
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             nai.updateScoreForNetworkAgentUpdate();
             if (avoidBadWifi) {
                 // If the device is now avoiding bad wifi, remove notifications that might have
                 // been put up when the device didn't.
                 mNotifier.clearNotification(nai.network.getNetId(), NotificationType.LOST_INTERNET);
             }
-        }
+        });
         // UpdateOfferScore will update mNetworkOffers inline, so make a copy first.
         final ArrayList<NetworkOfferInfo> offersToUpdate = new ArrayList<>(mNetworkOffers);
         for (final NetworkOfferInfo noi : offersToUpdate) {
@@ -6691,7 +6887,7 @@
                     final NetworkOfferInfo offer =
                             findNetworkOfferInfoByCallback((INetworkOfferCallback) msg.obj);
                     if (null != offer) {
-                        handleUnregisterNetworkOffer(offer);
+                        handleUnregisterNetworkOffer(offer, true /* releaseReservations */);
                     }
                     break;
                 }
@@ -6761,19 +6957,15 @@
 
                     final Network underpinnedNetwork = ki.getUnderpinnedNetwork();
                     final Network network = ki.getNetwork();
-                    boolean networkFound = false;
-                    boolean underpinnedNetworkFound = false;
-                    for (NetworkAgentInfo n : mNetworkAgentInfos) {
-                        if (n.network.equals(network)) networkFound = true;
-                        if (n.everConnected() && n.network.equals(underpinnedNetwork)) {
-                            underpinnedNetworkFound = true;
-                        }
-                    }
+                    final boolean networkFound =
+                            anyNetworkAgentInfo(n -> n.network.equals(network));
 
                     // If the network no longer exists, then the keepalive should have been
                     // cleaned up already. There is no point trying to resume keepalives.
                     if (!networkFound) return;
 
+                    final boolean underpinnedNetworkFound = anyNetworkAgentInfo(
+                            n -> n.everConnected() && n.network.equals(underpinnedNetwork));
                     if (underpinnedNetworkFound) {
                         mKeepaliveTracker.handleMonitorAutomaticKeepalive(ki,
                                 underpinnedNetwork.netId);
@@ -6843,7 +7035,11 @@
                     final NetworkAgentInfo nai = getNetworkAgentInfoForNetwork((Network) msg.obj);
                     if (nai == null) break;
                     nai.onPreventAutomaticReconnect();
-                    nai.disconnect();
+                    if (mQueueNetworkAgentEventsInSystemServer) {
+                        disconnectAndDestroyNetwork(nai);
+                    } else {
+                        nai.disconnect();
+                    }
                     break;
                 case EVENT_SET_VPN_NETWORK_PREFERENCE:
                     handleSetVpnNetworkPreference((VpnNetworkPreferenceInfo) msg.obj);
@@ -7233,12 +7429,12 @@
             return new UnderlyingNetworkInfo[0];
         }
         List<UnderlyingNetworkInfo> infoList = new ArrayList<>();
-        for (NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             UnderlyingNetworkInfo info = createVpnInfo(nai);
             if (info != null) {
                 infoList.add(info);
             }
-        }
+        });
         return infoList.toArray(new UnderlyingNetworkInfo[infoList.size()]);
     }
 
@@ -7316,11 +7512,11 @@
      */
     private void propagateUnderlyingNetworkCapabilities(Network updatedNetwork) {
         ensureRunningOnConnectivityServiceThread();
-        for (NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             if (updatedNetwork == null || hasUnderlyingNetwork(nai, updatedNetwork)) {
                 updateCapabilitiesForNetwork(nai);
             }
-        }
+        });
     }
 
     private boolean isUidBlockedByVpn(int uid, List<UidRange> blockedUidRanges) {
@@ -7368,11 +7564,11 @@
             mPermissionMonitor.updateVpnLockdownUidRanges(requireVpn, ranges);
         }
 
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             final boolean curMetered = nai.networkCapabilities.isMetered();
             maybeNotifyNetworkBlocked(nai, curMetered, curMetered,
                     mVpnBlockedUidRanges, newVpnBlockedUidRanges);
-        }
+        });
 
         mVpnBlockedUidRanges = newVpnBlockedUidRanges;
     }
@@ -7385,7 +7581,7 @@
 
     private boolean isLegacyLockdownNai(NetworkAgentInfo nai) {
         return mLockdownEnabled
-                && getVpnType(nai) == VpnManager.TYPE_VPN_LEGACY
+                && isLegacyVpn(nai)
                 && nai.networkCapabilities.appliesToUid(Process.FIRST_APPLICATION_UID);
     }
 
@@ -7608,17 +7804,23 @@
         }
     }
 
-    private void ensureAllNetworkRequestsHaveType(List<NetworkRequest> requests) {
+    private void ensureAllNetworkRequestsHaveSupportedType(List<NetworkRequest> requests) {
+        final boolean isMultilayerRequest = requests.size() > 1;
         for (int i = 0; i < requests.size(); i++) {
-            ensureNetworkRequestHasType(requests.get(i));
+            ensureNetworkRequestHasSupportedType(requests.get(i), isMultilayerRequest);
         }
     }
 
-    private void ensureNetworkRequestHasType(NetworkRequest request) {
+    private void ensureNetworkRequestHasSupportedType(NetworkRequest request,
+            boolean isMultilayerRequest) {
         if (request.type == NetworkRequest.Type.NONE) {
             throw new IllegalArgumentException(
                     "All NetworkRequests in ConnectivityService must have a type");
         }
+        if (isMultilayerRequest && request.type == NetworkRequest.Type.RESERVATION) {
+            throw new IllegalArgumentException(
+                    "Reservation requests are not supported in multilayer request");
+        }
     }
 
     /**
@@ -7729,6 +7931,28 @@
         }
 
         /**
+         * NetworkCapabilities that were created as part of a NetworkOffer in response to a
+         * RESERVATION request. mReservedCapabilities is null if no current offer matches the
+         * RESERVATION request or if the request is not a RESERVATION. Matching is based on
+         * reservationId.
+         */
+        @Nullable
+        private NetworkCapabilities mReservedCapabilities;
+        @Nullable
+        NetworkCapabilities getReservedCapabilities() {
+            return mReservedCapabilities;
+        }
+
+        void setReservedCapabilities(@NonNull NetworkCapabilities caps) {
+            // This function can only be called once. NetworkCapabilities are never reset as the
+            // reservation is released when the offer disappears.
+            if (mReservedCapabilities != null) {
+                logwtf("ReservedCapabilities can only be set once");
+            }
+            mReservedCapabilities = caps;
+        }
+
+        /**
          * Get the list of UIDs this nri applies to.
          */
         @NonNull
@@ -7748,7 +7972,7 @@
         NetworkRequestInfo(int asUid, @NonNull final List<NetworkRequest> r,
                 @NonNull final NetworkRequest requestForCallback, @Nullable final PendingIntent pi,
                 @Nullable String callingAttributionTag, final int preferenceOrder) {
-            ensureAllNetworkRequestsHaveType(r);
+            ensureAllNetworkRequestsHaveSupportedType(r);
             mRequests = initializeRequests(r);
             mNetworkRequestForCallback = requestForCallback;
             mPendingIntent = pi;
@@ -7782,7 +8006,7 @@
                 @NetworkCallback.Flag int callbackFlags,
                 @Nullable String callingAttributionTag, int declaredMethodsFlags) {
             super();
-            ensureAllNetworkRequestsHaveType(r);
+            ensureAllNetworkRequestsHaveSupportedType(r);
             mRequests = initializeRequests(r);
             mNetworkRequestForCallback = requestForCallback;
             mMessenger = m;
@@ -7802,7 +8026,7 @@
         NetworkRequestInfo(@NonNull final NetworkRequestInfo nri,
                 @NonNull final List<NetworkRequest> r) {
             super();
-            ensureAllNetworkRequestsHaveType(r);
+            ensureAllNetworkRequestsHaveSupportedType(r);
             mRequests = initializeRequests(r);
             mNetworkRequestForCallback = nri.getNetworkRequestForCallback();
             final NetworkAgentInfo satisfier = nri.getSatisfier();
@@ -8088,6 +8312,14 @@
             return PREFERENCE_ORDER_NONE;
         }
 
+        public int getReservationId() {
+            // RESERVATIONs cannot be used in multilayer requests.
+            if (isMultilayerRequest()) return RES_ID_UNSET;
+            final NetworkRequest req = mRequests.get(0);
+            // Non-reservation types return RES_ID_UNSET.
+            return req.networkCapabilities.getReservationId();
+        }
+
         @Override
         public void binderDied() {
             // As an immutable collection, mRequests cannot change by the time the
@@ -8139,6 +8371,7 @@
         flags = maybeAppendDeclaredMethod(flags, CALLBACK_BLK_CHANGED, "BLK", sb);
         flags = maybeAppendDeclaredMethod(flags, CALLBACK_LOCAL_NETWORK_INFO_CHANGED,
                 "LOCALINF", sb);
+        flags = maybeAppendDeclaredMethod(flags, CALLBACK_RESERVED, "RES", sb);
         if (flags != 0) {
             sb.append("|0x").append(Integer.toHexString(flags));
         }
@@ -8343,6 +8576,7 @@
                 enforceNetworkStackOrSettingsPermission();
                 // Fall-through since other checks are the same with normal requests.
             case REQUEST:
+            case RESERVATION:
                 networkCapabilities = new NetworkCapabilities(networkCapabilities);
                 enforceNetworkRequestPermissions(networkCapabilities, callingPackageName,
                         callingAttributionTag, callingUid);
@@ -8781,7 +9015,7 @@
 
     @Override
     public void releaseNetworkRequest(NetworkRequest networkRequest) {
-        ensureNetworkRequestHasType(networkRequest);
+        ensureNetworkRequestHasSupportedType(networkRequest, false /* isMultilayerRequest */);
         mHandler.sendMessage(mHandler.obtainMessage(
                 EVENT_RELEASE_NETWORK_REQUEST, mDeps.getCallingUid(), 0, networkRequest));
     }
@@ -8824,6 +9058,11 @@
         Objects.requireNonNull(score);
         Objects.requireNonNull(caps);
         Objects.requireNonNull(callback);
+        if (caps.hasTransport(TRANSPORT_TEST)) {
+            enforceAnyPermissionOf(mContext, Manifest.permission.MANAGE_TEST_NETWORKS);
+        } else {
+            enforceNetworkFactoryPermission();
+        }
         final boolean yieldToBadWiFi = caps.hasTransport(TRANSPORT_CELLULAR) && !avoidBadWifi();
         final NetworkOffer offer = new NetworkOffer(
                 FullScore.makeProspectiveScore(score, caps, yieldToBadWiFi),
@@ -8862,7 +9101,7 @@
             }
         }
         for (final NetworkOfferInfo noi : toRemove) {
-            handleUnregisterNetworkOffer(noi);
+            handleUnregisterNetworkOffer(noi, true /* releaseReservations */);
         }
         if (DBG) log("unregisterNetworkProvider for " + npi.name);
     }
@@ -8893,6 +9132,9 @@
 
     // Tracks all NetworkAgents that are currently registered.
     // NOTE: Only should be accessed on ConnectivityServiceThread, except dump().
+    // Code iterating over this set is recommended to use forAllNetworkAgentInfos(), which allows
+    // code within the loop to disconnect networks during iteration without causing null pointer or
+    // OOB exceptions.
     private final ArraySet<NetworkAgentInfo> mNetworkAgentInfos = new ArraySet<>();
 
     // UID ranges for users that are currently blocked by VPNs.
@@ -9176,7 +9418,7 @@
      * @param providerId the ID of the provider owning this NetworkAgent.
      * @return the network created for this agent.
      */
-    public Network registerNetworkAgent(INetworkAgent na,
+    public NetworkAndAgentRegistryParcelable registerNetworkAgent(INetworkAgent na,
             NetworkInfo networkInfo,
             LinkProperties linkProperties,
             NetworkCapabilities networkCapabilities,
@@ -9219,7 +9461,8 @@
         }
     }
 
-    private Network registerNetworkAgentInternal(INetworkAgent na, NetworkInfo networkInfo,
+    private NetworkAndAgentRegistryParcelable registerNetworkAgentInternal(
+            INetworkAgent na, NetworkInfo networkInfo,
             LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
             NetworkScore currentScore, NetworkAgentConfig networkAgentConfig,
             @Nullable LocalNetworkConfig localNetworkConfig, int providerId,
@@ -9248,11 +9491,15 @@
         if (DBG) log("registerNetworkAgent " + nai);
         mDeps.getNetworkStack().makeNetworkMonitor(
                 nai.network, name, new NetworkMonitorCallbacks(nai));
-        // NetworkAgentInfo registration will finish when the NetworkMonitor is created.
-        // If the network disconnects or sends any other event before that, messages are deferred by
-        // NetworkAgent until nai.connect(), which will be called when finalizing the
-        // registration.
-        return nai.network;
+        // NetworkAgentInfo registration is done, but CS will only accept messages when the
+        // NetworkMonitor is created. If the network disconnects or sends any other event
+        // before that, messages are deferred by the Tracker Handler until it is (by asking
+        // NetworkAgentInfo to do it). The window is very small unless the NetworkStack
+        // doesn't reply immediately, which would mean a broken system anyway.
+        final NetworkAndAgentRegistryParcelable result = new NetworkAndAgentRegistryParcelable();
+        result.network = nai.network;
+        result.registry = nai.getRegistry();
+        return result;
     }
 
     private void handleRegisterNetworkAgent(NetworkAgentInfo nai, INetworkMonitor networkMonitor) {
@@ -9264,8 +9511,6 @@
                 nai.getDeclaredCapabilitiesSanitized(mCarrierPrivilegeAuthenticator)));
         processLinkPropertiesFromAgent(nai, nai.linkProperties);
 
-        nai.onNetworkMonitorCreated(networkMonitor);
-
         mNetworkAgentInfos.add(nai);
         synchronized (mNetworkForNetId) {
             mNetworkForNetId.put(nai.network.getNetId(), nai);
@@ -9280,10 +9525,11 @@
         if (nai.isLocalNetwork()) {
             handleUpdateLocalNetworkConfig(nai, null /* oldConfig */, nai.localNetworkConfig);
         }
-        nai.notifyRegistered();
+        nai.notifyRegistered(networkMonitor);
         NetworkInfo networkInfo = nai.networkInfo;
         updateNetworkInfo(nai, networkInfo);
-        updateVpnUids(nai, null, nai.networkCapabilities);
+        if (nai.isVPN()) updateVpnUids(nai, null, nai.networkCapabilities);
+        nai.processEnqueuedMessages(mTrackerHandler::handleMessage);
     }
 
     private class NetworkOfferInfo implements IBinder.DeathRecipient {
@@ -9295,7 +9541,7 @@
 
         @Override
         public void binderDied() {
-            mHandler.post(() -> handleUnregisterNetworkOffer(this));
+            mHandler.post(() -> handleUnregisterNetworkOffer(this, true /* releaseReservations */));
         }
     }
 
@@ -9306,6 +9552,18 @@
         return false;
     }
 
+    @Nullable
+    private NetworkRequestInfo maybeGetNriForReservedOffer(NetworkOfferInfo noi) {
+        final int reservationId = noi.offer.caps.getReservationId();
+        if (reservationId == RES_ID_UNSET) return null; // not a reserved offer.
+
+        for (NetworkRequestInfo nri : mNetworkRequests.values()) {
+            if (reservationId == nri.getReservationId()) return nri;
+        }
+        // The reservation was withdrawn or the reserving process died.
+        return null;
+    }
+
     /**
      * Register or update a network offer.
      * @param newOffer The new offer. If the callback member is the same as an existing
@@ -9322,19 +9580,62 @@
             return;
         }
         final NetworkOfferInfo existingOffer = findNetworkOfferInfoByCallback(newOffer.callback);
+
+        // If a reserved offer is updated, ensure the capabilities are not changed. This ensures
+        // that the reserved offer's capabilities match the ones passed by the onReserved callback,
+        // which is sent only once.
+        //
+        // TODO: consider letting the provider change the capabilities of an offer as long as they
+        // continue to satisfy the capabilities that were passed to onReserved. This is not needed
+        // today, but it shouldn't violate the API contract:
+        // - NetworkOffer capabilities are not promises
+        // - The app making a reservation must never assume that the capabilities of the reserved
+        // network are equal to the ones that were passed to onReserved. There will almost always be
+        // other capabilities, for example, those that change at runtime such as VALIDATED or
+        // NOT_SUSPENDED.
+        if (null != existingOffer
+                && existingOffer.offer.caps.getReservationId() != RES_ID_UNSET
+                && existingOffer.offer.caps.getReservationId() != RES_ID_MATCH_ALL_RESERVATIONS
+                && !newOffer.caps.equals(existingOffer.offer.caps)) {
+            // Reserved offers are not allowed to update their NetworkCapabilities.
+            // Doing so will immediately remove the offer from CS and send onUnavailable to the app.
+            handleUnregisterNetworkOffer(existingOffer, true /* releaseReservations */);
+            existingOffer.offer.notifyUnneeded();
+            logwtf("Reserved offers must never update their reserved NetworkCapabilities");
+            return;
+        }
+
+        final NetworkOfferInfo noi = new NetworkOfferInfo(newOffer);
         if (null != existingOffer) {
-            handleUnregisterNetworkOffer(existingOffer);
+            // Do not send onUnavailable for a reserved offer when updating it.
+            handleUnregisterNetworkOffer(existingOffer, false /* releaseReservations */);
             newOffer.migrateFrom(existingOffer.offer);
             if (DBG) {
                 // handleUnregisterNetworkOffer has already logged the old offer
                 log("update offer from providerId " + newOffer.providerId + " new : " + newOffer);
             }
         } else {
+            final NetworkRequestInfo reservationNri = maybeGetNriForReservedOffer(noi);
+            if (reservationNri != null) {
+                // A NetworkRequest is only allowed to trigger a single reserved offer (and
+                // onReserved() callback). All subsequent offers are ignored. This either indicates
+                // a bug in the provider (e.g., responding twice to the same reservation, or
+                // updating the capabilities of a reserved offer), or multiple providers responding
+                // to the same offer (which could happen, but is not useful to the requesting app).
+                if (reservationNri.getReservedCapabilities() != null) {
+                    loge("A reservation can only trigger a single offer; new offer is ignored.");
+                    return;
+                }
+                // Always update the reserved offer before calling callCallbackForRequest.
+                reservationNri.setReservedCapabilities(noi.offer.caps);
+                callCallbackForRequest(
+                        reservationNri, null /*networkAgent*/, CALLBACK_RESERVED, 0 /*arg1*/);
+            }
             if (DBG) {
                 log("register offer from providerId " + newOffer.providerId + " : " + newOffer);
             }
         }
-        final NetworkOfferInfo noi = new NetworkOfferInfo(newOffer);
+
         try {
             noi.offer.callback.asBinder().linkToDeath(noi, 0 /* flags */);
         } catch (RemoteException e) {
@@ -9345,7 +9646,8 @@
         issueNetworkNeeds(noi);
     }
 
-    private void handleUnregisterNetworkOffer(@NonNull final NetworkOfferInfo noi) {
+    private void handleUnregisterNetworkOffer(@NonNull final NetworkOfferInfo noi,
+                    boolean releaseReservations) {
         ensureRunningOnConnectivityServiceThread();
         if (DBG) {
             log("unregister offer from providerId " + noi.offer.providerId + " : " + noi.offer);
@@ -9355,6 +9657,18 @@
         // function may be called twice in a row, but the array will no longer contain
         // the offer.
         if (!mNetworkOffers.remove(noi)) return;
+
+        // If the offer was brought up as a result of a reservation, inform the RESERVATION request
+        // that it has disappeared. There is no need to reset nri.mReservedCapabilities to null, as
+        // CALLBACK_UNAVAIL will cause the request to be torn down. In addition, leaving
+        // nri.mReservedOffer set prevents an additional onReserved() callback in
+        // handleRegisterNetworkOffer() in the case of a migration (which would be ignored as it
+        // follows an onUnavailable).
+        final NetworkRequestInfo nri = maybeGetNriForReservedOffer(noi);
+        if (releaseReservations && nri != null) {
+            handleRemoveNetworkRequest(nri);
+            callCallbackForRequest(nri, null /* networkAgent */, CALLBACK_UNAVAIL, 0 /* arg1 */);
+        }
         noi.offer.callback.asBinder().unlinkToDeath(noi, 0 /* flags */);
     }
 
@@ -9559,16 +9873,23 @@
                     wakeupModifyInterface(iface, nai, true);
                     mDeps.reportNetworkInterfaceForTransports(mContext, iface,
                             nai.networkCapabilities.getTransportTypes());
+                    mInterfaceTracker.addInterface(iface);
                 } catch (Exception e) {
                     logw("Exception adding interface: " + e);
                 }
             }
         }
+
+        // The local network addresses needs to be updated before interfaces are removed because
+        // modifying bpf map local_net_access requires mapping interface name to index.
+        updateLocalNetworkAddresses(newLp, oldLp);
+
         for (final String iface : interfaceDiff.removed) {
             try {
                 if (DBG) log("Removing iface " + iface + " from network " + netId);
                 wakeupModifyInterface(iface, nai, false);
                 mRoutingCoordinatorService.removeInterfaceFromNetwork(netId, iface);
+                mInterfaceTracker.removeInterface(iface);
             } catch (Exception e) {
                 loge("Exception removing interface: " + e);
             }
@@ -9576,6 +9897,213 @@
     }
 
     /**
+     * Update Local Network Addresses to LocalNetAccess BPF map.
+     * @param newLp new link properties
+     * @param oldLp old link properties
+     */
+    private void updateLocalNetworkAddresses(@Nullable final LinkProperties newLp,
+            @NonNull final LinkProperties oldLp) {
+
+        // The maps are available only after 25Q2 release
+        if (!BpfNetMaps.isAtLeast25Q2()) {
+            return;
+        }
+
+        final CompareResult<String> interfaceDiff = new CompareResult<>(
+                oldLp != null ? oldLp.getAllInterfaceNames() : null,
+                newLp != null ? newLp.getAllInterfaceNames() : null);
+
+        for (final String iface : interfaceDiff.added) {
+            addLocalAddressesToBpfMap(iface, MULTICAST_AND_BROADCAST_PREFIXES, newLp);
+        }
+        for (final String iface : interfaceDiff.removed) {
+            removeLocalAddressesFromBpfMap(iface, MULTICAST_AND_BROADCAST_PREFIXES, oldLp);
+        }
+
+        // The both list contain current link properties + stacked links for new and old LP.
+        final List<LinkProperties> newLinkProperties = new ArrayList<>();
+        final List<LinkProperties> oldLinkProperties = new ArrayList<>();
+
+        if (newLp != null) {
+            newLinkProperties.add(newLp);
+            newLinkProperties.addAll(newLp.getStackedLinks());
+        }
+        if (oldLp != null) {
+            oldLinkProperties.add(oldLp);
+            oldLinkProperties.addAll(oldLp.getStackedLinks());
+        }
+
+        // map contains interface name to list of local network prefixes added because of change
+        // in link properties
+        final Map<String, List<IpPrefix>> prefixesAddedForInterface = new ArrayMap<>();
+
+        final CompareResult<LinkProperties> linkPropertiesDiff = new CompareResult<>(
+                oldLinkProperties, newLinkProperties);
+
+        for (LinkProperties linkProperty : linkPropertiesDiff.added) {
+            final List<IpPrefix> unicastLocalPrefixesToBeAdded = new ArrayList<>();
+            for (LinkAddress linkAddress : linkProperty.getLinkAddresses()) {
+                unicastLocalPrefixesToBeAdded.addAll(
+                        getLocalNetworkPrefixesForAddress(linkAddress));
+            }
+            addLocalAddressesToBpfMap(linkProperty.getInterfaceName(),
+                    unicastLocalPrefixesToBeAdded, linkProperty);
+
+            // populating interface name -> ip prefixes which were added to local_net_access map.
+            if (!prefixesAddedForInterface.containsKey(linkProperty.getInterfaceName())) {
+                prefixesAddedForInterface.put(linkProperty.getInterfaceName(), new ArrayList<>());
+            }
+            prefixesAddedForInterface.get(linkProperty.getInterfaceName())
+                    .addAll(unicastLocalPrefixesToBeAdded);
+        }
+
+        for (LinkProperties linkProperty : linkPropertiesDiff.removed) {
+            final List<IpPrefix> unicastLocalPrefixesToBeRemoved = new ArrayList<>();
+            final List<IpPrefix> unicastLocalPrefixesAdded = prefixesAddedForInterface.getOrDefault(
+                    linkProperty.getInterfaceName(), Collections.emptyList());
+
+            for (LinkAddress linkAddress : linkProperty.getLinkAddresses()) {
+                unicastLocalPrefixesToBeRemoved.addAll(
+                        getLocalNetworkPrefixesForAddress(linkAddress));
+            }
+
+            // This is to ensure if 10.0.10.0/24 was added and 10.0.11.0/24 was removed both will
+            // still populate the same prefix of 10.0.0.0/8, which mean 10.0.0.0/8 should not be
+            // removed due to removal of 10.0.11.0/24
+            unicastLocalPrefixesToBeRemoved.removeAll(unicastLocalPrefixesAdded);
+
+            removeLocalAddressesFromBpfMap(linkProperty.getInterfaceName(),
+                    new ArrayList<>(unicastLocalPrefixesToBeRemoved), linkProperty);
+        }
+    }
+
+    /**
+     * Filters IpPrefix that are local prefixes and LinkAddress is part of them.
+     * @param linkAddress link address used for filtering
+     * @return list of IpPrefix that are local addresses.
+     */
+    private List<IpPrefix> getLocalNetworkPrefixesForAddress(LinkAddress linkAddress) {
+        List<IpPrefix> localPrefixes = new ArrayList<>();
+        if (linkAddress.isIpv6()) {
+            // For IPv6, if the prefix length is greater than zero then they are part of local
+            // network
+            if (linkAddress.getPrefixLength() != 0) {
+                localPrefixes.add(
+                        new IpPrefix(linkAddress.getAddress(), linkAddress.getPrefixLength()));
+            }
+        } else {
+            // For IPv4, if the linkAddress is part of IpPrefix adding prefix to result.
+            for (IpPrefix ipv4LocalPrefix : IPV4_LOCAL_PREFIXES) {
+                if (ipv4LocalPrefix.containsPrefix(
+                        new IpPrefix(linkAddress.getAddress(), linkAddress.getPrefixLength()))) {
+                    localPrefixes.add(ipv4LocalPrefix);
+                }
+            }
+        }
+        return localPrefixes;
+    }
+
+    /**
+     * Adds list of prefixes(addresses) to local network access map.
+     * @param iface interface name
+     * @param prefixes list of prefixes/addresses
+     * @param lp LinkProperties
+     */
+    private void addLocalAddressesToBpfMap(final String iface, final List<IpPrefix> prefixes,
+                                           @Nullable final LinkProperties lp) {
+        if (!BpfNetMaps.isAtLeast25Q2()) return;
+
+        for (IpPrefix prefix : prefixes) {
+            // Add local dnses allow rule To BpfMap before adding the block rule for prefix
+            addLocalDnsesToBpfMap(iface, prefix, lp);
+            /*
+            Prefix length is used by LPM trie map(local_net_access_map) for performing longest
+            prefix matching, this length represents the maximum number of bits used for matching.
+            The interface index should always be matched which is 32-bit integer. For IPv6, prefix
+            length is calculated by adding the ip address prefix length along with interface index
+            making it (32 + length). IPv4 addresses are stored as ipv4-mapped-ipv6 which implies
+            first 96 bits are common for all ipv4 addresses. Hence, prefix length is calculated as
+            32(interface index) + 96 (common for ipv4-mapped-ipv6) + length.
+             */
+            final int prefixLengthConstant = (prefix.isIPv4() ? (32 + 96) : 32);
+            mBpfNetMaps.addLocalNetAccess(prefixLengthConstant + prefix.getPrefixLength(),
+                    iface, prefix.getAddress(), 0, 0, false);
+
+        }
+
+    }
+
+    /**
+     * Removes list of prefixes(addresses) from local network access map.
+     * @param iface interface name
+     * @param prefixes list of prefixes/addresses
+     * @param lp LinkProperties
+     */
+    private void removeLocalAddressesFromBpfMap(final String iface, final List<IpPrefix> prefixes,
+                                                @Nullable final LinkProperties lp) {
+        if (!BpfNetMaps.isAtLeast25Q2()) return;
+
+        for (IpPrefix prefix : prefixes) {
+            // The reasoning for prefix length is explained in addLocalAddressesToBpfMap()
+            final int prefixLengthConstant = (prefix.isIPv4() ? (32 + 96) : 32);
+            mBpfNetMaps.removeLocalNetAccess(prefixLengthConstant
+                    + prefix.getPrefixLength(), iface, prefix.getAddress(), 0, 0);
+
+            // Also remove the allow rule for dnses included in the prefix after removing the block
+            // rule for prefix.
+            removeLocalDnsesFromBpfMap(iface, prefix, lp);
+        }
+    }
+
+    /**
+     * Adds DNS servers to local network access map, if included in the interface prefix
+     * @param iface interface name
+     * @param prefix IpPrefix
+     * @param lp LinkProperties
+     */
+    private void addLocalDnsesToBpfMap(final String iface, IpPrefix prefix,
+            @Nullable final LinkProperties lp) {
+        if (!BpfNetMaps.isAtLeast25Q2() || lp == null) return;
+
+        for (InetAddress dnsServer : lp.getDnsServers()) {
+            // Adds dns allow rule to LocalNetAccessMap for both TCP and UDP protocol at port 53,
+            // if it is a local dns (ie. it falls in the local prefix range).
+            if (prefix.contains(dnsServer)) {
+                mBpfNetMaps.addLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_UDP, 53, true);
+                mBpfNetMaps.addLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_TCP, 53, true);
+                mBpfNetMaps.addLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_TCP, 853, true);  // DNS over TLS
+            }
+        }
+    }
+
+    /**
+     * Removes DNS servers from local network access map, if included in the interface prefix
+     * @param iface interface name
+     * @param prefix IpPrefix
+     * @param lp LinkProperties
+     */
+    private void removeLocalDnsesFromBpfMap(final String iface, IpPrefix prefix,
+            @Nullable final LinkProperties lp) {
+        if (!BpfNetMaps.isAtLeast25Q2() || lp == null) return;
+
+        for (InetAddress dnsServer : lp.getDnsServers()) {
+            // Removes dns allow rule from LocalNetAccessMap for both TCP and UDP protocol
+            // at port 53, if it is a local dns (ie. it falls in the prefix range).
+            if (prefix.contains(dnsServer)) {
+                mBpfNetMaps.removeLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_UDP, 53);
+                mBpfNetMaps.removeLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_TCP, 53);
+                mBpfNetMaps.removeLocalNetAccess(32 + 128 + 16 + 16, iface, dnsServer,
+                        IPPROTO_TCP, 853);  // DNS over TLS
+            }
+        }
+    }
+
+    /**
      * Have netd update routes from oldLp to newLp.
      * @return true if routes changed between oldLp and newLp
      */
@@ -9693,8 +10221,8 @@
      * interfaces.
      * Ingress discard rule is added to the address iff
      *   1. The address is not a link local address
-     *   2. The address is used by a single interface of VPN whose VPN type is not TYPE_VPN_LEGACY
-     *      or TYPE_VPN_OEM and the address is not used by any other interfaces even non-VPN ones
+     *   2. The address is used by a single interface of VPN whose VPN type is not LEGACY, OEM or
+     *      OEM_LEGACY and the address is not used by any other interfaces even non-VPN ones
      * Ingress discard rule is not be added to TYPE_VPN_LEGACY or TYPE_VPN_OEM VPN since these VPNs
      * might need to receive packet to VPN address via non-VPN interface.
      * This method can be called during network disconnects, when nai has already been removed from
@@ -9732,9 +10260,7 @@
         final Set<Pair<InetAddress, String>> ingressDiscardRules = new ArraySet<>();
         for (final NetworkAgentInfo agent : nais) {
             final int vpnType = getVpnType(agent);
-            if (!agent.isVPN() || agent.isDestroyed()
-                    || vpnType == VpnManager.TYPE_VPN_LEGACY
-                    || vpnType == VpnManager.TYPE_VPN_OEM) {
+            if (!agent.isVPN() || agent.isDestroyed() || !vpnSupportsInterfaceFiltering(agent)) {
                 continue;
             }
             final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
@@ -9977,7 +10503,7 @@
 
         // A NetworkAgent's allowedUids may need to be updated if the app has lost
         // carrier config
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             if (nai.networkCapabilities.getAllowedUidsNoCopy().contains(uid)
                     && getSubscriptionIdFromNetworkCaps(nai.networkCapabilities) == subId) {
                 final NetworkCapabilities nc = new NetworkCapabilities(nai.networkCapabilities);
@@ -9989,7 +10515,7 @@
                         mCarrierPrivilegeAuthenticator);
                 updateCapabilities(nai.getScore(), nai, nc);
             }
-        }
+        });
     }
 
     /**
@@ -10513,10 +11039,42 @@
         // else not handled
     }
 
+    /**
+     * A small class to manage releasing a lock exactly once even if releaseLock is called
+     * multiple times. See b/390043283
+     * PendingIntent#send throws CanceledException in various cases. In some of them it will
+     * still call onSendFinished, in others it won't and the client can't know. This class
+     * keeps a ref to the wakelock that it releases exactly once, thanks to Atomics semantics.
+     */
+    private class WakeLockOnFinishedReceiver implements PendingIntent.OnFinished {
+        private final AtomicReference<PowerManager.WakeLock> mLock;
+        WakeLockOnFinishedReceiver(@NonNull final PowerManager.WakeLock lock) {
+            mLock = new AtomicReference<>(lock);
+            lock.acquire();
+        }
+
+        public void releaseLock() {
+            final PowerManager.WakeLock lock = mLock.getAndSet(null);
+            if (null != lock) lock.release();
+        }
+
+        @Override
+        public void onSendFinished(PendingIntent pendingIntent, Intent intent, int resultCode,
+                String resultData, Bundle resultExtras) {
+            if (DBG) log("Finished sending " + pendingIntent);
+            releaseLock();
+            releasePendingNetworkRequestWithDelay(pendingIntent);
+        }
+    }
+
     // TODO(b/193460475): Remove when tooling supports SystemApi to public API.
     @SuppressLint("NewApi")
     private void sendIntent(PendingIntent pendingIntent, Intent intent) {
-        mPendingIntentWakeLock.acquire();
+        // Since the receiver will take the lock exactly once and release it exactly once, it
+        // is safe to pass the same wakelock to all receivers and avoid creating a new lock
+        // every time.
+        final WakeLockOnFinishedReceiver receiver =
+                new WakeLockOnFinishedReceiver(mPendingIntentWakeLock);
         try {
             if (DBG) log("Sending " + pendingIntent);
             final BroadcastOptions options = BroadcastOptions.makeBasic();
@@ -10525,25 +11083,14 @@
                 // utilizing the PendingIntent as a backdoor to do this.
                 options.setPendingIntentBackgroundActivityLaunchAllowed(false);
             }
-            pendingIntent.send(mContext, 0, intent, this /* onFinished */, null /* Handler */,
+            pendingIntent.send(mContext, 0, intent, receiver, null /* Handler */,
                     null /* requiredPermission */,
                     mDeps.isAtLeastT() ? options.toBundle() : null);
         } catch (PendingIntent.CanceledException e) {
             if (DBG) log(pendingIntent + " was not sent, it had been canceled.");
-            mPendingIntentWakeLock.release();
+            receiver.releaseLock();
             releasePendingNetworkRequest(pendingIntent);
         }
-        // ...otherwise, mPendingIntentWakeLock.release() gets called by onSendFinished()
-    }
-
-    @Override
-    public void onSendFinished(PendingIntent pendingIntent, Intent intent, int resultCode,
-            String resultData, Bundle resultExtras) {
-        if (DBG) log("Finished sending " + pendingIntent);
-        mPendingIntentWakeLock.release();
-        // Release with a delay so the receiving client has an opportunity to put in its
-        // own request.
-        releasePendingNetworkRequestWithDelay(pendingIntent);
     }
 
     @Nullable
@@ -10573,9 +11120,9 @@
         return bundle;
     }
 
-    // networkAgent is only allowed to be null if notificationType is
-    // CALLBACK_UNAVAIL. This is because UNAVAIL is about no network being
-    // available, while all other cases are about some particular network.
+    // networkAgent is only allowed to be null if notificationType is CALLBACK_UNAVAIL or
+    // CALLBACK_RESERVED. This is because, per definition, no network is available for UNAVAIL, and
+    // RESERVED callbacks happen when a NetworkOffer is created in response to a reservation.
     private void callCallbackForRequest(@NonNull final NetworkRequestInfo nri,
             @Nullable final NetworkAgentInfo networkAgent, final int notificationType,
             final int arg1) {
@@ -10587,6 +11134,10 @@
         }
         // Even if a callback ends up not being sent, it may affect other callbacks in the queue, so
         // queue callbacks before checking the declared methods flags.
+        // UNAVAIL and RESERVED callbacks are safe not to be queued, because RESERVED must always be
+        // the first callback. In addition, RESERVED cannot be sent more than once and is only
+        // cancelled by UNVAIL.
+        // TODO: evaluate whether it makes sense to queue RESERVED callbacks.
         if (networkAgent != null && nri.maybeQueueCallback(networkAgent, notificationType)) {
             return;
         }
@@ -10594,14 +11145,24 @@
             // No need to send the notification as the recipient method is not overridden
             return;
         }
-        final Network bundleNetwork = notificationType == CALLBACK_UNAVAIL
-                ? null
-                : networkAgent.network;
+        // networkAgent is only null for UNAVAIL and RESERVED.
+        final Network bundleNetwork = (networkAgent != null) ? networkAgent.network : null;
         final Bundle bundle = makeCommonBundleForCallback(nri, bundleNetwork);
         final boolean includeLocationSensitiveInfo =
                 (nri.mCallbackFlags & NetworkCallback.FLAG_INCLUDE_LOCATION_INFO) != 0;
         final NetworkRequest nrForCallback = nri.getNetworkRequestForCallback();
         switch (notificationType) {
+            case CALLBACK_RESERVED: {
+                final NetworkCapabilities nc =
+                        createWithLocationInfoSanitizedIfNecessaryWhenParceled(
+                                networkCapabilitiesRestrictedForCallerPermissions(
+                                        nri.getReservedCapabilities(), nri.mPid, nri.mUid),
+                                includeLocationSensitiveInfo, nri.mPid, nri.mUid,
+                                nrForCallback.getRequestorPackageName(),
+                                nri.mCallingAttributionTag);
+                putParcelable(bundle, nc);
+                break;
+            }
             case CALLBACK_AVAILABLE: {
                 final NetworkCapabilities nc =
                         createWithLocationInfoSanitizedIfNecessaryWhenParceled(
@@ -10711,7 +11272,11 @@
                 break;
             }
         }
-        nai.disconnect();
+        if (mQueueNetworkAgentEventsInSystemServer) {
+            disconnectAndDestroyNetwork(nai);
+        } else {
+            nai.disconnect();
+        }
     }
 
     private void handleLingerComplete(NetworkAgentInfo oldNetwork) {
@@ -10871,7 +11436,7 @@
             throw new IllegalStateException("No user is available");
         }
 
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             ArraySet<UidRange> allowedUidRanges = new ArraySet<>();
             for (final UserHandle user : users) {
                 final ArraySet<UidRange> restrictedUidRanges =
@@ -10883,7 +11448,7 @@
             final UidRangeParcel[] rangesParcel = toUidRangeStableParcels(allowedUidRanges);
             configs.add(new NativeUidRangeConfig(
                     nai.network.netId, rangesParcel, 0 /* subPriority */));
-        }
+        });
 
         // The netd API replaces the previous configs with the current configs.
         // Thus, for network disconnection or preference removal, no need to
@@ -11105,9 +11670,7 @@
 
         // Gather the list of all relevant agents.
         final ArrayList<NetworkAgentInfo> nais = new ArrayList<>();
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
-            nais.add(nai);
-        }
+        forEachNetworkAgentInfo(nai -> nais.add(nai));
 
         for (final NetworkRequestInfo nri : networkRequests) {
             // Non-multilayer listen requests can be ignored.
@@ -11213,14 +11776,14 @@
         // Don't send CALLBACK_LOCAL_NETWORK_INFO_CHANGED yet though : they should be sent after
         // onAvailable so clients know what network the change is about. Store such changes in
         // an array that's only allocated if necessary (because it's almost never necessary).
-        ArrayList<NetworkAgentInfo> localInfoChangedAgents = null;
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
-            if (!nai.isLocalNetwork()) continue;
+        final ArrayList<NetworkAgentInfo> localInfoChangedAgents = new ArrayList<>();
+        forEachNetworkAgentInfo(nai -> {
+            if (!nai.isLocalNetwork()) return; // return@forEach
             final NetworkRequest nr = nai.localNetworkConfig.getUpstreamSelector();
-            if (null == nr) continue; // No upstream for this local network
+            if (null == nr) return; // return@forEach, no upstream for this local network
             final NetworkRequestInfo nri = mNetworkRequests.get(nr);
             final NetworkReassignment.RequestReassignment change = changes.getReassignment(nri);
-            if (null == change) continue; // No change in upstreams for this network
+            if (null == change) return; // return@forEach, no change in upstreams for this network
             final String fromIface = nai.linkProperties.getInterfaceName();
             if (!hasSameInterfaceName(change.mOldNetwork, change.mNewNetwork)
                     || change.mOldNetwork.isDestroyed()) {
@@ -11248,9 +11811,8 @@
                     loge("Can't update forwarding rules", e);
                 }
             }
-            if (null == localInfoChangedAgents) localInfoChangedAgents = new ArrayList<>();
             localInfoChangedAgents.add(nai);
-        }
+        });
 
         // Notify requested networks are available after the default net is switched, but
         // before LegacyTypeTracker sends legacy broadcasts
@@ -11301,17 +11863,14 @@
         }
 
         // Send LOCAL_NETWORK_INFO_CHANGED callbacks now that onAvailable and onLost have been sent.
-        if (null != localInfoChangedAgents) {
-            for (final NetworkAgentInfo nai : localInfoChangedAgents) {
-                notifyNetworkCallbacks(nai,
-                        CALLBACK_LOCAL_NETWORK_INFO_CHANGED);
-            }
+        for (final NetworkAgentInfo nai : localInfoChangedAgents) {
+          notifyNetworkCallbacks(nai, CALLBACK_LOCAL_NETWORK_INFO_CHANGED);
         }
 
         updateLegacyTypeTrackerAndVpnLockdownForRematch(changes, nais);
 
         // Tear down all unneeded networks.
-        for (NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             if (unneeded(nai, UnneededFor.TEARDOWN)) {
                 if (nai.getInactivityExpiry() > 0) {
                     // This network has active linger timers and no requests, but is not
@@ -11329,7 +11888,7 @@
                     teardownUnneededNetwork(nai);
                 }
             }
-        }
+        });
     }
 
     /**
@@ -11590,7 +12149,7 @@
             // interfaces and routing rules have been added, DNS servers programmed, etc.
             // For VPNs, this must be done before the capabilities are updated, because as soon as
             // that happens, UIDs are routed to the network.
-            if (shouldCreateNetworksImmediately()) {
+            if (shouldCreateNetworksImmediately(networkAgent.getCapsNoCopy())) {
                 applyInitialLinkProperties(networkAgent);
             }
 
@@ -11615,7 +12174,7 @@
             networkAgent.getAndSetNetworkCapabilities(networkAgent.networkCapabilities);
 
             handlePerNetworkPrivateDnsConfig(networkAgent, mDnsManager.getPrivateDnsConfig());
-            if (!shouldCreateNetworksImmediately()) {
+            if (!shouldCreateNetworksImmediately(networkAgent.getCapsNoCopy())) {
                 applyInitialLinkProperties(networkAgent);
             } else {
                 // The network was created when the agent registered, and the LinkProperties are
@@ -11718,7 +12277,9 @@
             // This has to happen after matching the requests, because callbacks are just requests.
             notifyNetworkCallbacks(networkAgent, CALLBACK_PRECHECK);
         } else if (state == NetworkInfo.State.DISCONNECTED) {
-            networkAgent.disconnect();
+            if (!mQueueNetworkAgentEventsInSystemServer) {
+                networkAgent.disconnect();
+            }
             if (networkAgent.isVPN()) {
                 updateVpnUids(networkAgent, networkAgent.networkCapabilities, null);
             }
@@ -11842,7 +12403,7 @@
      * @param blockedReasons The reasons for why an uid is blocked.
      */
     private void maybeNotifyNetworkBlockedForNewState(int uid, @BlockedReason int blockedReasons) {
-        for (final NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             final boolean metered = nai.networkCapabilities.isMetered();
             final boolean vpnBlocked = isUidBlockedByVpn(uid, mVpnBlockedUidRanges);
 
@@ -11850,9 +12411,7 @@
                     uid, mUidBlockedReasons.get(uid, BLOCKED_REASON_NONE), metered, vpnBlocked);
             final int newBlockedState =
                     getBlockedState(uid, blockedReasons, metered, vpnBlocked);
-            if (oldBlockedState == newBlockedState) {
-                continue;
-            }
+            if (oldBlockedState == newBlockedState) return; // return@forEach
             for (int i = 0; i < nai.numNetworkRequests(); i++) {
                 NetworkRequest nr = nai.requestAt(i);
                 NetworkRequestInfo nri = mNetworkRequests.get(nr);
@@ -11861,7 +12420,7 @@
                             newBlockedState);
                 }
             }
-        }
+        });
     }
 
     @VisibleForTesting
@@ -11950,11 +12509,11 @@
                 activeNetIds.add(nri.getSatisfier().network().netId);
             }
         }
-        for (NetworkAgentInfo nai : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(nai -> {
             if (activeNetIds.contains(nai.network().netId) || nai.isVPN()) {
                 defaultNetworks.add(nai.network);
             }
-        }
+        });
         return defaultNetworks;
     }
 
@@ -12342,6 +12901,23 @@
         return ((VpnTransportInfo) ti).getType();
     }
 
+    private boolean isVpnServiceVpn(NetworkAgentInfo nai) {
+        final int vpnType = getVpnType(nai);
+        return vpnType == VpnManager.TYPE_VPN_SERVICE || vpnType == VpnManager.TYPE_VPN_OEM_SERVICE;
+    }
+
+    private boolean isLegacyVpn(NetworkAgentInfo nai) {
+        final int vpnType = getVpnType(nai);
+        return vpnType == VpnManager.TYPE_VPN_LEGACY || vpnType == VpnManager.TYPE_VPN_OEM_LEGACY;
+    }
+
+    private boolean vpnSupportsInterfaceFiltering(NetworkAgentInfo vpn) {
+        final int vpnType = getVpnType(vpn);
+        return vpnType != VpnManager.TYPE_VPN_LEGACY
+                && vpnType != VpnManager.TYPE_VPN_OEM
+                && vpnType != VpnManager.TYPE_VPN_OEM_LEGACY;
+    }
+
     private void maybeUpdateWifiRoamTimestamp(@NonNull NetworkAgentInfo nai,
             @NonNull NetworkCapabilities nc) {
         final TransportInfo prevInfo = nai.networkCapabilities.getTransportInfo();
@@ -12375,7 +12951,7 @@
         if (hasNetworkStackPermission()) return uid;
 
         final NetworkAgentInfo vpn = getVpnForUid(uid);
-        if (vpn == null || getVpnType(vpn) != VpnManager.TYPE_VPN_SERVICE
+        if (vpn == null || !isVpnServiceVpn(vpn)
                 || vpn.networkCapabilities.getOwnerUid() != mDeps.getCallingUid()) {
             return INVALID_UID;
         }
@@ -12828,15 +13404,10 @@
     }
 
     private boolean ownsVpnRunningOverNetwork(int uid, Network network) {
-        for (NetworkAgentInfo virtual : mNetworkAgentInfos) {
-            if (virtual.propagateUnderlyingCapabilities()
-                    && virtual.networkCapabilities.getOwnerUid() == uid
-                    && CollectionUtils.contains(virtual.declaredUnderlyingNetworks, network)) {
-                return true;
-            }
-        }
-
-        return false;
+        return anyNetworkAgentInfo(virtual ->
+                virtual.propagateUnderlyingCapabilities()
+                        && virtual.networkCapabilities.getOwnerUid() == uid
+                        && CollectionUtils.contains(virtual.declaredUnderlyingNetworks, network));
     }
 
     @CheckResult
@@ -13007,18 +13578,16 @@
         @Override
         public void onInterfaceLinkStateChanged(@NonNull String iface, boolean up) {
             mHandler.post(() -> {
-                for (NetworkAgentInfo nai : mNetworkAgentInfos) {
-                    nai.clatd.handleInterfaceLinkStateChanged(iface, up);
-                }
+                forEachNetworkAgentInfo(nai ->
+                        nai.clatd.handleInterfaceLinkStateChanged(iface, up));
             });
         }
 
         @Override
         public void onInterfaceRemoved(@NonNull String iface) {
             mHandler.post(() -> {
-                for (NetworkAgentInfo nai : mNetworkAgentInfos) {
-                    nai.clatd.handleInterfaceRemoved(iface);
-                }
+                forEachNetworkAgentInfo(nai ->
+                        nai.clatd.handleInterfaceRemoved(iface));
             });
         }
     }
@@ -13799,7 +14368,7 @@
         final long oldIngressRateLimit = mIngressRateLimit;
         mIngressRateLimit = ConnectivitySettingsManager.getIngressRateLimitInBytesPerSecond(
                 mContext);
-        for (final NetworkAgentInfo networkAgent : mNetworkAgentInfos) {
+        forEachNetworkAgentInfo(networkAgent -> {
             if (canNetworkBeRateLimited(networkAgent)) {
                 // If rate limit has previously been enabled, remove the old limit first.
                 if (oldIngressRateLimit >= 0) {
@@ -13810,7 +14379,7 @@
                             mIngressRateLimit);
                 }
             }
-        }
+        });
     }
 
     private boolean canNetworkBeRateLimited(@NonNull final NetworkAgentInfo networkAgent) {
@@ -14539,6 +15108,9 @@
         if (mUseDeclaredMethodsForCallbacksEnabled) {
             features |= ConnectivityManager.FEATURE_USE_DECLARED_METHODS_FOR_CALLBACKS;
         }
+        if (mQueueNetworkAgentEventsInSystemServer) {
+            features |= ConnectivityManager.FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER;
+        }
         return features;
     }
 
@@ -14547,6 +15119,8 @@
         switch (featureFlag) {
             case INGRESS_TO_VPN_ADDRESS_FILTERING:
                 return mIngressToVpnAddressFiltering;
+            case QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER:
+                return mQueueNetworkAgentEventsInSystemServer;
             default:
                 throw new IllegalArgumentException("Unknown flag: " + featureFlag);
         }
diff --git a/service/src/com/android/server/L2capNetworkProvider.java b/service/src/com/android/server/L2capNetworkProvider.java
new file mode 100644
index 0000000..149979f
--- /dev/null
+++ b/service/src/com/android/server/L2capNetworkProvider.java
@@ -0,0 +1,693 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server;
+
+import static android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE;
+import static android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_ANY;
+import static android.net.L2capNetworkSpecifier.ROLE_CLIENT;
+import static android.net.L2capNetworkSpecifier.ROLE_SERVER;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
+import static android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS;
+import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
+import static android.system.OsConstants.F_GETFL;
+import static android.system.OsConstants.F_SETFL;
+import static android.system.OsConstants.O_NONBLOCK;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothServerSocket;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.net.ConnectivityManager;
+import android.net.L2capNetworkSpecifier;
+import android.net.NetworkCapabilities;
+import android.net.NetworkProvider;
+import android.net.NetworkProvider.NetworkOfferCallback;
+import android.net.NetworkRequest;
+import android.net.NetworkScore;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.ParcelFileDescriptor;
+import android.system.Os;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.HandlerUtils;
+import com.android.net.module.util.ServiceConnectivityJni;
+import com.android.server.net.L2capNetwork;
+import com.android.server.net.L2capNetwork.L2capIpClient;
+import com.android.server.net.L2capPacketForwarder;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+
+public class L2capNetworkProvider {
+    private static final String TAG = L2capNetworkProvider.class.getSimpleName();
+    private static final NetworkCapabilities COMMON_CAPABILITIES =
+            // TODO: add NET_CAPABILITY_NOT_RESTRICTED and check that getRequestorUid() has
+            // BLUETOOTH_CONNECT permission.
+            NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                    .addTransportType(TRANSPORT_BLUETOOTH)
+                    .addCapability(NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED)
+                    .addCapability(NET_CAPABILITY_NOT_CONGESTED)
+                    .addCapability(NET_CAPABILITY_NOT_METERED)
+                    .addCapability(NET_CAPABILITY_NOT_ROAMING)
+                    .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+                    .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+                    .addCapability(NET_CAPABILITY_NOT_VPN)
+                    .build();
+    private final Dependencies mDeps;
+    private final Context mContext;
+    private final HandlerThread mHandlerThread;
+    private final Handler mHandler;
+    private final NetworkProvider mProvider;
+    private final BlanketReservationOffer mBlanketOffer;
+    private final Set<ReservedServerOffer> mReservedServerOffers = new ArraySet<>();
+    private final ClientOffer mClientOffer;
+    // mBluetoothManager guaranteed non-null when read on handler thread after start() is called
+    @Nullable
+    private BluetoothManager mBluetoothManager;
+
+    // Note: IFNAMSIZ is 16.
+    private static final String TUN_IFNAME = "l2cap-tun";
+    private static int sTunIndex = 0;
+
+    /**
+     * The blanket reservation offer is used to create an L2CAP server network, i.e. a network
+     * based on a BluetoothServerSocket.
+     *
+     * Note that NetworkCapabilities matching semantics will cause onNetworkNeeded to be called for
+     * requests that do not have a NetworkSpecifier set.
+     */
+    private class BlanketReservationOffer implements NetworkOfferCallback {
+        public static final NetworkScore SCORE = new NetworkScore.Builder().build();
+        // Note the missing NET_CAPABILITY_NOT_RESTRICTED marking the network as restricted.
+        public static final NetworkCapabilities CAPABILITIES;
+        static {
+            // Below capabilities will match any reservation request with an L2capNetworkSpecifier
+            // that specifies ROLE_SERVER or without a NetworkSpecifier.
+            final L2capNetworkSpecifier l2capNetworkSpecifier = new L2capNetworkSpecifier.Builder()
+                    .setRole(ROLE_SERVER)
+                    .build();
+            NetworkCapabilities caps = new NetworkCapabilities.Builder(COMMON_CAPABILITIES)
+                    .setNetworkSpecifier(l2capNetworkSpecifier)
+                    .build();
+            // TODO: add #setReservationId() to NetworkCapabilities.Builder
+            caps.setReservationId(RES_ID_MATCH_ALL_RESERVATIONS);
+            CAPABILITIES = caps;
+        }
+
+        @Override
+        public void onNetworkNeeded(NetworkRequest request) {
+            // The NetworkSpecifier is guaranteed to be either null or an L2capNetworkSpecifier, so
+            // this cast is safe.
+            final L2capNetworkSpecifier specifier =
+                    (L2capNetworkSpecifier) request.getNetworkSpecifier();
+            if (specifier == null) return;
+            if (!specifier.isValidServerReservationSpecifier()) {
+                Log.i(TAG, "Ignoring invalid reservation request: " + request);
+                return;
+            }
+
+            final ReservedServerOffer reservedOffer = createReservedServerOffer(request);
+            if (reservedOffer == null) {
+                // Something went wrong when creating the offer. Send onUnavailable() to the app.
+                Log.e(TAG, "Failed to create L2cap server offer");
+                mProvider.declareNetworkRequestUnfulfillable(request);
+                return;
+            }
+
+            final NetworkCapabilities reservedCaps = reservedOffer.getReservedCapabilities();
+            mProvider.registerNetworkOffer(SCORE, reservedCaps, mHandler::post, reservedOffer);
+            mReservedServerOffers.add(reservedOffer);
+        }
+
+        @Nullable
+        private ReservedServerOffer createReservedServerOffer(NetworkRequest reservation) {
+            final BluetoothAdapter bluetoothAdapter = mBluetoothManager.getAdapter();
+            if (bluetoothAdapter == null) {
+                Log.w(TAG, "Failed to get BluetoothAdapter");
+                return null;
+            }
+            final BluetoothServerSocket serverSocket;
+            try {
+                serverSocket = bluetoothAdapter.listenUsingInsecureL2capChannel();
+            } catch (IOException e) {
+                Log.w(TAG, "Failed to open BluetoothServerSocket");
+                return null;
+            }
+
+            // Create the reserved capabilities partially from the reservation itself (non-reserved
+            // parts of the L2capNetworkSpecifier), the COMMON_CAPABILITIES, and the reserved data
+            // (BLE L2CAP PSM from the BluetoothServerSocket).
+            final NetworkCapabilities reservationNc = reservation.networkCapabilities;
+            final L2capNetworkSpecifier reservationSpec =
+                    (L2capNetworkSpecifier) reservationNc.getNetworkSpecifier();
+            // Note: the RemoteAddress is unspecified for server networks.
+            final L2capNetworkSpecifier reservedSpec = new L2capNetworkSpecifier.Builder()
+                    .setRole(ROLE_SERVER)
+                    .setHeaderCompression(reservationSpec.getHeaderCompression())
+                    .setPsm(serverSocket.getPsm())
+                    .build();
+            NetworkCapabilities reservedNc =
+                    new NetworkCapabilities.Builder(COMMON_CAPABILITIES)
+                            .setNetworkSpecifier(reservedSpec)
+                            .build();
+            reservedNc.setReservationId(reservationNc.getReservationId());
+            return new ReservedServerOffer(reservedNc, serverSocket);
+        }
+
+        @Nullable
+        private ReservedServerOffer getReservedOfferForRequest(NetworkRequest request) {
+            final int rId = request.networkCapabilities.getReservationId();
+            for (ReservedServerOffer offer : mReservedServerOffers) {
+                // Comparing by reservationId is more explicit then using canBeSatisfiedBy() or the
+                // requestId.
+                if (offer.getReservedCapabilities().getReservationId() != rId) continue;
+                return offer;
+            }
+            return null;
+        }
+
+        @Override
+        public void onNetworkUnneeded(NetworkRequest request) {
+            final ReservedServerOffer reservedOffer = getReservedOfferForRequest(request);
+            if (reservedOffer == null) return;
+
+            // Note that the reserved offer gets torn down when the reservation goes away, even if
+            // there are active (non-reservation) requests for said offer.
+            destroyAndUnregisterReservedOffer(reservedOffer);
+        }
+    }
+
+    private void destroyAndUnregisterReservedOffer(ReservedServerOffer reservedOffer) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        // Ensure the offer still exists if this was posted on the handler.
+        if (!mReservedServerOffers.contains(reservedOffer)) return;
+        mReservedServerOffers.remove(reservedOffer);
+
+        reservedOffer.tearDown();
+        mProvider.unregisterNetworkOffer(reservedOffer);
+    }
+
+    @Nullable
+    private L2capNetwork createL2capNetwork(BluetoothSocket socket, NetworkCapabilities caps,
+            L2capNetwork.ICallback cb) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        final String ifname = TUN_IFNAME + String.valueOf(sTunIndex++);
+        final ParcelFileDescriptor tunFd = mDeps.createTunInterface(ifname);
+        if (tunFd == null) {
+            return null;
+        }
+
+        return L2capNetwork.create(
+                mHandler, mContext, mProvider, ifname, socket, tunFd, caps, mDeps, cb);
+    }
+
+    private static void closeBluetoothSocket(BluetoothSocket socket) {
+        try {
+            socket.close();
+        } catch (IOException e) {
+            Log.w(TAG, "Failed to close BluetoothSocket", e);
+        }
+    }
+
+    private class ReservedServerOffer implements NetworkOfferCallback {
+        private final NetworkCapabilities mReservedCapabilities;
+        private final AcceptThread mAcceptThread;
+        // This set should almost always contain at most one network. This is because all L2CAP
+        // server networks created by the same reserved offer are indistinguishable from each other,
+        // so that ConnectivityService will tear down all but the first. However, temporarily, there
+        // can be more than one network.
+        private final Set<L2capNetwork> mL2capNetworks = new ArraySet<>();
+
+        private class AcceptThread extends Thread {
+            private static final int TIMEOUT_MS = 500;
+            private final BluetoothServerSocket mServerSocket;
+
+            public AcceptThread(BluetoothServerSocket serverSocket) {
+                super("L2capNetworkProvider-AcceptThread");
+                mServerSocket = serverSocket;
+            }
+
+            private void postDestroyAndUnregisterReservedOffer() {
+                // Called on AcceptThread
+                mHandler.post(() -> {
+                    destroyAndUnregisterReservedOffer(ReservedServerOffer.this);
+                });
+            }
+
+            private void postCreateServerNetwork(BluetoothSocket connectedSocket) {
+                // Called on AcceptThread
+                mHandler.post(() -> {
+                    final boolean success = createServerNetwork(connectedSocket);
+                    if (!success) closeBluetoothSocket(connectedSocket);
+                });
+            }
+
+            @Override
+            public void run() {
+                while (true) {
+                    final BluetoothSocket connectedSocket;
+                    try {
+                        connectedSocket = mServerSocket.accept();
+                    } catch (IOException e) {
+                        // Note calling BluetoothServerSocket#close() also triggers an IOException
+                        // which is indistinguishable from any other exceptional behavior.
+                        // postDestroyAndUnregisterReservedOffer() is always safe to call as it
+                        // first checks whether the offer still exists; so if the
+                        // BluetoothServerSocket was closed (i.e. on tearDown()) this is a noop.
+                        Log.w(TAG, "BluetoothServerSocket closed or #accept failed", e);
+                        postDestroyAndUnregisterReservedOffer();
+                        return; // stop running immediately on error
+                    }
+                    postCreateServerNetwork(connectedSocket);
+                }
+            }
+
+            public void tearDown() {
+                HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                try {
+                    // BluetoothServerSocket.close() is thread-safe.
+                    mServerSocket.close();
+                } catch (IOException e) {
+                    Log.w(TAG, "Failed to close BluetoothServerSocket", e);
+                }
+                try {
+                    join();
+                } catch (InterruptedException e) {
+                    // join() interrupted during tearDown(). Do nothing.
+                }
+            }
+        }
+
+        private boolean createServerNetwork(BluetoothSocket socket) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+            // It is possible the offer went away.
+            if (!mReservedServerOffers.contains(this)) return false;
+
+            if (!socket.isConnected()) {
+                Log.wtf(TAG, "BluetoothSocket must be connected");
+                return false;
+            }
+
+            final L2capNetwork network = createL2capNetwork(socket, mReservedCapabilities,
+                    new L2capNetwork.ICallback() {
+                    @Override
+                    public void onError(L2capNetwork network) {
+                        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                        destroyAndUnregisterReservedOffer(ReservedServerOffer.this);
+                    }
+                    @Override
+                    public void onNetworkUnwanted(L2capNetwork network) {
+                        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                        // Leave reservation in place.
+                        final boolean networkExists = mL2capNetworks.remove(network);
+                        if (!networkExists) return; // already torn down.
+                        network.tearDown();
+                    }
+            });
+
+            if (network == null) {
+                Log.e(TAG, "Failed to create L2capNetwork");
+                return false;
+            }
+
+            mL2capNetworks.add(network);
+            return true;
+        }
+
+        public ReservedServerOffer(NetworkCapabilities reservedCapabilities,
+                BluetoothServerSocket serverSocket) {
+            mReservedCapabilities = reservedCapabilities;
+            mAcceptThread = new AcceptThread(serverSocket);
+            mAcceptThread.start();
+        }
+
+        public NetworkCapabilities getReservedCapabilities() {
+            return mReservedCapabilities;
+        }
+
+        @Override
+        public void onNetworkNeeded(NetworkRequest request) {
+            // UNUSED: the lifetime of the reserved network is controlled by the blanket offer.
+        }
+
+        @Override
+        public void onNetworkUnneeded(NetworkRequest request) {
+            // UNUSED: the lifetime of the reserved network is controlled by the blanket offer.
+        }
+
+        /** Called when the reservation goes away and the reserved offer must be torn down. */
+        public void tearDown() {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+            mAcceptThread.tearDown();
+            for (L2capNetwork network : mL2capNetworks) {
+                network.tearDown();
+            }
+        }
+    }
+
+    private class ClientOffer implements NetworkOfferCallback {
+        public static final NetworkScore SCORE = new NetworkScore.Builder().build();
+        public static final NetworkCapabilities CAPABILITIES;
+        static {
+            // Below capabilities will match any request with an L2capNetworkSpecifier
+            // that specifies ROLE_CLIENT or without a NetworkSpecifier.
+            final L2capNetworkSpecifier l2capNetworkSpecifier = new L2capNetworkSpecifier.Builder()
+                    .setRole(ROLE_CLIENT)
+                    .build();
+            CAPABILITIES = new NetworkCapabilities.Builder(COMMON_CAPABILITIES)
+                    .setNetworkSpecifier(l2capNetworkSpecifier)
+                    .build();
+        }
+
+        private final Map<L2capNetworkSpecifier, ClientRequestInfo> mClientNetworkRequests =
+                new ArrayMap<>();
+
+        /**
+         * State object to store information for client NetworkRequests.
+         */
+        private static class ClientRequestInfo {
+            public final L2capNetworkSpecifier specifier;
+            public final List<NetworkRequest> requests = new ArrayList<>();
+            // TODO: add support for retries.
+            public final ConnectThread connectThread;
+            @Nullable
+            public L2capNetwork network;
+
+            public ClientRequestInfo(NetworkRequest request, ConnectThread connectThread) {
+                this.specifier = (L2capNetworkSpecifier) request.getNetworkSpecifier();
+                this.requests.add(request);
+                this.connectThread = connectThread;
+            }
+        }
+
+        // TODO: consider using ExecutorService
+        private class ConnectThread extends Thread {
+            private final L2capNetworkSpecifier mSpecifier;
+            private final BluetoothSocket mSocket;
+
+            public ConnectThread(L2capNetworkSpecifier specifier, BluetoothSocket socket) {
+                super("L2capNetworkProvider-ConnectThread");
+                mSpecifier = specifier;
+                mSocket = socket;
+            }
+
+            @Override
+            public void run() {
+                try {
+                    mSocket.connect();
+                    mHandler.post(() -> {
+                        final boolean success = createClientNetwork(mSpecifier, mSocket);
+                        if (!success) closeBluetoothSocket(mSocket);
+                    });
+                } catch (IOException e) {
+                    Log.w(TAG, "BluetoothSocket was closed or #connect failed", e);
+                    // It is safe to call BluetoothSocket#close() multiple times.
+                    closeBluetoothSocket(mSocket);
+                    mHandler.post(() -> {
+                        // Note that if the Socket was closed, this call is a noop as the
+                        // ClientNetworkRequest has already been removed.
+                        declareAllNetworkRequestsUnfulfillable(mSpecifier);
+                    });
+                }
+            }
+
+            public void abort() {
+                HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                // Closing the BluetoothSocket is the only way to unblock connect() because it calls
+                // shutdown on the underlying (connected) SOCK_SEQPACKET.
+                // It is safe to call BluetoothSocket#close() multiple times.
+                closeBluetoothSocket(mSocket);
+                try {
+                    join();
+                } catch (InterruptedException e) {
+                    Log.i(TAG, "Interrupted while joining ConnectThread", e);
+                }
+            }
+        }
+
+        private boolean createClientNetwork(L2capNetworkSpecifier specifier,
+                BluetoothSocket socket) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+            // Check whether request still exists
+            final ClientRequestInfo cri = mClientNetworkRequests.get(specifier);
+            if (cri == null) return false;
+
+            final NetworkCapabilities caps = new NetworkCapabilities.Builder(CAPABILITIES)
+                    .setNetworkSpecifier(specifier)
+                    .build();
+
+            final L2capNetwork network = createL2capNetwork(socket, caps,
+                    new L2capNetwork.ICallback() {
+                    // TODO: do not send onUnavailable() after the network has become available. The
+                    // right thing to do here is to tearDown the network (if it still exists,
+                    // because note that the request might have already been removed in the
+                    // meantime, so `network` cannot be used directly.
+                    @Override
+                    public void onError(L2capNetwork network) {
+                        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                        declareAllNetworkRequestsUnfulfillable(specifier);
+                    }
+                    @Override
+                    public void onNetworkUnwanted(L2capNetwork network) {
+                        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+                        declareAllNetworkRequestsUnfulfillable(specifier);
+                    }
+            });
+            if (network == null) return false;
+
+            cri.network = network;
+            return true;
+        }
+
+        @Override
+        public void onNetworkNeeded(NetworkRequest request) {
+            // The NetworkSpecifier is guaranteed to be either null or an L2capNetworkSpecifier, so
+            // this cast is safe.
+            final L2capNetworkSpecifier requestSpecifier =
+                    (L2capNetworkSpecifier) request.getNetworkSpecifier();
+            if (requestSpecifier == null) return;
+            if (!requestSpecifier.isValidClientRequestSpecifier()) {
+                Log.i(TAG, "Ignoring invalid client request: " + request);
+                return;
+            }
+
+             // Check whether this exact request is already being tracked.
+            final ClientRequestInfo cri = mClientNetworkRequests.get(requestSpecifier);
+            if (cri != null) {
+                Log.d(TAG, "The request is already being tracked. NetworkRequest: " + request);
+                cri.requests.add(request);
+                return;
+            }
+
+            // Check whether a fuzzy match shows a mismatch in header compression by calling
+            // canBeSatisfiedBy().
+            // TODO: Add a copy constructor to L2capNetworkSpecifier.Builder.
+            final L2capNetworkSpecifier matchAnyHeaderCompressionSpecifier =
+                    new L2capNetworkSpecifier.Builder()
+                            .setRole(requestSpecifier.getRole())
+                            .setRemoteAddress(requestSpecifier.getRemoteAddress())
+                            .setPsm(requestSpecifier.getPsm())
+                            .setHeaderCompression(HEADER_COMPRESSION_ANY)
+                            .build();
+            for (L2capNetworkSpecifier existingSpecifier : mClientNetworkRequests.keySet()) {
+                if (existingSpecifier.canBeSatisfiedBy(matchAnyHeaderCompressionSpecifier)) {
+                    // This requeset can never be serviced as this network already exists with a
+                    // different header compression mechanism.
+                    mProvider.declareNetworkRequestUnfulfillable(request);
+                    return;
+                }
+            }
+
+            // If the code reaches here, this is a new request.
+            final BluetoothAdapter bluetoothAdapter = mBluetoothManager.getAdapter();
+            if (bluetoothAdapter == null) {
+                Log.w(TAG, "Failed to get BluetoothAdapter");
+                mProvider.declareNetworkRequestUnfulfillable(request);
+                return;
+            }
+
+            final byte[] macAddress = requestSpecifier.getRemoteAddress().toByteArray();
+            final BluetoothDevice bluetoothDevice = bluetoothAdapter.getRemoteDevice(macAddress);
+            final BluetoothSocket socket;
+            try {
+                socket = bluetoothDevice.createInsecureL2capChannel(requestSpecifier.getPsm());
+            } catch (IOException e) {
+                Log.w(TAG, "Failed to createInsecureL2capChannel", e);
+                mProvider.declareNetworkRequestUnfulfillable(request);
+                return;
+            }
+
+            final ConnectThread connectThread = new ConnectThread(requestSpecifier, socket);
+            connectThread.start();
+            final ClientRequestInfo newRequestInfo = new ClientRequestInfo(request, connectThread);
+            mClientNetworkRequests.put(requestSpecifier, newRequestInfo);
+        }
+
+        @Override
+        public void onNetworkUnneeded(NetworkRequest request) {
+            final L2capNetworkSpecifier specifier =
+                    (L2capNetworkSpecifier) request.getNetworkSpecifier();
+
+            // Map#get() is safe to call with null key
+            final ClientRequestInfo cri = mClientNetworkRequests.get(specifier);
+            if (cri == null) return;
+
+            cri.requests.remove(request);
+            if (cri.requests.size() > 0) return;
+
+            // If the code reaches here, the network needs to be torn down.
+            releaseClientNetworkRequest(cri);
+        }
+
+        /**
+         * Release the client network request and tear down all associated state.
+         *
+         * Only call this when all associated NetworkRequests have been released.
+         */
+        private void releaseClientNetworkRequest(ClientRequestInfo cri) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+            mClientNetworkRequests.remove(cri.specifier);
+            if (cri.connectThread.isAlive()) {
+                // Note that if ConnectThread succeeds between calling #isAlive() and #abort(), the
+                // request will already be removed from mClientNetworkRequests by the time the
+                // createClientNetwork() call is processed on the handler, so it is safe to call
+                // #abort().
+                cri.connectThread.abort();
+            }
+
+            if (cri.network != null) {
+                cri.network.tearDown();
+            }
+        }
+
+        private void declareAllNetworkRequestsUnfulfillable(L2capNetworkSpecifier specifier) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+            final ClientRequestInfo cri = mClientNetworkRequests.get(specifier);
+            if (cri == null) return;
+
+            // Release ClientNetworkRequest before sending onUnavailable() to ensure the app
+            // first receives an onLost() callback if a network had been created.
+            releaseClientNetworkRequest(cri);
+            for (NetworkRequest request : cri.requests) {
+                mProvider.declareNetworkRequestUnfulfillable(request);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Get the HandlerThread for L2capNetworkProvider to run on */
+        public HandlerThread getHandlerThread() {
+            final HandlerThread thread = new HandlerThread("L2capNetworkProviderThread");
+            thread.start();
+            return thread;
+        }
+
+        /** Create a tun interface configured for blocking i/o */
+        @Nullable
+        public ParcelFileDescriptor createTunInterface(String ifname) {
+            final ParcelFileDescriptor fd;
+            try {
+                fd = ParcelFileDescriptor.adoptFd(ServiceConnectivityJni.createTunTap(
+                        true /*isTun*/,
+                        true /*hasCarrier*/,
+                        true /*setIffMulticast*/,
+                        ifname));
+                ServiceConnectivityJni.bringUpInterface(ifname);
+                // TODO: consider adding a parameter to createTunTap() (or the Builder that should
+                // be added) to configure i/o blocking.
+                final int flags = Os.fcntlInt(fd.getFileDescriptor(), F_GETFL, 0);
+                Os.fcntlInt(fd.getFileDescriptor(), F_SETFL, flags & ~O_NONBLOCK);
+            } catch (Exception e) {
+                // Note: createTunTap currently throws an IllegalStateException on failure.
+                // TODO: native functions should throw ErrnoException.
+                Log.e(TAG, "Failed to create tun interface", e);
+                return null;
+            }
+            return fd;
+        }
+
+        /** Create an L2capPacketForwarder and start forwarding */
+        public L2capPacketForwarder createL2capPacketForwarder(Handler handler,
+                ParcelFileDescriptor tunFd, BluetoothSocket socket, boolean compressHeaders,
+                L2capPacketForwarder.ICallback cb) {
+            return new L2capPacketForwarder(handler, tunFd, socket, compressHeaders, cb);
+        }
+
+        /** Create an L2capIpClient */
+        public L2capIpClient createL2capIpClient(String logTag, Context context, String ifname) {
+            return new L2capIpClient(logTag, context, ifname);
+        }
+    }
+
+    public L2capNetworkProvider(Context context) {
+        this(new Dependencies(), context);
+    }
+
+    public L2capNetworkProvider(Dependencies deps, Context context) {
+        mDeps = deps;
+        mContext = context;
+        mHandlerThread = mDeps.getHandlerThread();
+        mHandler = new Handler(mHandlerThread.getLooper());
+        mProvider = new NetworkProvider(context, mHandlerThread.getLooper(), TAG);
+        mBlanketOffer = new BlanketReservationOffer();
+        mClientOffer = new ClientOffer();
+    }
+
+    /**
+     * Start L2capNetworkProvider.
+     *
+     * Called on CS Handler thread.
+     */
+    public void start() {
+        mHandler.post(() -> {
+            final PackageManager pm = mContext.getPackageManager();
+            if (!pm.hasSystemFeature(FEATURE_BLUETOOTH_LE)) {
+                return;
+            }
+            mBluetoothManager = mContext.getSystemService(BluetoothManager.class);
+            if (mBluetoothManager == null) {
+                // Can this ever happen?
+                Log.wtf(TAG, "BluetoothManager not found");
+                return;
+            }
+            mContext.getSystemService(ConnectivityManager.class).registerNetworkProvider(mProvider);
+            mProvider.registerNetworkOffer(BlanketReservationOffer.SCORE,
+                    BlanketReservationOffer.CAPABILITIES, mHandler::post, mBlanketOffer);
+            mProvider.registerNetworkOffer(ClientOffer.SCORE,
+                    ClientOffer.CAPABILITIES, mHandler::post, mClientOffer);
+        });
+    }
+}
diff --git a/service/src/com/android/server/TestNetworkService.java b/service/src/com/android/server/TestNetworkService.java
index 4d39d7d..96f4e20 100644
--- a/service/src/com/android/server/TestNetworkService.java
+++ b/service/src/com/android/server/TestNetworkService.java
@@ -48,6 +48,7 @@
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.NetworkStackConstants;
+import com.android.net.module.util.ServiceConnectivityJni;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
@@ -75,15 +76,6 @@
     @NonNull private final ConnectivityManager mCm;
     @NonNull private final NetworkProvider mNetworkProvider;
 
-    // Native method stubs
-    private static native int nativeCreateTunTap(boolean isTun, boolean hasCarrier,
-            boolean setIffMulticast, @NonNull String iface);
-
-    private static native void nativeSetTunTapCarrierEnabled(@NonNull String iface, int tunFd,
-            boolean enabled);
-
-    private static native void nativeBringUpInterface(String iface);
-
     @VisibleForTesting
     protected TestNetworkService(@NonNull Context context) {
         mHandlerThread = new HandlerThread("TestNetworkServiceThread");
@@ -143,7 +135,8 @@
             // flags atomically.
             final boolean setIffMulticast = bringUp;
             ParcelFileDescriptor tunIntf = ParcelFileDescriptor.adoptFd(
-                    nativeCreateTunTap(isTun, hasCarrier, setIffMulticast, interfaceName));
+                    ServiceConnectivityJni.createTunTap(
+                            isTun, hasCarrier, setIffMulticast, interfaceName));
 
             // Disable DAD and remove router_solicitation_delay before assigning link addresses.
             if (disableIpv6ProvisioningDelay) {
@@ -160,7 +153,7 @@
             }
 
             if (bringUp) {
-                nativeBringUpInterface(interfaceName);
+                ServiceConnectivityJni.bringUpInterface(interfaceName);
             }
 
             return new TestNetworkInterface(tunIntf, interfaceName);
@@ -403,11 +396,11 @@
     @Override
     public void setCarrierEnabled(@NonNull TestNetworkInterface iface, boolean enabled) {
         enforceTestNetworkPermissions(mContext);
-        nativeSetTunTapCarrierEnabled(iface.getInterfaceName(), iface.getFileDescriptor().getFd(),
-                enabled);
+        ServiceConnectivityJni.setTunTapCarrierEnabled(iface.getInterfaceName(),
+                iface.getFileDescriptor().getFd(), enabled);
         // Explicitly close fd after use to prevent StrictMode from complaining.
         // Also, explicitly referencing iface guarantees that the object is not garbage collected
-        // before nativeSetTunTapCarrierEnabled() executes.
+        // before setTunTapCarrierEnabled() executes.
         try {
             iface.getFileDescriptor().close();
         } catch (IOException e) {
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index 136ea81..74bd235 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -62,6 +62,9 @@
     public static final String QUEUE_CALLBACKS_FOR_FROZEN_APPS =
             "queue_callbacks_for_frozen_apps";
 
+    public static final String QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER =
+            "queue_network_agent_events_in_system_server";
+
     private boolean mNoRematchAllRequestsOnRegister;
 
     /**
diff --git a/service/src/com/android/server/connectivity/DscpPolicyTracker.java b/service/src/com/android/server/connectivity/DscpPolicyTracker.java
index 9c2b9e8..857d705 100644
--- a/service/src/com/android/server/connectivity/DscpPolicyTracker.java
+++ b/service/src/com/android/server/connectivity/DscpPolicyTracker.java
@@ -233,6 +233,11 @@
      */
     public void addDscpPolicy(NetworkAgentInfo nai, DscpPolicy policy) {
         String iface = nai.linkProperties.getInterfaceName();
+        if (null == iface) {
+            Log.e(TAG, "DSCP policies are not supported on null interfaces.");
+            sendStatus(nai, policy.getPolicyId(), DSCP_POLICY_STATUS_REQUEST_DECLINED);
+            return;
+        }
         if (!isEthernet(iface)) {
             Log.e(TAG, "DSCP policies are not supported on raw IP interfaces.");
             sendStatus(nai, policy.getPolicyId(), DSCP_POLICY_STATUS_REQUEST_DECLINED);
diff --git a/service/src/com/android/server/connectivity/InterfaceTracker.java b/service/src/com/android/server/connectivity/InterfaceTracker.java
new file mode 100644
index 0000000..0b4abeb
--- /dev/null
+++ b/service/src/com/android/server/connectivity/InterfaceTracker.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2025 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.connectivity;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.system.Os;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.BpfNetMaps;
+
+import java.util.Map;
+
+/**
+ * InterfaceTracker is responsible for providing interface mapping and tracking.
+ * @hide
+ */
+public class InterfaceTracker {
+    static {
+        if (BpfNetMaps.isAtLeast25Q2()) {
+            System.loadLibrary("service-connectivity");
+        }
+    }
+    private static final String TAG = "InterfaceTracker";
+    private final Dependencies mDeps;
+    private final Map<String, Integer> mInterfaceMap;
+
+    public InterfaceTracker(final Context context) {
+        this(context, new Dependencies());
+    }
+
+    @VisibleForTesting
+    public InterfaceTracker(final Context context, final Dependencies deps) {
+        this.mInterfaceMap = new ArrayMap<>();
+        this.mDeps = deps;
+    }
+
+    /**
+     * To add interface to tracking
+     * @param interfaceName name of interface added.
+     */
+    public void addInterface(@Nullable final String interfaceName) {
+        final int interfaceIndex;
+        if (interfaceName == null) {
+            interfaceIndex = 0;
+        } else {
+            interfaceIndex = mDeps.getIfIndex(interfaceName);
+        }
+        if (interfaceIndex == 0) {
+            Log.e(TAG, "Failed to get interface index for " + interfaceName);
+            return;
+        }
+        synchronized (mInterfaceMap) {
+            mInterfaceMap.put(interfaceName, interfaceIndex);
+        }
+    }
+
+    /**
+     * To remove interface from tracking
+     * @param interfaceName name of interface removed.
+     * @return true if the value was present and now removed.
+     */
+    public boolean removeInterface(@Nullable final String interfaceName) {
+        if (interfaceName == null) return false;
+        synchronized (mInterfaceMap) {
+            return mInterfaceMap.remove(interfaceName) != null;
+        }
+    }
+
+    /**
+     * Get interface index from interface name.
+     * @param interfaceName name of interface
+     * @return interface index for given interface name or 0 if interface is not found.
+     */
+    public int getInterfaceIndex(@Nullable final String interfaceName) {
+        final int interfaceIndex;
+        if (interfaceName != null) {
+            synchronized (mInterfaceMap) {
+                interfaceIndex = mInterfaceMap.getOrDefault(interfaceName, 0);
+            }
+        } else {
+            interfaceIndex = 0;
+        }
+        return interfaceIndex;
+    }
+
+    /**
+     * Dependencies of InterfaceTracker, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /**
+         * Get interface index.
+         */
+        public int getIfIndex(final String ifName) {
+            return Os.if_nametoindex(ifName);
+        }
+
+        /**
+         * Get interface name
+         */
+        public String getIfName(final int ifIndex) {
+            return Os.if_indextoname(ifIndex);
+        }
+
+    }
+}
diff --git a/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java b/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
index af4aee5..c4de80f 100644
--- a/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
+++ b/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
@@ -21,6 +21,7 @@
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
 import static android.system.OsConstants.AF_INET6;
 import static android.system.OsConstants.EADDRINUSE;
+import static android.system.OsConstants.EADDRNOTAVAIL;
 import static android.system.OsConstants.IPPROTO_ICMPV6;
 import static android.system.OsConstants.IPPROTO_IPV6;
 import static android.system.OsConstants.SOCK_CLOEXEC;
@@ -258,6 +259,10 @@
             mDependencies.setsockoptMrt6DelMif(mMulticastRoutingFd, virtualIndex);
             Log.d(TAG, "Removed mifi " + virtualIndex + " from MIF");
         } catch (ErrnoException e) {
+            if (e.errno == EADDRNOTAVAIL) {
+                Log.w(TAG, "multicast virtual interface " + virtualIndex + " already removed", e);
+                return;
+            }
             Log.e(TAG, "failed to remove multicast virtual interface" + virtualIndex, e);
         }
     }
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 94b655f..abab6ab 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -25,6 +25,12 @@
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkCapabilities.transportNamesOf;
+import static android.system.OsConstants.EEXIST;
+import static android.system.OsConstants.EIO;
+import static android.system.OsConstants.ENOENT;
+
+import static com.android.net.module.util.FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED;
+import static com.android.net.module.util.FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_DISALLOW_BYPASS_VPN_FOR_DELEGATE_UID_ENOENT;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -56,10 +62,13 @@
 import android.net.TcpKeepalivePacketData;
 import android.os.Handler;
 import android.os.IBinder;
+import android.os.Message;
 import android.os.RemoteException;
+import android.os.ServiceSpecificException;
 import android.os.SystemClock;
 import android.telephony.data.EpsBearerQosSessionAttributes;
 import android.telephony.data.NrQosSessionAttributes;
+import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
 import android.util.Pair;
@@ -68,8 +77,10 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.WakeupMessage;
+import com.android.net.module.util.FrameworkConnectivityStatsLog;
 import com.android.net.module.util.HandlerUtils;
 import com.android.server.ConnectivityService;
+import com.android.server.ConnectivityService.CaptivePortalImpl;
 
 import java.io.PrintWriter;
 import java.net.Inet4Address;
@@ -82,6 +93,7 @@
 import java.util.Objects;
 import java.util.SortedSet;
 import java.util.TreeSet;
+import java.util.function.Consumer;
 
 /**
  * A bag class used by ConnectivityService for holding a collection of most recent
@@ -574,6 +586,10 @@
     // For fast lookups. Indexes into mInactivityTimers by request ID.
     private final SparseArray<InactivityTimer> mInactivityTimerForRequest = new SparseArray<>();
 
+    // Map of delegated UIDs used to bypass VPN and its captive portal app caller.
+    private final ArrayMap<CaptivePortalImpl, Integer> mCaptivePortalDelegateUids =
+            new ArrayMap<>();
+
     // Inactivity expiry timer. Armed whenever mInactivityTimers is non-empty, regardless of
     // whether the network is inactive or not. Always set to the expiry of the mInactivityTimers
     // that expires last. When the timer fires, all inactivity state is cleared, and if the network
@@ -616,6 +632,7 @@
     // Used by ConnectivityService to keep track of 464xlat.
     public final Nat464Xlat clatd;
 
+    private final ArrayList<Message> mMessagesPendingRegistration = new ArrayList<>();
     // Set after asynchronous creation of the NetworkMonitor.
     private volatile NetworkMonitorManager mNetworkMonitor;
 
@@ -625,7 +642,9 @@
     private final ConnectivityService.Dependencies mConnServiceDeps;
     private final Context mContext;
     private final Handler mHandler;
+    private final NetworkAgentMessageHandler mRegistry;
     private final QosCallbackTracker mQosCallbackTracker;
+    private final INetd mNetd;
 
     private final long mCreationTime;
 
@@ -655,8 +674,10 @@
         mConnServiceDeps = deps;
         setScore(score); // uses members connService, networkCapabilities and networkAgentConfig
         clatd = new Nat464Xlat(this, netd, dnsResolver, deps);
+        mNetd = netd;
         mContext = context;
         mHandler = handler;
+        mRegistry = new NetworkAgentMessageHandler(mHandler);
         this.factorySerialNumber = factorySerialNumber;
         this.creatorUid = creatorUid;
         mLingerDurationMs = lingerDurationMs;
@@ -682,10 +703,12 @@
      * Must be called from the ConnectivityService handler thread. A NetworkAgent can only be
      * registered once.
      */
-    public void notifyRegistered() {
+    public void notifyRegistered(final INetworkMonitor nm) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        mNetworkMonitor = new NetworkMonitorManager(nm);
         try {
             networkAgent.asBinder().linkToDeath(mDeathMonitor, 0);
-            networkAgent.onRegistered(new NetworkAgentMessageHandler(mHandler));
+            networkAgent.onRegistered();
         } catch (RemoteException e) {
             Log.e(TAG, "Error registering NetworkAgent", e);
             maybeUnlinkDeathMonitor();
@@ -698,6 +721,41 @@
     }
 
     /**
+     * Pass all enqueued messages to the message processor argument, and clear the queue.
+     *
+     * This is called by ConnectivityService when it is ready to receive messages for this
+     * network agent. The processor may process the messages synchronously or asynchronously
+     * at its option.
+     *
+     * @param messageProcessor a function to process the messages
+     */
+    public void processEnqueuedMessages(final Consumer<Message> messageProcessor) {
+        for (final Message enqueued : mMessagesPendingRegistration) {
+            messageProcessor.accept(enqueued);
+        }
+        mMessagesPendingRegistration.clear();
+    }
+
+    /**
+     * Enqueues a message if it needs to be enqueued, and returns whether it was enqueued.
+     *
+     * The message is enqueued iff it can't be sent just yet. If it can be sent
+     * immediately, this method returns false and doesn't enqueue.
+     *
+     * If it enqueues, this method will make a copy of the message for enqueuing since
+     * messages can't be reused or recycled before the end of their processing by the
+     * handler.
+     */
+    public boolean maybeEnqueueMessage(final Message msg) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        if (null != mNetworkMonitor) return false;
+        final Message m = mHandler.obtainMessage();
+        m.copyFrom(msg);
+        mMessagesPendingRegistration.add(m);
+        return true;
+    }
+
+    /**
      * Disconnect the NetworkAgent. Must be called from the ConnectivityService handler thread.
      */
     public void disconnect() {
@@ -1020,13 +1078,6 @@
     }
 
     /**
-     * Inform NetworkAgentInfo that a new NetworkMonitor was created.
-     */
-    public void onNetworkMonitorCreated(INetworkMonitor networkMonitor) {
-        mNetworkMonitor = new NetworkMonitorManager(networkMonitor);
-    }
-
-    /**
      * Set the NetworkCapabilities on this NetworkAgentInfo. Also attempts to notify NetworkMonitor
      * of the new capabilities, if NetworkMonitor has been created.
      *
@@ -1101,6 +1152,13 @@
         return mNetworkMonitor;
     }
 
+    /**
+     * Get the registry in this NetworkAgentInfo.
+     */
+    public INetworkAgentRegistry getRegistry() {
+        return mRegistry;
+    }
+
     // Functions for manipulating the requests satisfied by this network.
     //
     // These functions must only called on ConnectivityService's main thread.
@@ -1112,6 +1170,7 @@
         int delta = add ? +1 : -1;
         switch (request.type) {
             case REQUEST:
+            case RESERVATION:
                 mNumRequestNetworkRequests += delta;
                 break;
 
@@ -1549,6 +1608,58 @@
         }
     }
 
+    private int allowBypassVpnOnNetwork(boolean allow, int uid, int netId) {
+        try {
+            mNetd.networkAllowBypassVpnOnNetwork(allow, uid, netId);
+            return 0;
+        } catch (RemoteException e) {
+            // Netd has crashed, and this process is about to crash as well.
+            return EIO;
+        } catch (ServiceSpecificException e) {
+            return e.errorCode;
+        }
+    }
+
+    /**
+     * Set the delegate UID of the app that is allowed to perform network traffic for captive
+     * portal login, and configure the netd bypass rule with this delegated UID.
+     *
+     * @param caller the captive portal app to that delegated UID
+     * @param uid the delegated UID of the captive portal app.
+     * @return Return 0 if set the UID and VPN bypass rule successfully or bypass rule corresponding
+     *                to this UID already exists otherwise return errno.
+     */
+    public int setCaptivePortalDelegateUid(@NonNull final CaptivePortalImpl caller, int uid) {
+        final int errorCode = allowBypassVpnOnNetwork(true /* allow */, uid, network.netId);
+        if (errorCode == 0 || errorCode == EEXIST) {
+            mCaptivePortalDelegateUids.put(caller, uid);
+        }
+        return errorCode == EEXIST ? 0 : errorCode;
+    }
+
+    /**
+     * Remove the delegate UID of the app that is allowed to perform network traffic for captive
+     * portal login, and remove the netd bypass rule if no other caller is delegating this UID.
+     *
+     * @param caller the captive portal app to that delegated UID.
+     * @return Return 0 if remove the UID and VPN bypass rule successfully or bypass rule
+     *                corresponding to this UID doesn't exist otherwise return errno.
+     */
+    public int removeCaptivePortalDelegateUid(@NonNull final CaptivePortalImpl caller) {
+        final Integer maybeDelegateUid = mCaptivePortalDelegateUids.remove(caller);
+        if (maybeDelegateUid == null) return 0;
+        if (mCaptivePortalDelegateUids.values().contains(maybeDelegateUid)) return 0;
+        final int errorCode =
+                allowBypassVpnOnNetwork(false /* allow */, maybeDelegateUid, network.netId);
+        if (errorCode == ENOENT) {
+            FrameworkConnectivityStatsLog.write(
+                    CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                    CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_DISALLOW_BYPASS_VPN_FOR_DELEGATE_UID_ENOENT
+            );
+        }
+        return errorCode == ENOENT ? 0 : errorCode;
+    }
+
     private static boolean areAllowedUidsAcceptableFromNetworkAgent(
             @NonNull final NetworkCapabilities nc, final boolean hasAutomotiveFeature,
             @NonNull final ConnectivityService.Dependencies deps,
diff --git a/service/src/com/android/server/connectivity/NetworkNotificationManager.java b/service/src/com/android/server/connectivity/NetworkNotificationManager.java
index fd41ee6..c5b2762 100644
--- a/service/src/com/android/server/connectivity/NetworkNotificationManager.java
+++ b/service/src/com/android/server/connectivity/NetworkNotificationManager.java
@@ -38,6 +38,7 @@
 import android.os.UserHandle;
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
+import android.text.BidiFormatter;
 import android.text.TextUtils;
 import android.util.Log;
 import android.util.SparseArray;
@@ -87,6 +88,7 @@
 
     // The context is for the current user (system server)
     private final Context mContext;
+    private final Dependencies mDependencies;
     private final ConnectivityResources mResources;
     private final TelephonyManager mTelephonyManager;
     // The notification manager is created from a context for User.ALL, so notifications
@@ -96,7 +98,15 @@
     private final SparseIntArray mNotificationTypeMap;
 
     public NetworkNotificationManager(@NonNull final Context c, @NonNull final TelephonyManager t) {
+        this(c, t, new Dependencies());
+    }
+
+    @VisibleForTesting
+    protected NetworkNotificationManager(@NonNull final Context c,
+            @NonNull final TelephonyManager t,
+            @NonNull Dependencies dependencies) {
         mContext = c;
+        mDependencies = dependencies;
         mTelephonyManager = t;
         mNotificationManager =
                 (NotificationManager) c.createContextAsUser(UserHandle.ALL, 0 /* flags */)
@@ -106,6 +116,13 @@
     }
 
     @VisibleForTesting
+    protected static class Dependencies {
+        public BidiFormatter getBidiFormatter() {
+            return BidiFormatter.getInstance();
+        }
+    }
+
+    @VisibleForTesting
     protected static int approximateTransportType(NetworkAgentInfo nai) {
         return nai.isVPN() ? TRANSPORT_VPN : getFirstTransportType(nai);
     }
@@ -174,7 +191,7 @@
                 name = extraInfo;
             } else {
                 final String ssid = WifiInfo.sanitizeSsid(nai.networkCapabilities.getSsid());
-                name = ssid == null ? "" : ssid;
+                name = ssid == null ? "" : mDependencies.getBidiFormatter().unicodeWrap(ssid);
             }
             // Only notify for Internet-capable networks.
             if (!nai.networkCapabilities.hasCapability(NET_CAPABILITY_INTERNET)) return;
diff --git a/service/src/com/android/server/connectivity/NetworkOffer.java b/service/src/com/android/server/connectivity/NetworkOffer.java
index eea382e..d294046 100644
--- a/service/src/com/android/server/connectivity/NetworkOffer.java
+++ b/service/src/com/android/server/connectivity/NetworkOffer.java
@@ -42,6 +42,7 @@
  * @hide
  */
 public class NetworkOffer implements NetworkRanker.Scoreable {
+    private static final String TAG = NetworkOffer.class.getSimpleName();
     @NonNull public final FullScore score;
     @NonNull public final NetworkCapabilities caps;
     @NonNull public final INetworkOfferCallback callback;
@@ -126,6 +127,23 @@
     }
 
     /**
+     * Sends onNetworkUnneeded for any remaining NetworkRequests.
+     *
+     * Used after a NetworkOffer migration failed to let the provider know that its networks should
+     * be torn down (as the offer is no longer registered).
+     */
+    public void notifyUnneeded() {
+        try {
+            for (NetworkRequest request : mCurrentlyNeeded) {
+                callback.onNetworkUnneeded(request);
+            }
+        } catch (RemoteException e) {
+            // The remote is dead; nothing to do.
+        }
+        mCurrentlyNeeded.clear();
+    }
+
+    /**
      * Migrate from, and take over, a previous offer.
      *
      * When an updated offer is sent from a provider, call this method on the new offer, passing
diff --git a/service/src/com/android/server/connectivity/NetworkPermissions.java b/service/src/com/android/server/connectivity/NetworkPermissions.java
new file mode 100644
index 0000000..9543d8f
--- /dev/null
+++ b/service/src/com/android/server/connectivity/NetworkPermissions.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2025 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.connectivity;
+
+import android.net.INetd;
+
+/**
+ * A wrapper class for managing network and traffic permissions.
+ *
+ * This class encapsulates permissions represented as a bitmask, as defined in INetd.aidl
+ * and used within PermissionMonitor.java.  It distinguishes between two types of permissions:
+ *
+ * 1. Network Permissions: These permissions, declared in INetd.aidl, are used
+ *    by the Android platform's network daemon (system/netd) to control network
+ *    management
+ *
+ * 2. Traffic Permissions: These permissions are used internally by PermissionMonitor.java and
+ *    BpfNetMaps.java to manage fine-grained network traffic filtering and control.
+ *
+ * This wrapper ensures that no new permission definitions, here or in aidl, conflict with any
+ * existing permissions. This prevents unintended interactions or overrides.
+ *
+ * @hide
+ */
+public class NetworkPermissions {
+
+    /*
+     * Below are network permissions declared in INetd.aidl and used by the platform. Using these is
+     * equivalent to using the values in android.net.INetd.
+     */
+    public static final int PERMISSION_NONE = INetd.PERMISSION_NONE; /* 0 */
+    public static final int PERMISSION_NETWORK = INetd.PERMISSION_NETWORK; /* 1 */
+    public static final int PERMISSION_SYSTEM = INetd.PERMISSION_SYSTEM; /* 2 */
+
+    /*
+     * Below are traffic permissions used by PermissionMonitor and BpfNetMaps.
+     */
+
+    /**
+     * PERMISSION_UNINSTALLED is used when an app is uninstalled from the device. All internet
+     * related permissions need to be cleaned.
+     */
+    public static final int TRAFFIC_PERMISSION_UNINSTALLED = -1;
+
+    /**
+     * PERMISSION_INTERNET indicates that the app can create AF_INET and AF_INET6 sockets.
+     */
+    public static final int TRAFFIC_PERMISSION_INTERNET = 4;
+
+    /**
+     * PERMISSION_UPDATE_DEVICE_STATS is used for system UIDs and privileged apps
+     * that have the UPDATE_DEVICE_STATS permission.
+     */
+    public static final int TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS = 8;
+
+    /**
+     * TRAFFIC_PERMISSION_SDKSANDBOX_LOCALHOST indicates if an SdkSandbox UID will be allowed
+     * to connect to localhost. For non SdkSandbox UIDs this bit is a no-op.
+     */
+    public static final int TRAFFIC_PERMISSION_SDKSANDBOX_LOCALHOST = 16;
+}
diff --git a/service/src/com/android/server/connectivity/PermissionMonitor.java b/service/src/com/android/server/connectivity/PermissionMonitor.java
index beaa174..5de5f61 100755
--- a/service/src/com/android/server/connectivity/PermissionMonitor.java
+++ b/service/src/com/android/server/connectivity/PermissionMonitor.java
@@ -19,25 +19,29 @@
 import static android.Manifest.permission.CHANGE_NETWORK_STATE;
 import static android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS;
 import static android.Manifest.permission.INTERNET;
+import static android.Manifest.permission.NEARBY_WIFI_DEVICES;
 import static android.Manifest.permission.NETWORK_STACK;
 import static android.Manifest.permission.UPDATE_DEVICE_STATS;
 import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED;
 import static android.content.pm.PackageManager.GET_PERMISSIONS;
 import static android.net.ConnectivitySettingsManager.UIDS_ALLOWED_ON_RESTRICTED_NETWORKS;
-import static android.net.INetd.PERMISSION_INTERNET;
-import static android.net.INetd.PERMISSION_NETWORK;
-import static android.net.INetd.PERMISSION_NONE;
-import static android.net.INetd.PERMISSION_SYSTEM;
-import static android.net.INetd.PERMISSION_UNINSTALLED;
-import static android.net.INetd.PERMISSION_UPDATE_DEVICE_STATS;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
+import static android.net.connectivity.ConnectivityCompatChanges.RESTRICT_LOCAL_NETWORK;
 import static android.os.Process.INVALID_UID;
 import static android.os.Process.SYSTEM_UID;
 
+import static com.android.server.connectivity.NetworkPermissions.PERMISSION_NETWORK;
+import static com.android.server.connectivity.NetworkPermissions.PERMISSION_NONE;
+import static com.android.server.connectivity.NetworkPermissions.PERMISSION_SYSTEM;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_INTERNET;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UNINSTALLED;
+import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS;
 import static com.android.net.module.util.CollectionUtils.toIntArray;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.app.compat.CompatChanges;
+import android.content.AttributionSource;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -60,6 +64,7 @@
 import android.os.SystemConfigManager;
 import android.os.UserHandle;
 import android.os.UserManager;
+import android.permission.PermissionManager;
 import android.provider.Settings;
 import android.util.ArrayMap;
 import android.util.ArraySet;
@@ -96,6 +101,8 @@
     private final PackageManager mPackageManager;
     private final UserManager mUserManager;
     private final SystemConfigManager mSystemConfigManager;
+    private final PermissionManager mPermissionManager;
+    private final PermissionChangeListener mPermissionChangeListener;
     private final INetd mNetd;
     private final Dependencies mDeps;
     private final Context mContext;
@@ -227,6 +234,12 @@
             context.getContentResolver().registerContentObserver(
                     uri, notifyForDescendants, observer);
         }
+
+        public boolean shouldEnforceLocalNetRestrictions(int uid) {
+            // TODO(b/394567896): Update compat change checks for enforcement
+            return BpfNetMaps.isAtLeast25Q2() &&
+                    CompatChanges.isChangeEnabled(RESTRICT_LOCAL_NETWORK, uid);
+        }
     }
 
     private static class MultiSet<T> {
@@ -267,18 +280,49 @@
     }
 
     @VisibleForTesting
-    PermissionMonitor(@NonNull final Context context, @NonNull final INetd netd,
+    public PermissionMonitor(@NonNull final Context context, @NonNull final INetd netd,
             @NonNull final BpfNetMaps bpfNetMaps,
             @NonNull final Dependencies deps,
             @NonNull final HandlerThread thread) {
         mPackageManager = context.getPackageManager();
         mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
         mSystemConfigManager = context.getSystemService(SystemConfigManager.class);
+        mPermissionManager = context.getSystemService(PermissionManager.class);
+        mPermissionChangeListener = new PermissionChangeListener();
         mNetd = netd;
         mDeps = deps;
         mContext = context;
         mBpfNetMaps = bpfNetMaps;
         mThread = thread;
+        if (BpfNetMaps.isAtLeast25Q2()) {
+            // Local net restrictions is supported as a developer opt-in starting in Android B.
+            // This listener should finish registration by the time the system has completed
+            // boot setup such that any changes to runtime permissions for local network
+            // restrictions can only occur after this registration has completed.
+            mPackageManager.addOnPermissionsChangeListener(mPermissionChangeListener);
+        }
+    }
+
+    @VisibleForTesting
+    void setLocalNetworkPermissions(final int uid, @Nullable final String packageName) {
+        if (!mDeps.shouldEnforceLocalNetRestrictions(uid)) return;
+
+        final AttributionSource attributionSource =
+                new AttributionSource.Builder(uid).setPackageName(packageName).build();
+        final int permissionState = mPermissionManager.checkPermissionForPreflight(
+                NEARBY_WIFI_DEVICES, attributionSource);
+        if (permissionState == PermissionManager.PERMISSION_GRANTED) {
+            mBpfNetMaps.removeUidFromLocalNetBlockMap(attributionSource.getUid());
+        } else {
+            mBpfNetMaps.addUidToLocalNetBlockMap(attributionSource.getUid());
+        }
+        if (hasSdkSandbox(uid)){
+            // SDKs in the SDK RT cannot hold runtime permissions
+            final int sdkSandboxUid = sProcessShim.toSdkSandboxUid(uid);
+            if (!mBpfNetMaps.isUidBlockedFromUsingLocalNetwork(sdkSandboxUid)) {
+                mBpfNetMaps.addUidToLocalNetBlockMap(sdkSandboxUid);
+            }
+        }
     }
 
     private void ensureRunningOnHandlerThread() {
@@ -341,6 +385,7 @@
                     uidsPerm.put(sdkSandboxUid, permission);
                 }
             }
+            setLocalNetworkPermissions(uid, app.packageName);
         }
         return uidsPerm;
     }
@@ -395,7 +440,7 @@
         final SparseIntArray appIdsPerm = new SparseIntArray();
         for (final int uid : mSystemConfigManager.getSystemPermissionUids(INTERNET)) {
             final int appId = UserHandle.getAppId(uid);
-            final int permission = appIdsPerm.get(appId) | PERMISSION_INTERNET;
+            final int permission = appIdsPerm.get(appId) | TRAFFIC_PERMISSION_INTERNET;
             appIdsPerm.put(appId, permission);
             if (hasSdkSandbox(appId)) {
                 appIdsPerm.put(sProcessShim.toSdkSandboxUid(appId), permission);
@@ -403,7 +448,7 @@
         }
         for (final int uid : mSystemConfigManager.getSystemPermissionUids(UPDATE_DEVICE_STATS)) {
             final int appId = UserHandle.getAppId(uid);
-            final int permission = appIdsPerm.get(appId) | PERMISSION_UPDATE_DEVICE_STATS;
+            final int permission = appIdsPerm.get(appId) | TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS;
             appIdsPerm.put(appId, permission);
             if (hasSdkSandbox(appId)) {
                 appIdsPerm.put(sProcessShim.toSdkSandboxUid(appId), permission);
@@ -588,7 +633,7 @@
 
         final List<PackageInfo> apps = getInstalledPackagesAsUser(user);
 
-        // Save all apps
+        // Save all apps in mAllApps
         updateAllApps(apps);
 
         // Uids network permissions
@@ -625,6 +670,11 @@
             final int uid = allUids.keyAt(i);
             if (user.equals(UserHandle.getUserHandleForUid(uid))) {
                 mUidToNetworkPerm.delete(uid);
+                if (mDeps.shouldEnforceLocalNetRestrictions(uid)) {
+                    mBpfNetMaps.removeUidFromLocalNetBlockMap(uid);
+                    if (hasSdkSandbox(uid)) mBpfNetMaps.removeUidFromLocalNetBlockMap(
+                            sProcessShim.toSdkSandboxUid(uid));
+                }
                 removedUids.put(uid, allUids.valueAt(i));
             }
         }
@@ -646,7 +696,7 @@
             final int appId = removedUserAppIds.keyAt(i);
             // Need to clear permission if the removed appId is not found in the array.
             if (appIds.indexOfKey(appId) < 0) {
-                appIds.put(appId, PERMISSION_UNINSTALLED);
+                appIds.put(appId, TRAFFIC_PERMISSION_UNINSTALLED);
             }
         }
         sendAppIdsTrafficPermission(appIds);
@@ -698,7 +748,7 @@
             }
         } else {
             // The last package of this uid is removed from device. Clean the package up.
-            permission = PERMISSION_UNINSTALLED;
+            permission = TRAFFIC_PERMISSION_UNINSTALLED;
         }
         return permission;
     }
@@ -741,13 +791,13 @@
                 return "NETWORK";
             case PERMISSION_SYSTEM:
                 return "SYSTEM";
-            case PERMISSION_INTERNET:
+            case TRAFFIC_PERMISSION_INTERNET:
                 return "INTERNET";
-            case PERMISSION_UPDATE_DEVICE_STATS:
+            case TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS:
                 return "UPDATE_DEVICE_STATS";
-            case (PERMISSION_INTERNET | PERMISSION_UPDATE_DEVICE_STATS):
+            case (TRAFFIC_PERMISSION_INTERNET | TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS):
                 return "ALL";
-            case PERMISSION_UNINSTALLED:
+            case TRAFFIC_PERMISSION_UNINSTALLED:
                 return "UNINSTALLED";
             default:
                 return "UNKNOWN";
@@ -766,7 +816,7 @@
         // (PERMISSION_UNINSTALLED), remove the appId from the array. Otherwise, update the latest
         // permission to the appId.
         final int appId = UserHandle.getAppId(uid);
-        if (uidTrafficPerm == PERMISSION_UNINSTALLED) {
+        if (uidTrafficPerm == TRAFFIC_PERMISSION_UNINSTALLED) {
             userTrafficPerms.delete(appId);
         } else {
             userTrafficPerms.put(appId, uidTrafficPerm);
@@ -784,7 +834,7 @@
                 installed = true;
             }
         }
-        return installed ? permission : PERMISSION_UNINSTALLED;
+        return installed ? permission : TRAFFIC_PERMISSION_UNINSTALLED;
     }
 
     /**
@@ -819,6 +869,7 @@
             }
             sendUidsNetworkPermission(apps, true /* add */);
         }
+        setLocalNetworkPermissions(uid, packageName);
 
         // If the newly-installed package falls within some VPN's uid range, update Netd with it.
         // This needs to happen after the mUidToNetworkPerm update above, since
@@ -863,6 +914,11 @@
     synchronized void onPackageRemoved(@NonNull final String packageName, final int uid) {
         // Update uid permission.
         updateAppIdTrafficPermission(uid);
+        if (BpfNetMaps.isAtLeast25Q2()) {
+            mBpfNetMaps.removeUidFromLocalNetBlockMap(uid);
+            if (hasSdkSandbox(uid)) mBpfNetMaps.removeUidFromLocalNetBlockMap(
+                    sProcessShim.toSdkSandboxUid(uid));
+        }
         // Get the appId permission from all users then send the latest permission to netd.
         final int appId = UserHandle.getAppId(uid);
         final int appIdTrafficPerm = getAppIdTrafficPermission(appId);
@@ -921,11 +977,11 @@
         for (int i = 0; i < requestedPermissions.length; i++) {
             if (requestedPermissions[i].equals(INTERNET)
                     && ((requestedPermissionsFlags[i] & REQUESTED_PERMISSION_GRANTED) != 0)) {
-                permissions |= PERMISSION_INTERNET;
+                permissions |= TRAFFIC_PERMISSION_INTERNET;
             }
             if (requestedPermissions[i].equals(UPDATE_DEVICE_STATS)
                     && ((requestedPermissionsFlags[i] & REQUESTED_PERMISSION_GRANTED) != 0)) {
-                permissions |= PERMISSION_UPDATE_DEVICE_STATS;
+                permissions |= TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS;
             }
         }
         return permissions;
@@ -1164,19 +1220,19 @@
         for (int i = 0; i < netdPermissionsAppIds.size(); i++) {
             int permissions = netdPermissionsAppIds.valueAt(i);
             switch(permissions) {
-                case (PERMISSION_INTERNET | PERMISSION_UPDATE_DEVICE_STATS):
+                case (TRAFFIC_PERMISSION_INTERNET | TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS):
                     allPermissionAppIds.add(netdPermissionsAppIds.keyAt(i));
                     break;
-                case PERMISSION_INTERNET:
+                case TRAFFIC_PERMISSION_INTERNET:
                     internetPermissionAppIds.add(netdPermissionsAppIds.keyAt(i));
                     break;
-                case PERMISSION_UPDATE_DEVICE_STATS:
+                case TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS:
                     updateStatsPermissionAppIds.add(netdPermissionsAppIds.keyAt(i));
                     break;
                 case PERMISSION_NONE:
                     noPermissionAppIds.add(netdPermissionsAppIds.keyAt(i));
                     break;
-                case PERMISSION_UNINSTALLED:
+                case TRAFFIC_PERMISSION_UNINSTALLED:
                     uninstalledAppIds.add(netdPermissionsAppIds.keyAt(i));
                     break;
                 default:
@@ -1188,15 +1244,15 @@
             // TODO: add a lock inside netd to protect IPC trafficSetNetPermForUids()
             if (allPermissionAppIds.size() != 0) {
                 mBpfNetMaps.setNetPermForUids(
-                        PERMISSION_INTERNET | PERMISSION_UPDATE_DEVICE_STATS,
+                        TRAFFIC_PERMISSION_INTERNET | TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS,
                         toIntArray(allPermissionAppIds));
             }
             if (internetPermissionAppIds.size() != 0) {
-                mBpfNetMaps.setNetPermForUids(PERMISSION_INTERNET,
+                mBpfNetMaps.setNetPermForUids(TRAFFIC_PERMISSION_INTERNET,
                         toIntArray(internetPermissionAppIds));
             }
             if (updateStatsPermissionAppIds.size() != 0) {
-                mBpfNetMaps.setNetPermForUids(PERMISSION_UPDATE_DEVICE_STATS,
+                mBpfNetMaps.setNetPermForUids(TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS,
                         toIntArray(updateStatsPermissionAppIds));
             }
             if (noPermissionAppIds.size() != 0) {
@@ -1204,7 +1260,7 @@
                         toIntArray(noPermissionAppIds));
             }
             if (uninstalledAppIds.size() != 0) {
-                mBpfNetMaps.setNetPermForUids(PERMISSION_UNINSTALLED,
+                mBpfNetMaps.setNetPermForUids(TRAFFIC_PERMISSION_UNINSTALLED,
                         toIntArray(uninstalledAppIds));
             }
         } catch (RemoteException | ServiceSpecificException e) {
@@ -1311,4 +1367,11 @@
     private static void loge(String s, Throwable e) {
         Log.e(TAG, s, e);
     }
+
+    private class PermissionChangeListener implements PackageManager.OnPermissionsChangedListener {
+        @Override
+        public void onPermissionsChanged(int uid) {
+            setLocalNetworkPermissions(uid, null);
+        }
+    }
 }
diff --git a/service/src/com/android/server/net/HeaderCompressionUtils.java b/service/src/com/android/server/net/HeaderCompressionUtils.java
new file mode 100644
index 0000000..5bd3a76
--- /dev/null
+++ b/service/src/com/android/server/net/HeaderCompressionUtils.java
@@ -0,0 +1,275 @@
+/*
+ * Copyright (C) 2025 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.net;
+
+import android.util.Log;
+
+import java.io.IOException;
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+
+public class HeaderCompressionUtils {
+    private static final String TAG = "L2capHeaderCompressionUtils";
+    private static final int IPV6_HEADER_SIZE = 40;
+
+    private static byte[] decodeIpv6Address(ByteBuffer buffer, int mode, boolean isMulticast)
+            throws BufferUnderflowException, IOException {
+        // Mode is equivalent between SAM and DAM; however, isMulticast only applies to DAM.
+        final byte[] address = new byte[16];
+        // If multicast bit is set, mix it in the mode, so that the lower two bits represent the
+        // address mode, and the upper bit represents multicast compression.
+        switch ((isMulticast ? 0b100 : 0) | mode) {
+            case 0b000: // 128 bits. The full address is carried in-line.
+            case 0b100:
+                buffer.get(address);
+                break;
+            case 0b001: // 64 bits. The first 64-bits of the fe80:: address are elided.
+                address[0] = (byte) 0xfe;
+                address[1] = (byte) 0x80;
+                buffer.get(address, 8 /*off*/, 8 /*len*/);
+                break;
+            case 0b010: // 16 bits. fe80::ff:fe00:XXXX, where XXXX are the bits carried in-line
+                address[0] = (byte) 0xfe;
+                address[1] = (byte) 0x80;
+                address[11] = (byte) 0xff;
+                address[12] = (byte) 0xfe;
+                buffer.get(address, 14 /*off*/, 2 /*len*/);
+                break;
+            case 0b011: // 0 bits. The address is fully elided and derived from BLE MAC address
+                // Note that on Android, the BLE MAC addresses are not exposed via the API;
+                // therefore, this compression mode cannot be supported.
+                throw new IOException("Address cannot be fully elided");
+            case 0b101: // 48 bits. The address takes the form ffXX::00XX:XXXX:XXXX.
+                address[0] = (byte) 0xff;
+                address[1] = buffer.get();
+                buffer.get(address, 11 /*off*/, 5 /*len*/);
+                break;
+            case 0b110: // 32 bits. The address takes the form ffXX::00XX:XXXX
+                address[0] = (byte) 0xff;
+                address[1] = buffer.get();
+                buffer.get(address, 13 /*off*/, 3 /*len*/);
+                break;
+            case 0b111: // 8 bits. The address takes the form ff02::00XX.
+                address[0] = (byte) 0xff;
+                address[1] = (byte) 0x02;
+                address[15] = buffer.get();
+                break;
+        }
+        return address;
+    }
+
+    /**
+     * Performs 6lowpan header decompression in place.
+     *
+     * Note that the passed in buffer must have enough capacity for successful decompression.
+     *
+     * @param bytes The buffer containing the packet.
+     * @param len The size of the packet
+     * @return decompressed size or zero
+     * @throws BufferUnderflowException if an illegal packet is encountered.
+     * @throws IOException if an unsupported option is encountered.
+     */
+    public static int decompress6lowpan(byte[] bytes, int len)
+            throws BufferUnderflowException, IOException {
+        // Note that ByteBuffer's default byte order is big endian.
+        final ByteBuffer inBuffer = ByteBuffer.wrap(bytes);
+        inBuffer.limit(len);
+
+        // LOWPAN_IPHC base encoding:
+        //   0   1   2   3   4   5   6   7 | 8   9  10  11  12  13  14  15
+        // +---+---+---+---+---+---+---+---|---+---+---+---+---+---+---+---+
+        // | 0 | 1 | 1 |  TF   |NH | HLIM  |CID|SAC|  SAM  | M |DAC|  DAM  |
+        // +---+---+---+---+---+---+---+---|---+---+---+---+---+---+---+---+
+        final int iphc1 = inBuffer.get() & 0xff;
+        final int iphc2 = inBuffer.get() & 0xff;
+        // Dispatch must start with 0b011.
+        if ((iphc1 & 0xe0) != 0x60) {
+            throw new IOException("LOWPAN_IPHC does not start with 011");
+        }
+
+        final int tf = (iphc1 >> 3) & 3;         // Traffic class
+        final boolean nh = (iphc1 & 4) != 0;     // Next header
+        final int hlim = iphc1 & 3;              // Hop limit
+        final boolean cid = (iphc2 & 0x80) != 0; // Context identifier extension
+        final boolean sac = (iphc2 & 0x40) != 0; // Source address compression
+        final int sam = (iphc2 >> 4) & 3;        // Source address mode
+        final boolean m = (iphc2 & 8) != 0;      // Multicast compression
+        final boolean dac = (iphc2 & 4) != 0;    // Destination address compression
+        final int dam = iphc2 & 3;               // Destination address mode
+
+        final ByteBuffer ipv6Header = ByteBuffer.allocate(IPV6_HEADER_SIZE);
+
+        final int trafficClass;
+        final int flowLabel;
+        switch (tf) {
+            case 0b00: // ECN + DSCP + 4-bit Pad + Flow Label (4 bytes)
+                trafficClass = inBuffer.get() & 0xff;
+                flowLabel = (inBuffer.get() & 0x0f) << 16
+                        | (inBuffer.get() & 0xff) << 8
+                        | (inBuffer.get() & 0xff);
+                break;
+            case 0b01: // ECN + 2-bit Pad + Flow Label (3 bytes), DSCP is elided.
+                final int firstByte = inBuffer.get() & 0xff;
+                //     0     1     2     3     4     5     6     7
+                // +-----+-----+-----+-----+-----+-----+-----+-----+
+                // |          DS FIELD, DSCP           | ECN FIELD |
+                // +-----+-----+-----+-----+-----+-----+-----+-----+
+                // rfc6282 does not explicitly state what value to use for DSCP, assuming 0.
+                trafficClass = firstByte >> 6;
+                flowLabel = (firstByte & 0x0f) << 16
+                        | (inBuffer.get() & 0xff) << 8
+                        | (inBuffer.get() & 0xff);
+                break;
+            case 0b10: // ECN + DSCP (1 byte), Flow Label is elided.
+                trafficClass = inBuffer.get() & 0xff;
+                // rfc6282 does not explicitly state what value to use, assuming 0.
+                flowLabel = 0;
+                break;
+            case 0b11: // Traffic Class and Flow Label are elided.
+                // rfc6282 does not explicitly state what value to use, assuming 0.
+                trafficClass = 0;
+                flowLabel = 0;
+                break;
+            default:
+                // This cannot happen. Crash if it does.
+                throw new IllegalStateException("Illegal TF value");
+        }
+
+        // Write version, traffic class, and flow label
+        final int versionTcFlowLabel = (6 << 28) | (trafficClass << 20) | flowLabel;
+        ipv6Header.putInt(versionTcFlowLabel);
+
+        // Payload length is still unknown. Use 0 for now.
+        ipv6Header.putShort((short) 0);
+
+        // We do not use UDP or extension header compression, therefore the next header
+        // cannot be compressed.
+        if (nh) throw new IOException("Next header cannot be compressed");
+        // Write next header
+        ipv6Header.put(inBuffer.get());
+
+        final byte hopLimit;
+        switch (hlim) {
+            case 0b00: // The Hop Limit field is carried in-line.
+                hopLimit = inBuffer.get();
+                break;
+            case 0b01: // The Hop Limit field is compressed and the hop limit is 1.
+                hopLimit = 1;
+                break;
+            case 0b10: // The Hop Limit field is compressed and the hop limit is 64.
+                hopLimit = 64;
+                break;
+            case 0b11: // The Hop Limit field is compressed and the hop limit is 255.
+                hopLimit = (byte) 255;
+                break;
+            default:
+                // This cannot happen. Crash if it does.
+                throw new IllegalStateException("Illegal HLIM value");
+        }
+        ipv6Header.put(hopLimit);
+
+        if (cid) throw new IOException("Context based compression not supported");
+        if (sac) throw new IOException("Context based compression not supported");
+        if (dac) throw new IOException("Context based compression not supported");
+
+        // Write source address
+        ipv6Header.put(decodeIpv6Address(inBuffer, sam, false /* isMulticast */));
+
+        // Write destination address
+        ipv6Header.put(decodeIpv6Address(inBuffer, dam, m));
+
+        // Go back and fix up payloadLength
+        final short payloadLength = (short) inBuffer.remaining();
+        ipv6Header.putShort(4, payloadLength);
+
+        // Done! Check that 40 bytes were written.
+        if (ipv6Header.position() != IPV6_HEADER_SIZE) {
+            // This indicates a bug in our code -> crash.
+            throw new IllegalStateException("Faulty decompression wrote less than 40 bytes");
+        }
+
+        // Ensure there is enough room in the buffer
+        final int packetLength = payloadLength + IPV6_HEADER_SIZE;
+        if (bytes.length < packetLength) {
+            throw new IOException("Decompressed packet exceeds buffer size");
+        }
+
+        // Move payload bytes back to make room for the header
+        inBuffer.limit(packetLength);
+        System.arraycopy(bytes, inBuffer.position(), bytes, IPV6_HEADER_SIZE, payloadLength);
+        // Copy IPv6 header to the beginning of the buffer.
+        inBuffer.position(0);
+        ipv6Header.flip();
+        inBuffer.put(ipv6Header);
+
+        return packetLength;
+    }
+
+    /**
+     * Performs 6lowpan header compression in place.
+     *
+     * @param bytes The buffer containing the packet.
+     * @param len The size of the packet
+     * @return compressed size or zero
+     * @throws BufferUnderflowException if an illegal packet is encountered.
+     * @throws IOException if an unsupported option is encountered.
+     */
+    public static int compress6lowpan(byte[] bytes, final int len)
+            throws BufferUnderflowException, IOException {
+        // Compression only happens on egress, i.e. the packet is read from the tun fd.
+        // This means that this code can be a bit more lenient.
+        if (len < 40) {
+            Log.wtf(TAG, "Encountered short (<40 byte) packet");
+            return 0;
+        }
+
+        // Note that ByteBuffer's default byte order is big endian.
+        final ByteBuffer inBuffer = ByteBuffer.wrap(bytes);
+        inBuffer.limit(len);
+
+        // Check that the packet is an IPv6 packet
+        final int versionTcFlowLabel = inBuffer.getInt() & 0xffffffff;
+        if ((versionTcFlowLabel >> 28) != 6) {
+            return 0;
+        }
+
+        // Check that the payload length matches the packet length - 40.
+        int payloadLength = inBuffer.getShort();
+        if (payloadLength != len - IPV6_HEADER_SIZE) {
+            throw new IOException("Encountered packet with payload length mismatch");
+        }
+
+        // Implements rfc 6282 6lowpan header compression using iphc 0110 0000 0000 0000 (all
+        // fields are carried inline).
+        inBuffer.position(0);
+        inBuffer.put((byte) 0x60);
+        inBuffer.put((byte) 0x00);
+        final byte trafficClass = (byte) ((versionTcFlowLabel >> 20) & 0xff);
+        inBuffer.put(trafficClass);
+        final byte flowLabelMsb = (byte) ((versionTcFlowLabel >> 16) & 0x0f);
+        final short flowLabelLsb = (short) (versionTcFlowLabel & 0xffff);
+        inBuffer.put(flowLabelMsb);
+        // Note: the next putShort overrides the payload length. This is WAI as the payload length
+        // is reconstructed via L2CAP packet length.
+        inBuffer.putShort(flowLabelLsb);
+
+        // Since the iphc (2 bytes) matches the payload length that was elided (2 bytes), the length
+        // of the packet did not change.
+        return len;
+    }
+}
diff --git a/service/src/com/android/server/net/L2capNetwork.java b/service/src/com/android/server/net/L2capNetwork.java
new file mode 100644
index 0000000..ca155db
--- /dev/null
+++ b/service/src/com/android/server/net/L2capNetwork.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2025 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.net;
+
+import static android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN;
+
+import android.annotation.Nullable;
+import android.bluetooth.BluetoothSocket;
+import android.content.Context;
+import android.net.L2capNetworkSpecifier;
+import android.net.LinkProperties;
+import android.net.NetworkAgent;
+import android.net.NetworkAgentConfig;
+import android.net.NetworkCapabilities;
+import android.net.NetworkProvider;
+import android.net.NetworkScore;
+import android.net.ip.IIpClient;
+import android.net.ip.IpClientCallbacks;
+import android.net.ip.IpClientManager;
+import android.net.ip.IpClientUtil;
+import android.net.shared.ProvisioningConfiguration;
+import android.os.ConditionVariable;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import android.util.Log;
+
+import com.android.server.L2capNetworkProvider;
+
+public class L2capNetwork {
+    private static final NetworkScore NETWORK_SCORE = new NetworkScore.Builder().build();
+    private final String mLogTag;
+    private final Handler mHandler;
+    private final L2capPacketForwarder mForwarder;
+    private final NetworkCapabilities mNetworkCapabilities;
+    private final NetworkAgent mNetworkAgent;
+
+    /** IpClient wrapper to handle IPv6 link-local provisioning for L2CAP tun.
+     *
+     * Note that the IpClient does not need to be stopped.
+     */
+    public static class L2capIpClient extends IpClientCallbacks {
+        private final String mLogTag;
+        private final ConditionVariable mOnIpClientCreatedCv = new ConditionVariable(false);
+        private final ConditionVariable mOnProvisioningSuccessCv = new ConditionVariable(false);
+        @Nullable
+        private IpClientManager mIpClient;
+        @Nullable
+        private volatile LinkProperties mLinkProperties;
+
+        public L2capIpClient(String logTag, Context context, String ifname) {
+            mLogTag = logTag;
+            IpClientUtil.makeIpClient(context, ifname, this);
+        }
+
+        @Override
+        public void onIpClientCreated(IIpClient ipClient) {
+            mIpClient = new IpClientManager(ipClient, mLogTag);
+            mOnIpClientCreatedCv.open();
+        }
+
+        @Override
+        public void onProvisioningSuccess(LinkProperties lp) {
+            Log.d(mLogTag, "Successfully provisioned l2cap tun: " + lp);
+            mLinkProperties = lp;
+            mOnProvisioningSuccessCv.open();
+        }
+
+        @Override
+        public void onProvisioningFailure(LinkProperties lp) {
+            Log.i(mLogTag, "Failed to provision l2cap tun: " + lp);
+            mLinkProperties = null;
+            mOnProvisioningSuccessCv.open();
+        }
+
+        /**
+         * Starts IPv6 link-local provisioning.
+         *
+         * @return LinkProperties on success, null on failure.
+         */
+        @Nullable
+        public LinkProperties start() {
+            mOnIpClientCreatedCv.block();
+            // mIpClient guaranteed non-null.
+            final ProvisioningConfiguration config = new ProvisioningConfiguration.Builder()
+                    .withoutIPv4()
+                    .withIpv6LinkLocalOnly()
+                    .withRandomMacAddress() // addr_gen_mode EUI64 -> random on tun.
+                    .build();
+            mIpClient.startProvisioning(config);
+            // "Provisioning" is guaranteed to succeed as link-local only mode does not actually
+            // require any provisioning.
+            mOnProvisioningSuccessCv.block();
+            return mLinkProperties;
+        }
+    }
+
+    public interface ICallback {
+        /** Called when an error is encountered */
+        void onError(L2capNetwork network);
+        /** Called when CS triggers NetworkAgent#onNetworkUnwanted */
+        void onNetworkUnwanted(L2capNetwork network);
+    }
+
+    public L2capNetwork(String logTag, Handler handler, Context context, NetworkProvider provider,
+            BluetoothSocket socket, ParcelFileDescriptor tunFd, NetworkCapabilities nc,
+            LinkProperties lp, L2capNetworkProvider.Dependencies deps, ICallback cb) {
+        mLogTag = logTag;
+        mHandler = handler;
+        mNetworkCapabilities = nc;
+
+        final L2capNetworkSpecifier spec = (L2capNetworkSpecifier) nc.getNetworkSpecifier();
+        final boolean compressHeaders = spec.getHeaderCompression() == HEADER_COMPRESSION_6LOWPAN;
+
+        mForwarder = deps.createL2capPacketForwarder(handler, tunFd, socket, compressHeaders,
+                () -> {
+            // TODO: add a check that this callback is invoked on the handler thread.
+            cb.onError(L2capNetwork.this);
+        });
+
+        final NetworkAgentConfig config = new NetworkAgentConfig.Builder().build();
+        mNetworkAgent = new NetworkAgent(context, mHandler.getLooper(), mLogTag,
+                nc, lp, NETWORK_SCORE, config, provider) {
+            @Override
+            public void onNetworkUnwanted() {
+                Log.i(mLogTag, "Network is unwanted");
+                // TODO: add a check that this callback is invoked on the handler thread.
+                cb.onNetworkUnwanted(L2capNetwork.this);
+            }
+        };
+        mNetworkAgent.register();
+        mNetworkAgent.markConnected();
+    }
+
+    /** Create an L2capNetwork or return null on failure. */
+    @Nullable
+    public static L2capNetwork create(Handler handler, Context context, NetworkProvider provider,
+            String ifname, BluetoothSocket socket, ParcelFileDescriptor tunFd,
+            NetworkCapabilities nc, L2capNetworkProvider.Dependencies deps, ICallback cb) {
+        // TODO: add a check that this function is invoked on the handler thread.
+        final String logTag = String.format("L2capNetwork[%s]", ifname);
+
+        // L2capIpClient#start() blocks until provisioning either succeeds (and returns
+        // LinkProperties) or fails (and returns null).
+        // Note that since L2capNetwork is using IPv6 link-local provisioning the most likely
+        // (only?) failure mode is due to the interface disappearing.
+        final LinkProperties lp = deps.createL2capIpClient(logTag, context, ifname).start();
+        if (lp == null) return null;
+
+        return new L2capNetwork(
+                logTag, handler, context, provider, socket, tunFd, nc, lp, deps, cb);
+    }
+
+    /** Get the NetworkCapabilities used for this Network */
+    public NetworkCapabilities getNetworkCapabilities() {
+        return mNetworkCapabilities;
+    }
+
+    /** Tear down the network and associated resources */
+    public void tearDown() {
+        mNetworkAgent.unregister();
+        mForwarder.tearDown();
+    }
+}
diff --git a/service/src/com/android/server/net/L2capPacketForwarder.java b/service/src/com/android/server/net/L2capPacketForwarder.java
new file mode 100644
index 0000000..8420d60
--- /dev/null
+++ b/service/src/com/android/server/net/L2capPacketForwarder.java
@@ -0,0 +1,341 @@
+/*
+ * Copyright (C) 2025 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.net;
+
+import static com.android.server.net.HeaderCompressionUtils.compress6lowpan;
+import static com.android.server.net.HeaderCompressionUtils.decompress6lowpan;
+
+import android.bluetooth.BluetoothSocket;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.BufferUnderflowException;
+
+/**
+ * Forwards packets from a BluetoothSocket of type L2CAP to a tun fd and vice versa.
+ *
+ * The forwarding logic operates on raw IP packets and there are no ethernet headers.
+ * Therefore, L3 MTU = L2 MTU.
+ */
+public class L2capPacketForwarder {
+    private static final String TAG = "L2capPacketForwarder";
+
+    // DCT specifies an MTU of 1500.
+    // TODO: Set /proc/sys/net/ipv6/conf/${iface}/mtu to 1280 and the link MTU to 1528 to accept
+    // slightly larger packets on ingress (i.e. packets passing through a NAT64 gateway).
+    // MTU determines the value of the read buffers, so use the larger of the two.
+    @VisibleForTesting
+    public static final int MTU = 1528;
+    private final Handler mHandler;
+    private final IReadWriteFd mTunFd;
+    private final IReadWriteFd mL2capFd;
+    private final L2capThread mIngressThread;
+    private final L2capThread mEgressThread;
+    private final ICallback mCallback;
+
+    public interface ICallback {
+        /** Called when an error is encountered; should tear down forwarding. */
+        void onError();
+    }
+
+    private interface IReadWriteFd {
+        /**
+         * Read up to len bytes into bytes[off] and return bytes actually read.
+         *
+         * bytes[] must be of size >= off + len.
+         */
+        int read(byte[] bytes, int off, int len) throws IOException;
+        /**
+         * Write len bytes starting from bytes[off]
+         *
+         * bytes[] must be of size >= off + len.
+         */
+        void write(byte[] bytes, int off, int len) throws IOException;
+        /** Disallow further receptions, shutdown(fd, SHUT_RD) */
+        void shutdownRead();
+        /** Disallow further transmissions, shutdown(fd, SHUT_WR) */
+        void shutdownWrite();
+        /** Close the fd */
+        void close();
+    }
+
+    @VisibleForTesting
+    public static class BluetoothSocketWrapper implements IReadWriteFd {
+        private final BluetoothSocket mSocket;
+        private final InputStream mInputStream;
+        private final OutputStream mOutputStream;
+
+        public BluetoothSocketWrapper(BluetoothSocket socket) {
+            // TODO: assert that MTU can fit within Bluetooth L2CAP SDU (maximum size of an L2CAP
+            // packet). The L2CAP SDU is 65535 by default, but can be less when using hardware
+            // offload.
+            mSocket = socket;
+            try {
+                mInputStream = socket.getInputStream();
+                mOutputStream = socket.getOutputStream();
+            } catch (IOException e) {
+                // Per the API docs, this should not actually be possible.
+                Log.wtf(TAG, "Failed to get Input/OutputStream", e);
+                // Fail hard.
+                throw new IllegalStateException("Failed to get Input/OutputStream");
+            }
+        }
+
+        /** Read from the BluetoothSocket. */
+        @Override
+        public int read(byte[] bytes, int off, int len) throws IOException {
+            // Note: EINTR is handled internally and automatically triggers a retry loop.
+            int bytesRead = mInputStream.read(bytes, off, len);
+            if (bytesRead < 0 || bytesRead > MTU) {
+                // Don't try to recover, just trigger network teardown. This might indicate a bug in
+                // the Bluetooth stack.
+                throw new IOException("Packet exceeds MTU or reached EOF. Read: " + bytesRead);
+            }
+            return bytesRead;
+        }
+
+        /** Write to the BluetoothSocket. */
+        @Override
+        public void write(byte[] bytes, int off, int len) throws IOException {
+            // Note: EINTR is handled internally and automatically triggers a retry loop.
+            mOutputStream.write(bytes, off, len);
+        }
+
+        @Override
+        public void shutdownRead() {
+            // BluetoothSocket does not expose methods to shutdown read / write individually;
+            // however, BluetoothSocket#close() shuts down both read and write and is safe to be
+            // called multiple times from any thread.
+            try {
+                mSocket.close();
+            } catch (IOException e) {
+                Log.w(TAG, "shutdownRead: Failed to close BluetoothSocket", e);
+            }
+        }
+
+        @Override
+        public void shutdownWrite() {
+            // BluetoothSocket does not expose methods to shutdown read / write individually;
+            // however, BluetoothSocket#close() shuts down both read and write and is safe to be
+            // called multiple times from any thread.
+            try {
+                mSocket.close();
+            } catch (IOException e) {
+                Log.w(TAG, "shutdownWrite: Failed to close BluetoothSocket", e);
+            }
+        }
+
+        @Override
+        public void close() {
+            // BluetoothSocket#close() is safe to be called multiple times.
+            try {
+                mSocket.close();
+            } catch (IOException e) {
+                Log.w(TAG, "close: Failed to close BluetoothSocket", e);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    public static class FdWrapper implements IReadWriteFd {
+        private final ParcelFileDescriptor mFd;
+
+        public FdWrapper(ParcelFileDescriptor fd) {
+            mFd = fd;
+        }
+
+        @Override
+        public int read(byte[] bytes, int off, int len) throws IOException {
+            try {
+                // Note: EINTR is handled internally and automatically triggers a retry loop.
+                return Os.read(mFd.getFileDescriptor(), bytes, off, len);
+            } catch (ErrnoException e) {
+                throw new IOException(e);
+            }
+        }
+
+        /**
+         * Write to BluetoothSocket.
+         */
+        @Override
+        public void write(byte[] bytes, int off, int len) throws IOException {
+            try {
+                // Note: EINTR is handled internally and automatically triggers a retry loop.
+                Os.write(mFd.getFileDescriptor(), bytes, off, len);
+            } catch (ErrnoException e) {
+                throw new IOException(e);
+            }
+        }
+
+        @Override
+        public void shutdownRead() {
+            try {
+                Os.shutdown(mFd.getFileDescriptor(), OsConstants.SHUT_RD);
+            } catch (ErrnoException e) {
+                Log.w(TAG, "shutdownRead: Failed to shutdown(fd, SHUT_RD)", e);
+            }
+        }
+
+        @Override
+        public void shutdownWrite() {
+            try {
+                Os.shutdown(mFd.getFileDescriptor(), OsConstants.SHUT_WR);
+            } catch (ErrnoException e) {
+                Log.w(TAG, "shutdownWrite: Failed to shutdown(fd, SHUT_WR)", e);
+            }
+        }
+
+        @Override
+        public void close() {
+            try {
+                // Safe to call multiple times. Both Os.close(FileDescriptor) and
+                // ParcelFileDescriptor#close() offer protection against double-closing an fd.
+                mFd.close();
+            } catch (IOException e) {
+                Log.w(TAG, "close: Failed to close fd", e);
+            }
+        }
+    }
+
+    private class L2capThread extends Thread {
+        // Set mBuffer length to MTU + 1 to catch read() overflows.
+        private final byte[] mBuffer = new byte[MTU + 1];
+        private volatile boolean mIsRunning = true;
+
+        private final String mLogTag;
+        private final IReadWriteFd mReadFd;
+        private final IReadWriteFd mWriteFd;
+        private final boolean mIsIngress;
+        private final boolean mCompressHeaders;
+
+        L2capThread(IReadWriteFd readFd, IReadWriteFd writeFd, boolean isIngress,
+                boolean compressHeaders) {
+            super("L2capNetworkProvider-ForwarderThread");
+            mLogTag = isIngress ? "L2capForwarderThread-Ingress" : "L2capForwarderThread-Egress";
+            mReadFd = readFd;
+            mWriteFd = writeFd;
+            mIsIngress = isIngress;
+            mCompressHeaders = compressHeaders;
+        }
+
+        private void postOnError() {
+            mHandler.post(() -> {
+                // All callbacks must be called on handler thread.
+                mCallback.onError();
+            });
+        }
+
+        @Override
+        public void run() {
+            while (mIsRunning) {
+                try {
+                    int readBytes = mReadFd.read(mBuffer, 0 /*off*/, mBuffer.length);
+                    // No bytes to write, continue.
+                    if (readBytes <= 0) {
+                        Log.w(mLogTag, "Zero-byte read encountered: " + readBytes);
+                        continue;
+                    }
+
+                    if (mCompressHeaders) {
+                        if (mIsIngress) {
+                            readBytes = decompress6lowpan(mBuffer, readBytes);
+                        } else {
+                            readBytes = compress6lowpan(mBuffer, readBytes);
+                        }
+                    }
+
+                    // If the packet is 0-length post de/compression or exceeds MTU, drop it.
+                    // Note that a large read on BluetoothSocket throws an IOException to tear down
+                    // the network.
+                    if (readBytes <= 0 || readBytes > MTU) continue;
+
+                    mWriteFd.write(mBuffer, 0 /*off*/, readBytes);
+                } catch (IOException|BufferUnderflowException e) {
+                    Log.e(mLogTag, "L2capThread exception", e);
+                    // Tear down the network on any error.
+                    mIsRunning = false;
+                    // Notify provider that forwarding has stopped.
+                    postOnError();
+                }
+            }
+        }
+
+        public void tearDown() {
+            mIsRunning = false;
+            mReadFd.shutdownRead();
+            mWriteFd.shutdownWrite();
+        }
+    }
+
+    public L2capPacketForwarder(Handler handler, ParcelFileDescriptor tunFd, BluetoothSocket socket,
+            boolean compressHdrs, ICallback cb) {
+        this(handler, new FdWrapper(tunFd), new BluetoothSocketWrapper(socket), compressHdrs, cb);
+    }
+
+    @VisibleForTesting
+    L2capPacketForwarder(Handler handler, IReadWriteFd tunFd, IReadWriteFd l2capFd,
+            boolean compressHeaders, ICallback cb) {
+        mHandler = handler;
+        mTunFd = tunFd;
+        mL2capFd = l2capFd;
+        mCallback = cb;
+
+        mIngressThread = new L2capThread(l2capFd, tunFd, true /*isIngress*/, compressHeaders);
+        mEgressThread = new L2capThread(tunFd, l2capFd, false /*isIngress*/, compressHeaders);
+
+        mIngressThread.start();
+        mEgressThread.start();
+    }
+
+    /**
+     * Tear down the L2capPacketForwarder.
+     *
+     * This operation closes both the passed tun fd and BluetoothSocket.
+     **/
+    public void tearDown() {
+        // Destroying both threads first guarantees that both read and write side of FD have been
+        // shutdown.
+        mIngressThread.tearDown();
+        mEgressThread.tearDown();
+
+        // In order to interrupt a blocking read on the BluetoothSocket, the BluetoothSocket must be
+        // closed (which triggers shutdown()). This means, the BluetoothSocket must be closed inside
+        // L2capPacketForwarder. Tear down the tun fd alongside it for consistency.
+        mTunFd.close();
+        mL2capFd.close();
+
+        try {
+            mIngressThread.join();
+        } catch (InterruptedException e) {
+            // join() interrupted in tearDown path, do nothing.
+        }
+        try {
+            mEgressThread.join();
+        } catch (InterruptedException e) {
+            // join() interrupted in tearDown path, do nothing.
+        }
+    }
+}
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index b4a3b8a..abfc447 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -81,17 +81,6 @@
     },
 }
 
-java_defaults {
-    name: "lib_mockito_extended",
-    static_libs: [
-        "mockito-target-extended-minus-junit4",
-    ],
-    jni_libs: [
-        "libdexmakerjvmtiagent",
-        "libstaticjvmtiagent",
-    ],
-}
-
 java_library {
     name: "net-utils-dnspacket-common",
     srcs: [
@@ -350,7 +339,7 @@
     // TODO: remove "apex_available:platform".
     apex_available: [
         "//apex_available:platform",
-        "com.android.btservices",
+        "com.android.bt",
         "com.android.tethering",
         "com.android.wifi",
     ],
diff --git a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
index d99eedc..4af516d 100644
--- a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
+++ b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
@@ -148,25 +148,6 @@
         netd.tetherStartWithConfiguration(config);
     }
 
-    /** Setup interface for tethering. */
-    public static void tetherInterface(final INetd netd, final String iface, final IpPrefix dest)
-            throws RemoteException, ServiceSpecificException {
-        tetherInterface(netd, iface, dest, 20 /* maxAttempts */, 50 /* pollingIntervalMs */);
-    }
-
-    /** Setup interface with configurable retries for tethering. */
-    public static void tetherInterface(final INetd netd, final String iface, final IpPrefix dest,
-            int maxAttempts, int pollingIntervalMs)
-            throws RemoteException, ServiceSpecificException {
-        netd.tetherInterfaceAdd(iface);
-        networkAddInterface(netd, iface, maxAttempts, pollingIntervalMs);
-        // Activate a route to dest and IPv6 link local.
-        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
-                new RouteInfo(dest, null, iface, RTN_UNICAST));
-        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
-                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
-    }
-
     /**
      * Retry Netd#networkAddInterface for EBUSY error code.
      * If the same interface (e.g., wlan0) is in client mode and then switches to tethered mode.
@@ -174,12 +155,12 @@
      * in use in netd because the ConnectivityService thread hasn't processed the disconnect yet.
      * See b/158269544 for detail.
      */
-    private static void networkAddInterface(final INetd netd, final String iface,
+    public static void networkAddInterface(final INetd netd, int netId, final String iface,
             int maxAttempts, int pollingIntervalMs)
             throws ServiceSpecificException, RemoteException {
         for (int i = 1; i <= maxAttempts; i++) {
             try {
-                netd.networkAddInterface(INetd.LOCAL_NET_ID, iface);
+                netd.networkAddInterface(netId, iface);
                 return;
             } catch (ServiceSpecificException e) {
                 if (e.errorCode == EBUSY && i < maxAttempts) {
@@ -193,38 +174,29 @@
         }
     }
 
-    /** Reset interface for tethering. */
-    public static void untetherInterface(final INetd netd, String iface)
-            throws RemoteException, ServiceSpecificException {
-        try {
-            netd.tetherInterfaceRemove(iface);
-        } finally {
-            netd.networkRemoveInterface(INetd.LOCAL_NET_ID, iface);
-        }
-    }
-
-    /** Add |routes| to local network. */
-    public static void addRoutesToLocalNetwork(final INetd netd, final String iface,
+    /** Add |routes| to the given network. */
+    public static void addRoutesToNetwork(final INetd netd, int netId, final String iface,
             final List<RouteInfo> routes) {
 
         for (RouteInfo route : routes) {
             if (!route.isDefaultRoute()) {
-                modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID, route);
+                modifyRoute(netd, ModifyOperation.ADD, netId, route);
             }
         }
 
         // IPv6 link local should be activated always.
-        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+        modifyRoute(netd, ModifyOperation.ADD, netId,
                 new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
     }
 
-    /** Remove routes from local network. */
-    public static int removeRoutesFromLocalNetwork(final INetd netd, final List<RouteInfo> routes) {
+    /** Remove routes from the given network. */
+    public static int removeRoutesFromNetwork(final INetd netd, int netId,
+            final List<RouteInfo> routes) {
         int failures = 0;
 
         for (RouteInfo route : routes) {
             try {
-                modifyRoute(netd, ModifyOperation.REMOVE, INetd.LOCAL_NET_ID, route);
+                modifyRoute(netd, ModifyOperation.REMOVE, netId, route);
             } catch (IllegalStateException e) {
                 failures++;
             }
diff --git a/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java b/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
index 5069672..0241d0a 100644
--- a/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
+++ b/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
@@ -16,7 +16,6 @@
 
 package com.android.net.module.util;
 
-import static android.net.INetd.LOCAL_NET_ID;
 import static android.system.OsConstants.EBUSY;
 
 import static com.android.testutils.MiscAsserts.assertThrows;
@@ -25,13 +24,9 @@
 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.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -40,7 +35,6 @@
 
 import android.net.INetd;
 import android.net.InterfaceConfigurationParcel;
-import android.net.IpPrefix;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
 
@@ -62,7 +56,7 @@
     @Mock private INetd mNetd;
 
     private static final String IFACE = "TEST_IFACE";
-    private static final IpPrefix TEST_IPPREFIX = new IpPrefix("192.168.42.1/24");
+    private static final int TEST_NET_ID = 123;
 
     @Before
     public void setUp() throws Exception {
@@ -134,7 +128,7 @@
             }
 
             throw new ServiceSpecificException(EBUSY);
-        }).when(mNetd).networkAddInterface(LOCAL_NET_ID, IFACE);
+        }).when(mNetd).networkAddInterface(TEST_NET_ID, IFACE);
     }
 
     class Counter {
@@ -150,20 +144,21 @@
     }
 
     @Test
-    public void testTetherInterfaceSuccessful() throws Exception {
+    public void testNetworkAddInterfaceSuccessful() throws Exception {
         // Expect #networkAddInterface successful at first tries.
-        verifyTetherInterfaceSucceeds(1);
+        verifyNetworkAddInterfaceSucceeds(1);
 
         // Expect #networkAddInterface successful after 10 tries.
-        verifyTetherInterfaceSucceeds(10);
+        verifyNetworkAddInterfaceSucceeds(10);
     }
 
-    private void runTetherInterfaceWithServiceSpecificException(int expectedTries,
+    private void runNetworkAddInterfaceWithServiceSpecificException(int expectedTries,
             int expectedCode) throws Exception {
         setNetworkAddInterfaceOutcome(new ServiceSpecificException(expectedCode), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                    20 /* maxAttempts */, 0 /* pollingIntervalMs */);
             fail("Expect throw ServiceSpecificException");
         } catch (ServiceSpecificException e) {
             assertEquals(e.errorCode, expectedCode);
@@ -173,11 +168,12 @@
         reset(mNetd);
     }
 
-    private void runTetherInterfaceWithRemoteException(int expectedTries) throws Exception {
+    private void runNetworkAddInterfaceWithRemoteException(int expectedTries) throws Exception {
         setNetworkAddInterfaceOutcome(new RemoteException(), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                    20 /* maxAttempts */, 0 /* pollingIntervalMs */);
             fail("Expect throw RemoteException");
         } catch (RemoteException e) { }
 
@@ -186,40 +182,37 @@
     }
 
     private void verifyNetworkAddInterfaceFails(int expectedTries) throws Exception {
-        verify(mNetd).tetherInterfaceAdd(IFACE);
-        verify(mNetd, times(expectedTries)).networkAddInterface(LOCAL_NET_ID, IFACE);
-        verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
+        verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
         verifyNoMoreInteractions(mNetd);
     }
 
-    private void verifyTetherInterfaceSucceeds(int expectedTries) throws Exception {
+    private void verifyNetworkAddInterfaceSucceeds(int expectedTries) throws Exception {
         setNetworkAddInterfaceOutcome(null, expectedTries);
 
-        NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX);
-        verify(mNetd).tetherInterfaceAdd(IFACE);
-        verify(mNetd, times(expectedTries)).networkAddInterface(LOCAL_NET_ID, IFACE);
-        verify(mNetd, times(2)).networkAddRoute(eq(LOCAL_NET_ID), eq(IFACE), any(), any());
+        NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                20 /* maxAttempts */, 0 /* pollingIntervalMs */);
+        verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
         verifyNoMoreInteractions(mNetd);
         reset(mNetd);
     }
 
     @Test
-    public void testTetherInterfaceFailOnNetworkAddInterface() throws Exception {
+    public void testFailOnNetworkAddInterface() throws Exception {
         // Test throwing ServiceSpecificException with EBUSY failure.
-        runTetherInterfaceWithServiceSpecificException(20, EBUSY);
+        runNetworkAddInterfaceWithServiceSpecificException(20, EBUSY);
 
         // Test throwing ServiceSpecificException with unexpectedError.
         final int unexpectedError = 999;
-        runTetherInterfaceWithServiceSpecificException(1, unexpectedError);
+        runNetworkAddInterfaceWithServiceSpecificException(1, unexpectedError);
 
         // Test throwing ServiceSpecificException with unexpectedError after 7 tries.
-        runTetherInterfaceWithServiceSpecificException(7, unexpectedError);
+        runNetworkAddInterfaceWithServiceSpecificException(7, unexpectedError);
 
         // Test throwing RemoteException.
-        runTetherInterfaceWithRemoteException(1);
+        runNetworkAddInterfaceWithRemoteException(1);
 
         // Test throwing RemoteException after 3 tries.
-        runTetherInterfaceWithRemoteException(3);
+        runNetworkAddInterfaceWithRemoteException(3);
     }
 
     @Test
diff --git a/staticlibs/device/com/android/net/module/util/BpfDump.java b/staticlibs/device/com/android/net/module/util/BpfDump.java
index 4227194..d79f52e 100644
--- a/staticlibs/device/com/android/net/module/util/BpfDump.java
+++ b/staticlibs/device/com/android/net/module/util/BpfDump.java
@@ -23,6 +23,7 @@
 import android.util.Pair;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
 
 import java.io.PrintWriter;
 import java.nio.ByteBuffer;
@@ -132,17 +133,35 @@
         }
     }
 
+    public static class Dependencies {
+        /**
+         * Call {@link Os#access}
+         */
+        public boolean access(String path, int mode) throws ErrnoException {
+            return Os.access(path, mode);
+        }
+    }
+
     /**
      * Dump the BpfMap status
      */
     public static <K extends Struct, V extends Struct> void dumpMapStatus(IBpfMap<K, V> map,
             PrintWriter pw, String mapName, String path) {
+        dumpMapStatus(map, pw, mapName, path, new Dependencies());
+    }
+
+    /**
+     * Dump the BpfMap status. Only test should use this method directly.
+     */
+    @VisibleForTesting
+    public static <K extends Struct, V extends Struct> void dumpMapStatus(IBpfMap<K, V> map,
+            PrintWriter pw, String mapName, String path, Dependencies deps) {
         if (map != null) {
             pw.println(mapName + ": OK");
             return;
         }
         try {
-            Os.access(path, R_OK);
+            deps.access(path, R_OK);
             pw.println(mapName + ": NULL(map is pinned to " + path + ")");
         } catch (ErrnoException e) {
             pw.println(mapName + ": NULL(map is not pinned to " + path + ": "
diff --git a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index bb95585..2ce5b86 100644
--- a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -33,12 +33,14 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
+import android.os.Build;
 import android.os.RemoteException;
 import android.util.ArrayMap;
 
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
 
 import java.io.PrintWriter;
 import java.net.Inet4Address;
@@ -67,9 +69,6 @@
     // WARNING: Keep in sync with chooseDownstreamAddress
     public static final int PREFIX_LENGTH = 24;
 
-    public static final String TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION =
-            "tether_force_random_prefix_base_selection";
-
     // Upstream monitor would be stopped when tethering is down. When tethering restart, downstream
     // address may be requested before coordinator get current upstream notification. To ensure
     // coordinator do not select conflict downstream prefix, mUpstreamPrefixMap would not be cleared
@@ -258,8 +257,15 @@
         return null;
     }
 
+    // TODO: Remove this method when SdkLevel.isAtLeastB() is fixed, aosp is at sdk level 36 or use
+    //  NetworkStackUtils.isAtLeast25Q2 when it is moved to a static lib.
+    public static boolean isAtLeast25Q2() {
+        return SdkLevel.isAtLeastB()  || (SdkLevel.isAtLeastV()
+                && "Baklava".equals(Build.VERSION.CODENAME));
+    }
+
     private int getRandomPrefixIndex() {
-        if (!mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)) return 0;
+        if (!isAtLeast25Q2()) return 0;
 
         final int random = getRandomInt() & 0xffffff;
         // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
diff --git a/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
new file mode 100644
index 0000000..2d95223
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_ERROR;
+import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.ParcelFileDescriptor;
+import android.os.SystemClock;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.CloseGuard;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+import java.util.PriorityQueue;
+
+/**
+ * Represents a realtime scheduler object used for scheduling tasks with precise delays.
+ * Compared to {@link Handler#postDelayed}, this class offers enhanced accuracy for delayed
+ * callbacks by accounting for periods when the device is in deep sleep.
+ *
+ *  <p> This class is designed for use exclusively from the handler thread.
+ *
+ * **Usage Examples:**
+ *
+ * ** Scheduling recurring tasks with the same RealtimeScheduler **
+ *
+ * ```java
+ * // Create a RealtimeScheduler
+ * final RealtimeScheduler scheduler = new RealtimeScheduler(handler);
+ *
+ * // Schedule a new task with a delay.
+ * scheduler.postDelayed(() -> taskToExecute(), delayTime);
+ *
+ * // Once the delay has elapsed, and the task is running, schedule another task.
+ * scheduler.postDelayed(() -> anotherTaskToExecute(), anotherDelayTime);
+ *
+ * // Remember to close the RealtimeScheduler after all tasks have finished running.
+ * scheduler.close();
+ * ```
+ */
+public class RealtimeScheduler {
+    private static final String TAG = RealtimeScheduler.class.getSimpleName();
+    // EVENT_ERROR may be generated even if not specified, as per its javadoc.
+    private static final int FD_EVENTS = EVENT_INPUT | EVENT_ERROR;
+    private final CloseGuard mGuard = new CloseGuard();
+    @NonNull
+    private final Handler mHandler;
+    @NonNull
+    private final MessageQueue mQueue;
+    @NonNull
+    private final ParcelFileDescriptor mParcelFileDescriptor;
+    private final int mFdInt;
+
+    private final PriorityQueue<Task> mTaskQueue;
+
+    /**
+     * An abstract class for defining tasks that can be executed using a {@link Handler}.
+     */
+    private abstract static class Task implements Comparable<Task> {
+        private final long mRunTimeMs;
+        private final long mCreatedTimeNs = SystemClock.elapsedRealtimeNanos();
+
+        /**
+         * create a task with a run time
+         */
+        Task(long runTimeMs) {
+            mRunTimeMs = runTimeMs;
+        }
+
+        /**
+         * Executes the task using the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for executing the task.
+         */
+        abstract void post(Handler handler);
+
+        @Override
+        public int compareTo(@NonNull Task o) {
+            if (mRunTimeMs != o.mRunTimeMs) {
+                return Long.compare(mRunTimeMs, o.mRunTimeMs);
+            }
+            return Long.compare(mCreatedTimeNs, o.mCreatedTimeNs);
+        }
+
+        /**
+         * Returns the run time of the task.
+         */
+        public long getRunTimeMs() {
+            return mRunTimeMs;
+        }
+    }
+
+    /**
+     * A task that sends a {@link Message} using a {@link Handler}.
+     */
+    private static class MessageTask extends Task {
+        private final Message mMessage;
+
+        MessageTask(Message message, long runTimeMs) {
+            super(runTimeMs);
+            mMessage = message;
+        }
+
+        /**
+         * Sends the {@link Message} using the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for sending the message.
+         */
+        @Override
+        public void post(Handler handler) {
+            handler.sendMessage(mMessage);
+        }
+    }
+
+    /**
+     * A task that posts a {@link Runnable} to a {@link Handler}.
+     */
+    private static class RunnableTask extends Task {
+        private final Runnable mRunnable;
+
+        RunnableTask(Runnable runnable, long runTimeMs) {
+            super(runTimeMs);
+            mRunnable = runnable;
+        }
+
+        /**
+         * Posts the {@link Runnable} to the provided {@link Handler}.
+         *
+         * @param handler The {@link Handler} to use for posting the runnable.
+         */
+        @Override
+        public void post(Handler handler) {
+            handler.post(mRunnable);
+        }
+    }
+
+    /**
+     * The RealtimeScheduler constructor
+     *
+     * Note: The constructor is currently safe to call on another thread because it only sets final
+     * members and registers the event to be called on the handler.
+     */
+    public RealtimeScheduler(@NonNull Handler handler) {
+        mFdInt = TimerFdUtils.createTimerFileDescriptor();
+        mParcelFileDescriptor = ParcelFileDescriptor.adoptFd(mFdInt);
+        mHandler = handler;
+        mQueue = handler.getLooper().getQueue();
+        mTaskQueue = new PriorityQueue<>();
+        registerFdEventListener();
+
+        mGuard.open("close");
+    }
+
+    private boolean enqueueTask(@NonNull Task task, long delayMs) {
+        ensureRunningOnCorrectThread();
+        if (delayMs <= 0L) {
+            task.post(mHandler);
+            return true;
+        }
+        if (mTaskQueue.isEmpty() || task.compareTo(mTaskQueue.peek()) < 0) {
+            if (!TimerFdUtils.setExpirationTime(mFdInt, delayMs)) {
+                return false;
+            }
+        }
+        mTaskQueue.add(task);
+        return true;
+    }
+
+    /**
+     * Set a runnable to be executed after a specified delay.
+     *
+     * If delayMs is less than or equal to 0, the runnable will be executed immediately.
+     *
+     * @param runnable the runnable to be executed
+     * @param delayMs the delay time in milliseconds
+     * @return true if the task is scheduled successfully, false otherwise.
+     */
+    public boolean postDelayed(@NonNull Runnable runnable, long delayMs) {
+        return enqueueTask(new RunnableTask(runnable, SystemClock.elapsedRealtime() + delayMs),
+                delayMs);
+    }
+
+    /**
+     * Remove a scheduled runnable.
+     *
+     * @param runnable the runnable to be removed
+     */
+    public void removeDelayedRunnable(@NonNull Runnable runnable) {
+        ensureRunningOnCorrectThread();
+        mTaskQueue.removeIf(task -> task instanceof RunnableTask
+                && ((RunnableTask) task).mRunnable == runnable);
+    }
+
+    /**
+     * Set a message to be sent after a specified delay.
+     *
+     * If delayMs is less than or equal to 0, the message will be sent immediately.
+     *
+     * @param msg the message to be sent
+     * @param delayMs the delay time in milliseconds
+     * @return true if the message is scheduled successfully, false otherwise.
+     */
+    public boolean sendDelayedMessage(Message msg, long delayMs) {
+
+        return enqueueTask(new MessageTask(msg, SystemClock.elapsedRealtime() + delayMs), delayMs);
+    }
+
+    private static boolean isMessageTask(Task task, int what) {
+        if (task instanceof MessageTask && ((MessageTask) task).mMessage.what == what) {
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Remove a scheduled message.
+     *
+     * @param what the message to be removed
+     */
+    public void removeDelayedMessage(int what) {
+        ensureRunningOnCorrectThread();
+        mTaskQueue.removeIf(task -> isMessageTask(task, what));
+    }
+
+    /**
+     * Check if there is a scheduled message.
+     *
+     * @param what the message to be checked
+     * @return true if there is a target message, false otherwise.
+     */
+    public boolean hasDelayedMessage(int what) {
+        ensureRunningOnCorrectThread();
+
+        for (Task task : mTaskQueue) {
+            if (isMessageTask(task, what)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Close the RealtimeScheduler. This implementation closes the underlying
+     * OS resources allocated to represent this stream.
+     */
+    public void close() {
+        ensureRunningOnCorrectThread();
+        unregisterAndDestroyFd();
+    }
+
+    private void registerFdEventListener() {
+        mQueue.addOnFileDescriptorEventListener(
+                mParcelFileDescriptor.getFileDescriptor(),
+                FD_EVENTS,
+                (fd, events) -> {
+                    if (!isRunning()) {
+                        return 0;
+                    }
+                    if ((events & EVENT_ERROR) != 0) {
+                        Log.wtf(TAG, "Got EVENT_ERROR from FileDescriptorEventListener.");
+                        return 0;
+                    }
+                    if ((events & EVENT_INPUT) != 0) {
+                        handleExpiration();
+                    }
+                    return FD_EVENTS;
+                });
+    }
+
+    private boolean isRunning() {
+        return mParcelFileDescriptor.getFileDescriptor().valid();
+    }
+
+    private void handleExpiration() {
+        // The data from the FileDescriptor must be read after the timer expires. Otherwise,
+        // expiration callbacks will continue to be sent, notifying of unread data. The content(the
+        // number of expirations) can be ignored, as the callback is the only item of interest.
+        // Refer to https://man7.org/linux/man-pages/man2/timerfd_create.2.html
+        // read(2)
+        //         If the timer has already expired one or more times since
+        //         its settings were last modified using timerfd_settime(),
+        //         or since the last successful read(2), then the buffer
+        //         given to read(2) returns an unsigned 8-byte integer
+        //         (uint64_t) containing the number of expirations that have
+        //         occurred.  (The returned value is in host byte order—that
+        //         is, the native byte order for integers on the host
+        //         machine.)
+        final byte[] readBuffer = new byte[8];
+        try {
+            Os.read(mParcelFileDescriptor.getFileDescriptor(), readBuffer, 0, readBuffer.length);
+        } catch (IOException | ErrnoException exception) {
+            Log.wtf(TAG, "Read FileDescriptor failed. ", exception);
+        }
+
+        long currentTimeMs = SystemClock.elapsedRealtime();
+        while (!mTaskQueue.isEmpty()) {
+            final Task task = mTaskQueue.peek();
+            currentTimeMs = SystemClock.elapsedRealtime();
+            if (currentTimeMs < task.getRunTimeMs()) {
+                break;
+            }
+            task.post(mHandler);
+            mTaskQueue.poll();
+        }
+
+        if (!mTaskQueue.isEmpty()) {
+            // Using currentTimeMs ensures that the calculated expiration time
+            // is always positive.
+            if (!TimerFdUtils.setExpirationTime(mFdInt,
+                    mTaskQueue.peek().getRunTimeMs() - currentTimeMs)) {
+                // If setting the expiration time fails, clear the task queue.
+                Log.wtf(TAG, "Failed to set expiration time");
+                mTaskQueue.clear();
+            }
+        }
+    }
+
+    private void unregisterAndDestroyFd() {
+        if (mGuard != null) {
+            mGuard.close();
+        }
+
+        mQueue.removeOnFileDescriptorEventListener(mParcelFileDescriptor.getFileDescriptor());
+        try {
+            mParcelFileDescriptor.close();
+        } catch (IOException exception) {
+            Log.e(TAG, "close ParcelFileDescriptor failed. ", exception);
+        }
+    }
+
+    private void ensureRunningOnCorrectThread() {
+        if (mHandler.getLooper() != Looper.myLooper()) {
+            throw new IllegalStateException(
+                    "Not running on Handler thread: " + Thread.currentThread().getName());
+        }
+    }
+
+    @SuppressWarnings("Finalize")
+    @Override
+    protected void finalize() throws Throwable {
+        if (mGuard != null) {
+            mGuard.warnIfOpen();
+        }
+        super.finalize();
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/ServiceConnectivityJni.java b/staticlibs/device/com/android/net/module/util/ServiceConnectivityJni.java
new file mode 100644
index 0000000..1d3561a
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/ServiceConnectivityJni.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import android.annotation.NonNull;
+import android.system.ErrnoException;
+
+/**
+ * Contains JNI functions for use in service-connectivity
+ */
+public class ServiceConnectivityJni {
+    static {
+        final String libName = JniUtil.getJniLibraryName(ServiceConnectivityJni.class.getPackage());
+        if (libName.equals("android_net_connectivity_com_android_net_module_util_jni")) {
+            // This library is part of service-connectivity.jar when in the system server,
+            // so libservice-connectivity.so is the library to load.
+            System.loadLibrary("service-connectivity");
+        } else {
+            System.loadLibrary(libName);
+        }
+    }
+
+    /**
+     * Create a timerfd.
+     *
+     * @throws ErrnoException if the timerfd creation is failed.
+     */
+    public static native int createTimerFd() throws ErrnoException;
+
+    /**
+     * Set given time to the timerfd.
+     *
+     * @param timeMs target time
+     * @throws ErrnoException if setting expiration time is failed.
+     */
+    public static native void setTimerFdTime(int fd, long timeMs) throws ErrnoException;
+
+    /** Create tun/tap interface */
+    public static native int createTunTap(boolean isTun, boolean hasCarrier,
+            boolean setIffMulticast, @NonNull String iface);
+
+    /** Enable carrier on tun/tap interface */
+    public static native void setTunTapCarrierEnabled(@NonNull String iface, int tunFd,
+            boolean enabled);
+
+    /** Bring up tun/tap interface */
+    public static native void bringUpInterface(String iface);
+}
diff --git a/staticlibs/device/com/android/net/module/util/SkDestroyListener.java b/staticlibs/device/com/android/net/module/util/SkDestroyListener.java
new file mode 100644
index 0000000..c7c2829
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/SkDestroyListener.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import static android.system.OsConstants.NETLINK_INET_DIAG;
+
+import android.os.Handler;
+
+import com.android.net.module.util.ip.NetlinkMonitor;
+import com.android.net.module.util.netlink.InetDiagMessage;
+import com.android.net.module.util.netlink.NetlinkMessage;
+
+import java.io.PrintWriter;
+import java.util.function.Consumer;
+
+/**
+ * Monitor socket destroy and delete entry from cookie tag bpf map.
+ */
+public class SkDestroyListener extends NetlinkMonitor {
+    private static final int SKNLGRP_INET_TCP_DESTROY = 1;
+    private static final int SKNLGRP_INET_UDP_DESTROY = 2;
+    private static final int SKNLGRP_INET6_TCP_DESTROY = 3;
+    private static final int SKNLGRP_INET6_UDP_DESTROY = 4;
+
+    // TODO: if too many sockets are closed too quickly, this can overflow the socket buffer, and
+    // some entries in mCookieTagMap will not be freed. In order to fix this it would be needed to
+    // periodically dump all sockets and remove the tag entries for sockets that have been closed.
+    // For now, set a large-enough buffer that hundreds of sockets can be closed without getting
+    // ENOBUFS and leaking mCookieTagMap entries.
+    private static final int SOCK_RCV_BUF_SIZE = 512 * 1024;
+
+    private final Consumer<InetDiagMessage> mSkDestroyCallback;
+
+    /**
+     * Return SkDestroyListener that monitor both TCP and UDP socket destroy
+     *
+     * @param consumer The consumer that processes InetDiagMessage
+     * @param handler The Handler on which to poll for messages
+     * @param log A SharedLog to log to.
+     * @return SkDestroyListener
+     */
+    public static SkDestroyListener makeSkDestroyListener(final Consumer<InetDiagMessage> consumer,
+            final Handler handler, final SharedLog log) {
+        return makeSkDestroyListener(consumer, true /* monitorTcpSocket */,
+                true /* monitorUdpSocket */, handler, log);
+    }
+
+    /**
+     * Return SkDestroyListener that monitor socket destroy
+     *
+     * @param consumer The consumer that processes InetDiagMessage
+     * @param monitorTcpSocket {@code true} to monitor TCP socket destroy
+     * @param monitorUdpSocket {@code true} to monitor UDP socket destroy
+     * @param handler The Handler on which to poll for messages
+     * @param log A SharedLog to log to.
+     * @return SkDestroyListener
+     */
+    public static SkDestroyListener makeSkDestroyListener(final Consumer<InetDiagMessage> consumer,
+            final boolean monitorTcpSocket, final boolean monitorUdpSocket,
+            final Handler handler, final SharedLog log) {
+        if (!monitorTcpSocket && !monitorUdpSocket) {
+            throw new IllegalArgumentException(
+                    "Both monitorTcpSocket and monitorUdpSocket can not be false");
+        }
+        int bindGroups = 0;
+        if (monitorTcpSocket) {
+            bindGroups |= 1 << (SKNLGRP_INET_TCP_DESTROY - 1)
+                    | 1 << (SKNLGRP_INET6_TCP_DESTROY - 1);
+        }
+        if (monitorUdpSocket) {
+            bindGroups |= 1 << (SKNLGRP_INET_UDP_DESTROY - 1)
+                    | 1 << (SKNLGRP_INET6_UDP_DESTROY - 1);
+        }
+        return new SkDestroyListener(consumer, bindGroups, handler, log);
+    }
+
+    private SkDestroyListener(final Consumer<InetDiagMessage> consumer, final int bindGroups,
+            final Handler handler, final SharedLog log) {
+        super(handler, log, "SkDestroyListener", NETLINK_INET_DIAG,
+                bindGroups, SOCK_RCV_BUF_SIZE);
+        mSkDestroyCallback = consumer;
+    }
+
+    @Override
+    public void processNetlinkMessage(final NetlinkMessage nlMsg, final long whenMs) {
+        if (!(nlMsg instanceof InetDiagMessage)) {
+            mLog.e("Received non InetDiagMessage");
+            return;
+        }
+        mSkDestroyCallback.accept((InetDiagMessage) nlMsg);
+    }
+
+    /**
+     * Dump the contents of SkDestroyListener log.
+     */
+    public void dump(PrintWriter pw) {
+        mLog.reverseDump(pw);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/TcUtils.java b/staticlibs/device/com/android/net/module/util/TcUtils.java
index a6b222f..eb119c8 100644
--- a/staticlibs/device/com/android/net/module/util/TcUtils.java
+++ b/staticlibs/device/com/android/net/module/util/TcUtils.java
@@ -16,6 +16,8 @@
 
 package com.android.net.module.util;
 
+import androidx.annotation.NonNull;
+
 import java.io.IOException;
 
 /**
@@ -33,7 +35,7 @@
      * @return true if the interface uses an ethernet L2 header.
      * @throws IOException
      */
-    public static native boolean isEthernet(String iface) throws IOException;
+    public static native boolean isEthernet(@NonNull String iface) throws IOException;
 
     /**
      * Attach a tc bpf filter.
diff --git a/staticlibs/device/com/android/net/module/util/TimerFdUtils.java b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
index c7ed911..cce7efd 100644
--- a/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
+++ b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
@@ -16,50 +16,22 @@
 
 package com.android.net.module.util;
 
-import android.os.Process;
+import android.system.ErrnoException;
 import android.util.Log;
 
-import java.io.IOException;
-
 /**
  * Contains mostly timerfd functionality.
  */
 public class TimerFdUtils {
-    static {
-        final String jniLibName = JniUtil.getJniLibraryName(TimerFdUtils.class.getPackage());
-        if (jniLibName.equals("android_net_connectivity_com_android_net_module_util_jni")) {
-            // This library is part of service-connectivity.jar when in the system server,
-            // so libservice-connectivity.so is the library to load.
-            System.loadLibrary("service-connectivity");
-        } else {
-            System.loadLibrary(jniLibName);
-        }
-    }
-
     private static final String TAG = TimerFdUtils.class.getSimpleName();
 
     /**
-     * Create a timerfd.
-     *
-     * @throws IOException if the timerfd creation is failed.
-     */
-    private static native int createTimerFd() throws IOException;
-
-    /**
-     * Set given time to the timerfd.
-     *
-     * @param timeMs target time
-     * @throws IOException if setting expiration time is failed.
-     */
-    private static native void setTime(int fd, long timeMs) throws IOException;
-
-    /**
      * Create a timerfd
      */
     static int createTimerFileDescriptor() {
         try {
-            return createTimerFd();
-        } catch (IOException e) {
+            return ServiceConnectivityJni.createTimerFd();
+        } catch (ErrnoException e) {
             Log.e(TAG, "createTimerFd failed", e);
             return -1;
         }
@@ -68,10 +40,10 @@
     /**
      * Set expiration time to timerfd
      */
-    static boolean setExpirationTime(int id, long expirationTimeMs) {
+    static boolean setExpirationTime(int fd, long expirationTimeMs) {
         try {
-            setTime(id, expirationTimeMs);
-        } catch (IOException e) {
+            ServiceConnectivityJni.setTimerFdTime(fd, expirationTimeMs);
+        } catch (ErrnoException e) {
             Log.e(TAG, "setExpirationTime failed", e);
             return false;
         }
diff --git a/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java b/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java
deleted file mode 100644
index dbbccc5..0000000
--- a/staticlibs/device/com/android/net/module/util/TimerFileDescriptor.java
+++ /dev/null
@@ -1,254 +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 com.android.net.module.util;
-
-import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_ERROR;
-import static android.os.MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT;
-
-import android.os.Handler;
-import android.os.Looper;
-import android.os.Message;
-import android.os.MessageQueue;
-import android.os.ParcelFileDescriptor;
-import android.util.CloseGuard;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-
-/**
- * Represents a Timer file descriptor object used for scheduling tasks with precise delays.
- * Compared to {@link Handler#postDelayed}, this class offers enhanced accuracy for delayed
- * callbacks by accounting for periods when the device is in deep sleep.
- *
- *  <p> This class is designed for use exclusively from the handler thread.
- *
- * **Usage Examples:**
- *
- * ** Scheduling recurring tasks with the same TimerFileDescriptor **
- *
- * ```java
- * // Create a TimerFileDescriptor
- * final TimerFileDescriptor timerFd = new TimerFileDescriptor(handler);
- *
- * // Schedule a new task with a delay.
- * timerFd.setDelayedTask(() -> taskToExecute(), delayTime);
- *
- * // Once the delay has elapsed, and the task is running, schedule another task.
- * timerFd.setDelayedTask(() -> anotherTaskToExecute(), anotherDelayTime);
- *
- * // Remember to close the TimerFileDescriptor after all tasks have finished running.
- * timerFd.close();
- * ```
- */
-public class TimerFileDescriptor {
-    private static final String TAG = TimerFileDescriptor.class.getSimpleName();
-    // EVENT_ERROR may be generated even if not specified, as per its javadoc.
-    private static final int FD_EVENTS = EVENT_INPUT | EVENT_ERROR;
-    private final CloseGuard mGuard = new CloseGuard();
-    @NonNull
-    private final Handler mHandler;
-    @NonNull
-    private final MessageQueue mQueue;
-    @NonNull
-    private final ParcelFileDescriptor mParcelFileDescriptor;
-    private final int mFdInt;
-    @Nullable
-    private ITask mTask;
-
-    /**
-     * An interface for defining tasks that can be executed using a {@link Handler}.
-     */
-    public interface ITask {
-        /**
-         * Executes the task using the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for executing the task.
-         */
-        void post(Handler handler);
-    }
-
-    /**
-     * A task that sends a {@link Message} using a {@link Handler}.
-     */
-    public static class MessageTask implements ITask {
-        private final Message mMessage;
-
-        public MessageTask(Message message) {
-            mMessage = message;
-        }
-
-        /**
-         * Sends the {@link Message} using the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for sending the message.
-         */
-        @Override
-        public void post(Handler handler) {
-            handler.sendMessage(mMessage);
-        }
-    }
-
-    /**
-     * A task that posts a {@link Runnable} to a {@link Handler}.
-     */
-    public static class RunnableTask implements ITask {
-        private final Runnable mRunnable;
-
-        public RunnableTask(Runnable runnable) {
-            mRunnable = runnable;
-        }
-
-        /**
-         * Posts the {@link Runnable} to the provided {@link Handler}.
-         *
-         * @param handler The {@link Handler} to use for posting the runnable.
-         */
-        @Override
-        public void post(Handler handler) {
-            handler.post(mRunnable);
-        }
-    }
-
-    /**
-     * TimerFileDescriptor constructor
-     *
-     * Note: The constructor is currently safe to call on another thread because it only sets final
-     * members and registers the event to be called on the handler.
-     */
-    public TimerFileDescriptor(@NonNull Handler handler) {
-        mFdInt = TimerFdUtils.createTimerFileDescriptor();
-        mParcelFileDescriptor = ParcelFileDescriptor.adoptFd(mFdInt);
-        mHandler = handler;
-        mQueue = handler.getLooper().getQueue();
-        registerFdEventListener();
-
-        mGuard.open("close");
-    }
-
-    /**
-     * Set a task to be executed after a specified delay.
-     *
-     * <p> A task can only be scheduled once at a time. Cancel previous scheduled task before the
-     *     new task is scheduled.
-     *
-     * @param task the task to be executed
-     * @param delayMs the delay time in milliseconds
-     * @throws IllegalArgumentException if try to replace the current scheduled task
-     * @throws IllegalArgumentException if the delay time is less than 0
-     */
-    public void setDelayedTask(@NonNull ITask task, long delayMs) {
-        ensureRunningOnCorrectThread();
-        if (mTask != null) {
-            throw new IllegalArgumentException("task is already scheduled");
-        }
-        if (delayMs <= 0L) {
-            task.post(mHandler);
-            return;
-        }
-
-        if (TimerFdUtils.setExpirationTime(mFdInt, delayMs)) {
-            mTask = task;
-        }
-    }
-
-    /**
-     * Cancel the scheduled task.
-     */
-    public void cancelTask() {
-        ensureRunningOnCorrectThread();
-        if (mTask == null) return;
-
-        TimerFdUtils.setExpirationTime(mFdInt, 0 /* delayMs */);
-        mTask = null;
-    }
-
-    /**
-     * Check if there is a scheduled task.
-     */
-    public boolean hasDelayedTask() {
-        ensureRunningOnCorrectThread();
-        return mTask != null;
-    }
-
-    /**
-     * Close the TimerFileDescriptor. This implementation closes the underlying
-     * OS resources allocated to represent this stream.
-     */
-    public void close() {
-        ensureRunningOnCorrectThread();
-        unregisterAndDestroyFd();
-    }
-
-    private void registerFdEventListener() {
-        mQueue.addOnFileDescriptorEventListener(
-                mParcelFileDescriptor.getFileDescriptor(),
-                FD_EVENTS,
-                (fd, events) -> {
-                    if (!isRunning()) {
-                        return 0;
-                    }
-                    if ((events & EVENT_INPUT) != 0) {
-                        handleExpiration();
-                    }
-                    return FD_EVENTS;
-                });
-    }
-
-    private boolean isRunning() {
-        return mParcelFileDescriptor.getFileDescriptor().valid();
-    }
-
-    private void handleExpiration() {
-        // Execute the task
-        if (mTask != null) {
-            mTask.post(mHandler);
-            mTask = null;
-        }
-    }
-
-    private void unregisterAndDestroyFd() {
-        if (mGuard != null) {
-            mGuard.close();
-        }
-
-        mQueue.removeOnFileDescriptorEventListener(mParcelFileDescriptor.getFileDescriptor());
-        try {
-            mParcelFileDescriptor.close();
-        } catch (IOException exception) {
-            Log.e(TAG, "close ParcelFileDescriptor failed. ", exception);
-        }
-    }
-
-    private void ensureRunningOnCorrectThread() {
-        if (mHandler.getLooper() != Looper.myLooper()) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
-    @SuppressWarnings("Finalize")
-    @Override
-    protected void finalize() throws Throwable {
-        if (mGuard != null) {
-            mGuard.warnIfOpen();
-        }
-        super.finalize();
-    }
-}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java b/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
index fecaa09..c9a89ec 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/InetDiagMessage.java
@@ -309,16 +309,18 @@
     }
 
     private static void sendNetlinkDestroyRequest(FileDescriptor fd, int proto,
-            InetDiagMessage diagMsg) throws InterruptedIOException, ErrnoException {
+            StructInetDiagSockId id, short family, int state)
+            throws InterruptedIOException, ErrnoException {
+        // TODO: Investigate if it's fine to always set 0 to state and remove state from the arg
         final byte[] destroyMsg = InetDiagMessage.inetDiagReqV2(
                 proto,
-                diagMsg.inetDiagMsg.id,
-                diagMsg.inetDiagMsg.idiag_family,
+                id,
+                family,
                 SOCK_DESTROY,
                 (short) (StructNlMsgHdr.NLM_F_REQUEST | StructNlMsgHdr.NLM_F_ACK),
                 0 /* pad */,
                 0 /* idiagExt */,
-                1 << diagMsg.inetDiagMsg.idiag_state
+                state
         );
         NetlinkUtils.sendMessage(fd, destroyMsg, 0, destroyMsg.length, IO_TIMEOUT_MS);
         NetlinkUtils.receiveNetlinkAck(fd);
@@ -343,7 +345,8 @@
         Consumer<InetDiagMessage> handleNlDumpMsg = (diagMsg) -> {
             if (filter.test(diagMsg)) {
                 try {
-                    sendNetlinkDestroyRequest(destroyFd, proto, diagMsg);
+                    sendNetlinkDestroyRequest(destroyFd, proto, diagMsg.inetDiagMsg.id,
+                            diagMsg.inetDiagMsg.idiag_family, 1 << diagMsg.inetDiagMsg.idiag_state);
                     destroyedSockets.getAndIncrement();
                 } catch (InterruptedIOException | ErrnoException e) {
                     if (!(e instanceof ErrnoException
@@ -484,6 +487,30 @@
         Log.d(TAG, "Destroyed live tcp sockets for uids=" + ownerUids + " in " + durationMs + "ms");
     }
 
+    /**
+     * Close the udp socket which can be uniquely identified with the cookie and other information.
+     */
+    public static void destroyUdpSocket(final InetSocketAddress src, final InetSocketAddress dst,
+            final int ifIndex, final long cookie)
+            throws ErrnoException, SocketException, InterruptedIOException {
+        FileDescriptor fd = null;
+
+        try {
+            fd = NetlinkUtils.createNetLinkInetDiagSocket();
+            connectToKernel(fd);
+            final int family = (src.getAddress() instanceof Inet6Address) ? AF_INET6 : AF_INET;
+            final StructInetDiagSockId id = new StructInetDiagSockId(
+                    src,
+                    dst,
+                    ifIndex,
+                    cookie
+            );
+            sendNetlinkDestroyRequest(fd, IPPROTO_UDP, id, (short) family, 0 /* state */);
+        } finally {
+            closeSocketQuietly(fd);
+        }
+    }
+
     @Override
     public String toString() {
         return "InetDiagMessage{ "
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
index 541a375..2420e7a 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
@@ -55,6 +55,7 @@
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
 import java.util.function.Consumer;
@@ -82,7 +83,7 @@
     public static final int INET_DIAG_INFO = 2;
     public static final int INET_DIAG_MARK = 15;
 
-    public static final long IO_TIMEOUT_MS = 300L;
+    public static final long IO_TIMEOUT_MS = 3000L;
 
     public static final int DEFAULT_RECV_BUFSIZE = 8 * 1024;
     public static final int SOCKET_RECV_BUFSIZE = 64 * 1024;
@@ -469,4 +470,60 @@
             // Nothing we can do here
         }
     }
+
+    /**
+     * Sends a netlink request to set flags for given interface
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param flags power-of-two integer flags to set or unset. A flag to set should be passed as
+     *        is as a power-of-two value, and a flag to remove should be passed inversed as -1 with
+     *        a single bit down. For example: IFF_UP, ~IFF_BROADCAST...
+     * @return true if the request finished successfully, otherwise false.
+     */
+    public static boolean setInterfaceFlags(@NonNull String interfaceName, int... flags) {
+        final RtNetlinkLinkMessage ntMsg =
+                RtNetlinkLinkMessage.createSetFlagsMessage(interfaceName, /*seqNo*/ 0, flags);
+        if (ntMsg == null) {
+            Log.e(TAG, "Failed to create message to set interface flags for interface "
+                    + interfaceName + ", input flags are: " + Arrays.toString(flags));
+            return false;
+        }
+        final byte[] msg = ntMsg.pack(ByteOrder.nativeOrder());
+        try {
+            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, msg);
+            return true;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to set flags for: " + interfaceName, e);
+            return false;
+        }
+    }
+
+    /**
+     * Sends a netlink request to set MTU for given interface
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param mtu MTU value to set for the interface.
+     * @return true if the request finished successfully, otherwise false.
+     */
+    public static boolean setInterfaceMtu(@NonNull String interfaceName, int mtu) {
+        if (mtu < 68) {
+            Log.e(TAG, "Invalid mtu: " + mtu + ", mtu should be greater than 68 referring RFC791");
+            return false;
+        }
+        final RtNetlinkLinkMessage ntMsg =
+                RtNetlinkLinkMessage.createSetMtuMessage(interfaceName, /*seqNo*/ 0, mtu);
+        if (ntMsg == null) {
+            Log.e(TAG, "Failed to create message to set MTU to " + mtu
+                    + "for interface " + interfaceName);
+            return false;
+        }
+        final byte[] msg = ntMsg.pack(ByteOrder.nativeOrder());
+        try {
+            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, msg);
+            return true;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Failed to set MTU to " + mtu + " for: " + interfaceName, e);
+            return false;
+        }
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
index 037d95f..5d49fa3 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
@@ -22,7 +22,6 @@
 import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTM_GETLINK;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
-import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST;
 import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST_ACK;
 
 import android.net.MacAddress;
@@ -307,11 +306,94 @@
         }
 
         return RtNetlinkLinkMessage.build(
-                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST, sequenceNumber),
+                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST_ACK, sequenceNumber),
                 new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
                 DEFAULT_MTU, null, null);
     }
 
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance that can be used to set the flags of a
+     * network interface.
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param sequenceNumber The sequence number for the Netlink message.
+     * @param flags power-of-two integer flags to set or unset. A flag to set should be passed as
+     *        is as a power-of-two value, and a flag to remove should be passed inversed as -1 with
+     *        a single bit down. For example: IFF_UP, ~IFF_BROADCAST...
+     * @return An `RtNetlinkLinkMessage` instance representing the request to query the interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetFlagsMessage(@NonNull String interfaceName,
+            int sequenceNumber, int... flags) {
+        return createSetFlagsMessage(
+                interfaceName, sequenceNumber, new OsAccess(), flags);
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetFlagsMessage(
+            @NonNull String interfaceName, int sequenceNumber, @NonNull OsAccess osAccess,
+            int... flags) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        int flagsBits = 0;
+        int changeBits = 0;
+        for (int f : flags) {
+            if (Integer.bitCount(f) == 1) {
+                flagsBits |= f;
+                changeBits |= f;
+            } else if (Integer.bitCount(~f) == 1) {
+                flagsBits &= f;
+                changeBits |= ~f;
+            } else {
+                return null;
+            }
+        }
+        // RTM_NEWLINK is used here for create, modify, or notify changes about a internet
+        // interface, including change in administrative state. While RTM_SETLINK is used to
+        // modify an existing link rather than creating a new one.
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(
+                        /*payloadLen*/ 0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, /*type*/ 0, interfaceIndex,
+                        flagsBits, changeBits),
+                DEFAULT_MTU, /*hardwareAddress*/ null, /*interfaceName*/ null);
+    }
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance that can be used to set the MTU of a
+     * network interface.
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param sequenceNumber The sequence number for the Netlink message.
+     * @param mtu MTU value to set for the interface.
+     * @return An `RtNetlinkLinkMessage` instance representing the request to query the interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetMtuMessage(@NonNull String interfaceName,
+            int sequenceNumber, int mtu) {
+        return createSetMtuMessage(
+            interfaceName, sequenceNumber, mtu, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetMtuMessage(@NonNull String interfaceName,
+            int sequenceNumber, int mtu, @NonNull OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+        return RtNetlinkLinkMessage.build(
+            new StructNlMsgHdr(/*payloadLen*/ 0, RTM_NEWLINK, NLM_F_REQUEST_ACK , sequenceNumber),
+            new StructIfinfoMsg((short) AF_UNSPEC, /*type*/ 0, interfaceIndex,
+                /*flags*/ 0, /*change*/ 0),
+            mtu, /*hardwareAddress*/ null, /*interfaceName*/ null);
+    }
+
     @Override
     public String toString() {
         return "RtNetlinkLinkMessage{ "
diff --git a/staticlibs/framework/com/android/net/module/util/CollectionUtils.java b/staticlibs/framework/com/android/net/module/util/CollectionUtils.java
index f3d8c4a..760d849 100644
--- a/staticlibs/framework/com/android/net/module/util/CollectionUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/CollectionUtils.java
@@ -22,6 +22,7 @@
 import android.util.Pair;
 import android.util.SparseArray;
 
+import java.lang.reflect.Array;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -413,4 +414,68 @@
         }
         return -1;
     }
+
+    /**
+     * Concatenates multiple arrays of the same type into a single new array.
+     */
+    public static byte[] concatArrays(@NonNull byte[]... arr) {
+        int size = 0;
+        for (byte[] a : arr) {
+            size += a.length;
+        }
+        final byte[] result = new byte[size];
+        int offset = 0;
+        for (byte[] a : arr) {
+            System.arraycopy(a, 0, result, offset, a.length);
+            offset += a.length;
+        }
+        return result;
+    }
+
+    /**
+     * Concatenates multiple arrays of the same type into a single new array.
+     */
+    public static <T> T[] concatArrays(@NonNull Class<T> clazz, @NonNull T[]... arr) {
+        int size = 0;
+        for (T[] a : arr) {
+            size += a.length;
+        }
+        final T[] result = (T[]) Array.newInstance(clazz, size);
+        int offset = 0;
+        for (T[] a : arr) {
+            System.arraycopy(a, 0, result, offset, a.length);
+            offset += a.length;
+        }
+        return result;
+    }
+
+    /**
+     * Prepends the elements of a variable number of prefixes to an existing array (suffix).
+     */
+    public static byte[] prependArray(@NonNull byte[] suffix, @NonNull byte... prefixes) {
+        return concatArrays(prefixes, suffix);
+    }
+
+    /**
+     * Prepends the elements of a variable number of prefixes to an existing array (suffix).
+     */
+    public static <T> T[] prependArray(@NonNull Class<T> clazz, @NonNull T[] suffix,
+            @NonNull T... prefixes) {
+        return concatArrays(clazz, prefixes, suffix);
+    }
+
+    /**
+     * Appends the elements of a variable number of suffixes to an existing array (prefix).
+     */
+    public static byte[] appendArray(@NonNull byte[] prefix, @NonNull byte... suffixes) {
+        return concatArrays(prefix, suffixes);
+    }
+
+    /**
+     * Appends the elements of a variable number of suffixes to an existing array (prefix).
+     */
+    public static <T> T[] appendArray(@NonNull Class<T> clazz, @NonNull T[] prefix,
+            @NonNull T... suffixes) {
+        return concatArrays(clazz, prefix, suffixes);
+    }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
index 31382bb..96d995a 100644
--- a/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
+++ b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
@@ -125,6 +125,25 @@
     }
 
     /**
+     * Stores a value in the cache if absent, associated with the given key.
+     *
+     * @param key   The key to associate with the value.
+     * @param value The value to store in the cache.
+     * @return The existing value associated with the key, if present; otherwise, null.
+     */
+    @Nullable
+    public V putIfAbsent(@NonNull K key, @NonNull V value) {
+        Objects.requireNonNull(value);
+        synchronized (mMap) {
+            final V existingValue = get(key);
+            if (existingValue == null) {
+                put(key, value);
+            }
+            return existingValue;
+        }
+    }
+
+    /**
      * Clear the cache.
      */
     public void clear() {
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
index 4878334..8d7ae5c 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
@@ -23,6 +23,7 @@
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
+import java.util.List;
 
 /**
  * Network constants used by the network stack.
@@ -153,7 +154,8 @@
             (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
     public static final Inet6Address IPV6_ADDR_ALL_HOSTS_MULTICAST =
             (Inet6Address) InetAddresses.parseNumericAddress("ff02::3");
-
+    public static final Inet6Address IPV6_ADDR_NODE_LOCAL_ALL_NODES_MULTICAST =
+             (Inet6Address) InetAddresses.parseNumericAddress("ff01::1");
     public static final int IPPROTO_FRAGMENT = 44;
 
     /**
@@ -340,6 +342,26 @@
      */
     public static final String TEST_URL_EXPIRATION_TIME = "test_url_expiration_time";
 
+    /**
+     * List of IpPrefix that are local network prefixes.
+     */
+    public static final List<IpPrefix> IPV4_LOCAL_PREFIXES = List.of(
+            new IpPrefix("169.254.0.0/16"), // Link Local
+            new IpPrefix("100.64.0.0/10"),  // CGNAT
+            new IpPrefix("10.0.0.0/8"),     // RFC1918
+            new IpPrefix("172.16.0.0/12"),  // RFC1918
+            new IpPrefix("192.168.0.0/16")  // RFC1918
+    );
+
+    /**
+     * List of IpPrefix that are multicast and broadcast prefixes.
+     */
+    public static final List<IpPrefix> MULTICAST_AND_BROADCAST_PREFIXES = List.of(
+            new IpPrefix("224.0.0.0/4"),               // Multicast
+            new IpPrefix("ff00::/8"),                  // Multicast
+            new IpPrefix("255.255.255.255/32")         // Broadcast
+    );
+
     // TODO: Move to Inet4AddressUtils
     // See aosp/1455936: NetworkStackConstants can't depend on it as it causes jarjar-related issues
     // for users of both the net-utils-device-common and net-utils-framework-common libraries.
diff --git a/staticlibs/framework/com/android/net/module/util/SdkUtil.java b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
index 5006ba9..63558dd 100644
--- a/staticlibs/framework/com/android/net/module/util/SdkUtil.java
+++ b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
@@ -16,7 +16,10 @@
 
 package com.android.net.module.util;
 
+import static android.os.Build.VERSION.SDK_INT;
+
 import android.annotation.Nullable;
+import android.os.Build;
 
 /**
  * Utilities to deal with multiple SDKs in a single mainline module.
@@ -46,4 +49,9 @@
             this.value = value;
         }
     }
+
+    /** Checks if the device is running on a release version of Android Baklava or newer */
+    public static boolean isAtLeast25Q2() {
+        return SDK_INT >= 36  || (SDK_INT == 35 && "Baklava".equals(Build.VERSION.CODENAME));
+    }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/TerribleErrorLog.java b/staticlibs/framework/com/android/net/module/util/TerribleErrorLog.java
new file mode 100644
index 0000000..b4f7642
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/TerribleErrorLog.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import android.annotation.NonNull;
+import android.util.Log;
+
+import java.util.function.BiConsumer;
+
+/**
+ * Utility class for logging terrible errors and reporting them for tracking.
+ *
+ * @hide
+ */
+public class TerribleErrorLog {
+
+    private static final String TAG = TerribleErrorLog.class.getSimpleName();
+
+    /**
+     * Logs a terrible error and reports metrics through a provided statsLog.
+     */
+    public static void logTerribleError(@NonNull BiConsumer<Integer, Integer> statsLog,
+            @NonNull String message, int protoType, int errorType) {
+        statsLog.accept(protoType, errorType);
+        Log.wtf(TAG, message);
+    }
+}
diff --git a/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp b/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
index 2a587b6..22b084c 100644
--- a/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
+++ b/staticlibs/native/bpfmapjni/com_android_net_module_util_TcUtils.cpp
@@ -32,6 +32,10 @@
 static jboolean com_android_net_module_util_TcUtils_isEthernet(JNIEnv *env,
                                                                jclass clazz,
                                                                jstring iface) {
+  if (nullptr == iface) {
+    jniThrowNullPointerException(env, "iface is null");
+    return false;
+  }
   ScopedUtfChars interface(env, iface);
   bool result = false;
   int error = isEthernet(interface.c_str(), result);
diff --git a/staticlibs/native/timerfdutils/Android.bp b/staticlibs/native/serviceconnectivityjni/Android.bp
similarity index 86%
rename from staticlibs/native/timerfdutils/Android.bp
rename to staticlibs/native/serviceconnectivityjni/Android.bp
index 939a2d2..18246dd 100644
--- a/staticlibs/native/timerfdutils/Android.bp
+++ b/staticlibs/native/serviceconnectivityjni/Android.bp
@@ -18,17 +18,20 @@
 }
 
 cc_library_static {
-    name: "libnet_utils_device_common_timerfdjni",
+    name: "libserviceconnectivityjni",
     srcs: [
-        "com_android_net_module_util_TimerFdUtils.cpp",
+        "com_android_net_module_util_ServiceConnectivityJni.cpp",
     ],
     header_libs: [
+        "bpf_headers",
         "jni_headers",
+        "libbase_headers",
     ],
     shared_libs: [
         "liblog",
         "libnativehelper_compat_libc++",
     ],
+    stl: "libc++_static",
     cflags: [
         "-Wall",
         "-Werror",
diff --git a/staticlibs/native/serviceconnectivityjni/com_android_net_module_util_ServiceConnectivityJni.cpp b/staticlibs/native/serviceconnectivityjni/com_android_net_module_util_ServiceConnectivityJni.cpp
new file mode 100644
index 0000000..8767589
--- /dev/null
+++ b/staticlibs/native/serviceconnectivityjni/com_android_net_module_util_ServiceConnectivityJni.cpp
@@ -0,0 +1,209 @@
+/*
+ * 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.
+ */
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <jni.h>
+#include <linux/if.h>
+#include <linux/if_tun.h>
+#include <linux/ipv6_route.h>
+#include <linux/route.h>
+#include <netinet/in.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <string>
+#include <sys/epoll.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/timerfd.h>
+#include <sys/types.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <android-base/unique_fd.h>
+#include <bpf/KernelUtils.h>
+#include <nativehelper/JNIHelp.h>
+#include <nativehelper/scoped_utf_chars.h>
+
+#define MSEC_PER_SEC 1000
+#define NSEC_PER_MSEC 1000000
+
+#ifndef IFF_NO_CARRIER
+#define IFF_NO_CARRIER 0x0040
+#endif
+
+namespace android {
+
+static jint createTimerFd(JNIEnv *env, jclass clazz) {
+  int tfd;
+  // For safety, the file descriptor should have O_NONBLOCK(TFD_NONBLOCK) set
+  // using fcntl during creation. This ensures that, in the worst-case scenario,
+  // an EAGAIN error is returned when reading.
+  tfd = timerfd_create(CLOCK_BOOTTIME, TFD_NONBLOCK);
+  if (tfd == -1) {
+    jniThrowErrnoException(env, "createTimerFd", tfd);
+  }
+  return tfd;
+}
+
+static void setTimerFdTime(JNIEnv *env, jclass clazz, jint tfd,
+                           jlong milliseconds) {
+  struct itimerspec new_value;
+  new_value.it_value.tv_sec = milliseconds / MSEC_PER_SEC;
+  new_value.it_value.tv_nsec = (milliseconds % MSEC_PER_SEC) * NSEC_PER_MSEC;
+  // Set the interval time to 0 because it's designed for repeated timer
+  // expirations after the initial expiration, which doesn't fit the current
+  // usage.
+  new_value.it_interval.tv_sec = 0;
+  new_value.it_interval.tv_nsec = 0;
+
+  int ret = timerfd_settime(tfd, 0, &new_value, NULL);
+  if (ret == -1) {
+    jniThrowErrnoException(env, "setTimerFdTime", ret);
+  }
+}
+
+static void throwException(JNIEnv *env, int error, const char *action,
+                           const char *iface) {
+  const std::string &msg = "Error: " + std::string(action) + " " +
+                           std::string(iface) + ": " +
+                           std::string(strerror(error));
+  jniThrowException(env, "java/lang/IllegalStateException", msg.c_str());
+}
+
+// enable or disable  carrier on tun / tap interface.
+static void setTunTapCarrierEnabledImpl(JNIEnv *env, const char *iface,
+                                        int tunFd, bool enabled) {
+  uint32_t carrierOn = enabled;
+  if (ioctl(tunFd, TUNSETCARRIER, &carrierOn)) {
+    throwException(env, errno, "set carrier", iface);
+  }
+}
+
+static int createTunTapImpl(JNIEnv *env, bool isTun, bool hasCarrier,
+                            bool setIffMulticast, const char *iface) {
+  base::unique_fd tun(open("/dev/tun", O_RDWR | O_NONBLOCK));
+  ifreq ifr{};
+
+  // Allocate interface.
+  ifr.ifr_flags = (isTun ? IFF_TUN : IFF_TAP) | IFF_NO_PI;
+  if (!hasCarrier) {
+    // Using IFF_NO_CARRIER is supported starting in kernel version >= 6.0
+    // Up until then, unsupported flags are ignored.
+    if (!bpf::isAtLeastKernelVersion(6, 0, 0)) {
+      throwException(env, EOPNOTSUPP, "IFF_NO_CARRIER not supported",
+                     ifr.ifr_name);
+      return -1;
+    }
+    ifr.ifr_flags |= IFF_NO_CARRIER;
+  }
+  strlcpy(ifr.ifr_name, iface, IFNAMSIZ);
+  if (ioctl(tun.get(), TUNSETIFF, &ifr)) {
+    throwException(env, errno, "allocating", ifr.ifr_name);
+    return -1;
+  }
+
+  // Mark some TAP interfaces as supporting multicast
+  if (setIffMulticast && !isTun) {
+    base::unique_fd inet6CtrlSock(socket(AF_INET6, SOCK_DGRAM, 0));
+    ifr.ifr_flags = IFF_MULTICAST;
+
+    if (ioctl(inet6CtrlSock.get(), SIOCSIFFLAGS, &ifr)) {
+      throwException(env, errno, "set IFF_MULTICAST", ifr.ifr_name);
+      return -1;
+    }
+  }
+
+  return tun.release();
+}
+
+static void bringUpInterfaceImpl(JNIEnv *env, const char *iface) {
+  // Activate interface using an unconnected datagram socket.
+  base::unique_fd inet6CtrlSock(socket(AF_INET6, SOCK_DGRAM, 0));
+
+  ifreq ifr{};
+  strlcpy(ifr.ifr_name, iface, IFNAMSIZ);
+  if (ioctl(inet6CtrlSock.get(), SIOCGIFFLAGS, &ifr)) {
+    throwException(env, errno, "read flags", iface);
+    return;
+  }
+  ifr.ifr_flags |= IFF_UP;
+  if (ioctl(inet6CtrlSock.get(), SIOCSIFFLAGS, &ifr)) {
+    throwException(env, errno, "set IFF_UP", iface);
+    return;
+  }
+}
+
+//------------------------------------------------------------------------------
+
+static void setTunTapCarrierEnabled(JNIEnv *env, jclass /* clazz */,
+                                    jstring jIface, jint tunFd,
+                                    jboolean enabled) {
+  ScopedUtfChars iface(env, jIface);
+  if (!iface.c_str()) {
+    jniThrowNullPointerException(env, "iface");
+    return;
+  }
+  setTunTapCarrierEnabledImpl(env, iface.c_str(), tunFd, enabled);
+}
+
+static jint createTunTap(JNIEnv *env, jclass /* clazz */, jboolean isTun,
+                         jboolean hasCarrier, jboolean setIffMulticast,
+                         jstring jIface) {
+  ScopedUtfChars iface(env, jIface);
+  if (!iface.c_str()) {
+    jniThrowNullPointerException(env, "iface");
+    return -1;
+  }
+
+  return createTunTapImpl(env, isTun, hasCarrier, setIffMulticast,
+                          iface.c_str());
+}
+
+static void bringUpInterface(JNIEnv *env, jclass /* clazz */, jstring jIface) {
+  ScopedUtfChars iface(env, jIface);
+  if (!iface.c_str()) {
+    jniThrowNullPointerException(env, "iface");
+    return;
+  }
+  bringUpInterfaceImpl(env, iface.c_str());
+}
+
+//------------------------------------------------------------------------------
+
+/*
+ * JNI registration.
+ */
+static const JNINativeMethod gMethods[] = {
+    /* name, signature, funcPtr */
+    {"createTimerFd", "()I", (void *)createTimerFd},
+    {"setTimerFdTime", "(IJ)V", (void *)setTimerFdTime},
+    {"setTunTapCarrierEnabled", "(Ljava/lang/String;IZ)V",
+     (void *)setTunTapCarrierEnabled},
+    {"createTunTap", "(ZZZLjava/lang/String;)I", (void *)createTunTap},
+    {"bringUpInterface", "(Ljava/lang/String;)V", (void *)bringUpInterface},
+};
+
+int register_com_android_net_module_util_ServiceConnectivityJni(
+    JNIEnv *env, char const *class_name) {
+  return jniRegisterNativeMethods(env, class_name, gMethods, NELEM(gMethods));
+}
+
+}; // namespace android
diff --git a/staticlibs/native/tcutils/tcutils.cpp b/staticlibs/native/tcutils/tcutils.cpp
index 21e781c..5425d0e 100644
--- a/staticlibs/native/tcutils/tcutils.cpp
+++ b/staticlibs/native/tcutils/tcutils.cpp
@@ -361,7 +361,7 @@
 const sockaddr_nl KERNEL_NLADDR = {AF_NETLINK, 0, 0, 0};
 const uint16_t NETLINK_REQUEST_FLAGS = NLM_F_REQUEST | NLM_F_ACK;
 
-int sendAndProcessNetlinkResponse(const void *req, int len) {
+int sendAndProcessNetlinkResponse(const void *req, int len, bool enoent_ok) {
   // TODO: use unique_fd instead of ScopeGuard
   unique_fd fd(socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE));
   if (!fd.ok()) {
@@ -445,7 +445,9 @@
     return -ENOMSG;
   }
 
-  if (resp.e.error) {
+  if (resp.e.error == -ENOENT) {
+    if (!enoent_ok) ALOGE("NLMSG_ERROR message returned ENOENT");
+  } else if (resp.e.error) {
     ALOGE("NLMSG_ERROR message return error: %d", resp.e.error);
   }
   return resp.e.error; // returns 0 on success
@@ -560,7 +562,8 @@
   };
 #undef CLSACT
 
-  return sendAndProcessNetlinkResponse(&req, sizeof(req));
+  const bool enoent_ok = (nlMsgType == RTM_DELQDISC);
+  return sendAndProcessNetlinkResponse(&req, sizeof(req), enoent_ok);
 }
 
 // tc filter add dev .. in/egress prio 1 protocol ipv6/ip bpf object-pinned
@@ -666,7 +669,7 @@
   snprintf(req.options.name.str, sizeof(req.options.name.str), "%s:[*fsobj]",
            basename(bpfProgPath));
 
-  int error = sendAndProcessNetlinkResponse(&req, sizeof(req));
+  int error = sendAndProcessNetlinkResponse(&req, sizeof(req), false);
   return error;
 }
 
@@ -698,7 +701,8 @@
     return error;
   }
   return sendAndProcessNetlinkResponse(filter.getRequest(),
-                                       filter.getRequestSize());
+                                       filter.getRequestSize(),
+                                       false);
 }
 
 // tc filter del dev .. in/egress prio .. protocol ..
@@ -726,7 +730,7 @@
           },
   };
 
-  return sendAndProcessNetlinkResponse(&req, sizeof(req));
+  return sendAndProcessNetlinkResponse(&req, sizeof(req), true);
 }
 
 } // namespace android
diff --git a/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp b/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
deleted file mode 100644
index c4c960d..0000000
--- a/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
+++ /dev/null
@@ -1,79 +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.
- */
-
-#include <errno.h>
-#include <jni.h>
-#include <nativehelper/JNIHelp.h>
-#include <nativehelper/scoped_utf_chars.h>
-#include <stdint.h>
-#include <stdio.h>
-#include <stdlib.h>
-#include <string.h>
-#include <sys/epoll.h>
-#include <sys/timerfd.h>
-#include <time.h>
-#include <unistd.h>
-
-#define MSEC_PER_SEC 1000
-#define NSEC_PER_MSEC 1000000
-
-namespace android {
-
-static jint
-com_android_net_module_util_TimerFdUtils_createTimerFd(JNIEnv *env,
-                                                       jclass clazz) {
-  int tfd;
-  tfd = timerfd_create(CLOCK_BOOTTIME, 0);
-  if (tfd == -1) {
-    jniThrowErrnoException(env, "createTimerFd", tfd);
-  }
-  return tfd;
-}
-
-static void
-com_android_net_module_util_TimerFdUtils_setTime(JNIEnv *env, jclass clazz,
-                                                 jint tfd, jlong milliseconds) {
-  struct itimerspec new_value;
-  new_value.it_value.tv_sec = milliseconds / MSEC_PER_SEC;
-  new_value.it_value.tv_nsec = (milliseconds % MSEC_PER_SEC) * NSEC_PER_MSEC;
-  // Set the interval time to 0 because it's designed for repeated timer expirations after the
-  // initial expiration, which doesn't fit the current usage.
-  new_value.it_interval.tv_sec = 0;
-  new_value.it_interval.tv_nsec = 0;
-
-  int ret = timerfd_settime(tfd, 0, &new_value, NULL);
-  if (ret == -1) {
-    jniThrowErrnoException(env, "setTime", ret);
-  }
-}
-
-/*
- * JNI registration.
- */
-static const JNINativeMethod gMethods[] = {
-    /* name, signature, funcPtr */
-    {"createTimerFd", "()I",
-     (void *)com_android_net_module_util_TimerFdUtils_createTimerFd},
-    {"setTime", "(IJ)V",
-     (void *)com_android_net_module_util_TimerFdUtils_setTime},
-};
-
-int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
-                                                      char const *class_name) {
-  return jniRegisterNativeMethods(env, class_name, gMethods, NELEM(gMethods));
-}
-
-}; // namespace android
diff --git a/staticlibs/netd/Android.bp b/staticlibs/netd/Android.bp
index 44abba2..9222b17 100644
--- a/staticlibs/netd/Android.bp
+++ b/staticlibs/netd/Android.bp
@@ -22,7 +22,7 @@
     sdk_version: "system_current",
     min_sdk_version: "30",
     static_libs: [
-        "netd_aidl_interface-V15-java",
+        "netd_aidl_interface-V17-java",
     ],
     apex_available: [
         "//apex_available:platform", // used from services.net
@@ -45,7 +45,7 @@
 cc_library_static {
     name: "netd_aidl_interface-lateststable-ndk",
     whole_static_libs: [
-        "netd_aidl_interface-V15-ndk",
+        "netd_aidl_interface-V17-ndk",
     ],
     apex_available: [
         "com.android.resolv",
@@ -56,12 +56,12 @@
 
 cc_defaults {
     name: "netd_aidl_interface_lateststable_cpp_static",
-    static_libs: ["netd_aidl_interface-V15-cpp"],
+    static_libs: ["netd_aidl_interface-V17-cpp"],
 }
 
 cc_defaults {
     name: "netd_aidl_interface_lateststable_cpp_shared",
-    shared_libs: ["netd_aidl_interface-V15-cpp"],
+    shared_libs: ["netd_aidl_interface-V17-cpp"],
 }
 
 aidl_interface {
@@ -171,6 +171,14 @@
             version: "15",
             imports: [],
         },
+        {
+            version: "16",
+            imports: [],
+        },
+        {
+            version: "17",
+            imports: [],
+        },
 
     ],
     frozen: true,
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/.hash b/staticlibs/netd/aidl_api/netd_aidl_interface/16/.hash
new file mode 100644
index 0000000..08cd338
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/.hash
@@ -0,0 +1 @@
+28e20632b92e146787d32437a53aaa5ad39125b7
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetd.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetd.aidl
new file mode 100644
index 0000000..8351b56
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetd.aidl
@@ -0,0 +1,272 @@
+/**
+ * Copyright (c) 2016, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetd {
+  boolean isAlive();
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  boolean firewallReplaceUidChain(in @utf8InCpp String chainName, boolean isAllowlist, in int[] uids);
+  boolean bandwidthEnableDataSaver(boolean enable);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreatePhysical(int netId, int permission);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreateVpn(int netId, boolean secure);
+  void networkDestroy(int netId);
+  void networkAddInterface(int netId, in @utf8InCpp String iface);
+  void networkRemoveInterface(int netId, in @utf8InCpp String iface);
+  void networkAddUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRemoveUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRejectNonSecureVpn(boolean add, in android.net.UidRangeParcel[] uidRanges);
+  void socketDestroy(in android.net.UidRangeParcel[] uidRanges, in int[] exemptUids);
+  boolean tetherApplyDnsInterfaces();
+  android.net.TetherStatsParcel[] tetherGetStats();
+  void interfaceAddAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  void interfaceDelAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  @utf8InCpp String getProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter);
+  void setProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter, in @utf8InCpp String value);
+  void ipSecSetEncapSocketOwner(in ParcelFileDescriptor socket, int newUid);
+  int ipSecAllocateSpi(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecAddSecurityAssociation(int transformId, int mode, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int underlyingNetId, int spi, int markValue, int markMask, in @utf8InCpp String authAlgo, in byte[] authKey, in int authTruncBits, in @utf8InCpp String cryptAlgo, in byte[] cryptKey, in int cryptTruncBits, in @utf8InCpp String aeadAlgo, in byte[] aeadKey, in int aeadIcvBits, int encapType, int encapLocalPort, int encapRemotePort, int interfaceId);
+  void ipSecDeleteSecurityAssociation(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecApplyTransportModeTransform(in ParcelFileDescriptor socket, int transformId, int direction, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecRemoveTransportModeTransform(in ParcelFileDescriptor socket);
+  void ipSecAddSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecUpdateSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecDeleteSecurityPolicy(int transformId, int selAddrFamily, int direction, int markValue, int markMask, int interfaceId);
+  void ipSecAddTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecUpdateTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecRemoveTunnelInterface(in @utf8InCpp String deviceName);
+  void wakeupAddInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void wakeupDelInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void setIPv6AddrGenMode(in @utf8InCpp String ifName, int mode);
+  void idletimerAddInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void idletimerRemoveInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void strictUidCleartextPenalty(int uid, int policyPenalty);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  @utf8InCpp String clatdStart(in @utf8InCpp String ifName, in @utf8InCpp String nat64Prefix);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  void clatdStop(in @utf8InCpp String ifName);
+  boolean ipfwdEnabled();
+  @utf8InCpp String[] ipfwdGetRequesterList();
+  void ipfwdEnableForwarding(in @utf8InCpp String requester);
+  void ipfwdDisableForwarding(in @utf8InCpp String requester);
+  void ipfwdAddInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void ipfwdRemoveInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void bandwidthSetInterfaceQuota(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceQuota(in @utf8InCpp String ifName);
+  void bandwidthSetInterfaceAlert(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceAlert(in @utf8InCpp String ifName);
+  void bandwidthSetGlobalAlert(long bytes);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNiceApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNiceApp(int uid);
+  void tetherStart(in @utf8InCpp String[] dhcpRanges);
+  void tetherStop();
+  boolean tetherIsEnabled();
+  void tetherInterfaceAdd(in @utf8InCpp String ifName);
+  void tetherInterfaceRemove(in @utf8InCpp String ifName);
+  @utf8InCpp String[] tetherInterfaceList();
+  void tetherDnsSet(int netId, in @utf8InCpp String[] dnsAddrs);
+  @utf8InCpp String[] tetherDnsList();
+  void networkAddRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkRemoveRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkAddLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  void networkRemoveLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  int networkGetDefault();
+  void networkSetDefault(int netId);
+  void networkClearDefault();
+  void networkSetPermissionForNetwork(int netId, int permission);
+  void networkSetPermissionForUser(int permission, in int[] uids);
+  void networkClearPermissionForUser(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSetNetPermForUids(int permission, in int[] uids);
+  void networkSetProtectAllow(int uid);
+  void networkSetProtectDeny(int uid);
+  boolean networkCanProtect(int uid);
+  void firewallSetFirewallType(int firewalltype);
+  void firewallSetInterfaceRule(in @utf8InCpp String ifName, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallSetUidRule(int childChain, int uid, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallEnableChildChain(int childChain, boolean enable);
+  @utf8InCpp String[] interfaceGetList();
+  android.net.InterfaceConfigurationParcel interfaceGetCfg(in @utf8InCpp String ifName);
+  void interfaceSetCfg(in android.net.InterfaceConfigurationParcel cfg);
+  void interfaceSetIPv6PrivacyExtensions(in @utf8InCpp String ifName, boolean enable);
+  void interfaceClearAddrs(in @utf8InCpp String ifName);
+  void interfaceSetEnableIPv6(in @utf8InCpp String ifName, boolean enable);
+  void interfaceSetMtu(in @utf8InCpp String ifName, int mtu);
+  void tetherAddForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void tetherRemoveForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void setTcpRWmemorySize(in @utf8InCpp String rmemValues, in @utf8InCpp String wmemValues);
+  void registerUnsolicitedEventListener(android.net.INetdUnsolicitedEventListener listener);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallAddUidInterfaceRules(in @utf8InCpp String ifName, in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallRemoveUidInterfaceRules(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSwapActiveStatsMap();
+  IBinder getOemNetd();
+  void tetherStartWithConfiguration(in android.net.TetherConfigParcel config);
+  android.net.MarkMaskParcel getFwmarkForNetwork(int netId);
+  void networkAddRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkUpdateRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkRemoveRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleAdd(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleRemove(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel[] tetherOffloadGetStats();
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadSetInterfaceQuota(int ifIndex, long quotaBytes);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel tetherOffloadGetAndClearStats(int ifIndex);
+  void networkCreate(in android.net.NativeNetworkConfig config);
+  void networkAddUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void networkRemoveUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void ipSecMigrate(in android.net.IpSecMigrateInfoParcel migrateInfo);
+  void setNetworkAllowlist(in android.net.netd.aidl.NativeUidRangeConfig[] allowedNetworks);
+  void networkAllowBypassVpnOnNetwork(boolean allow, int uid, int netId);
+  const int IPV4 = 4;
+  const int IPV6 = 6;
+  const int CONF = 1;
+  const int NEIGH = 2;
+  const String IPSEC_INTERFACE_PREFIX = "ipsec";
+  const int IPV6_ADDR_GEN_MODE_EUI64 = 0;
+  const int IPV6_ADDR_GEN_MODE_NONE = 1;
+  const int IPV6_ADDR_GEN_MODE_STABLE_PRIVACY = 2;
+  const int IPV6_ADDR_GEN_MODE_RANDOM = 3;
+  const int IPV6_ADDR_GEN_MODE_DEFAULT = 0;
+  const int PENALTY_POLICY_ACCEPT = 1;
+  const int PENALTY_POLICY_LOG = 2;
+  const int PENALTY_POLICY_REJECT = 3;
+  const int CLAT_MARK = 0xdeadc1a7;
+  const int LOCAL_NET_ID = 99;
+  const int DUMMY_NET_ID = 51;
+  const int UNREACHABLE_NET_ID = 52;
+  const String NEXTHOP_NONE = "";
+  const String NEXTHOP_UNREACHABLE = "unreachable";
+  const String NEXTHOP_THROW = "throw";
+  const int PERMISSION_NONE = 0;
+  const int PERMISSION_NETWORK = 1;
+  const int PERMISSION_SYSTEM = 2;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int NO_PERMISSIONS = 0;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_INTERNET = 4;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UPDATE_DEVICE_STATS = 8;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UNINSTALLED = (-1) /* -1 */;
+  /**
+   * @deprecated use FIREWALL_ALLOWLIST.
+   */
+  const int FIREWALL_WHITELIST = 0;
+  const int FIREWALL_ALLOWLIST = 0;
+  /**
+   * @deprecated use FIREWALL_DENYLIST.
+   */
+  const int FIREWALL_BLACKLIST = 1;
+  const int FIREWALL_DENYLIST = 1;
+  const int FIREWALL_RULE_ALLOW = 1;
+  const int FIREWALL_RULE_DENY = 2;
+  const int FIREWALL_CHAIN_NONE = 0;
+  const int FIREWALL_CHAIN_DOZABLE = 1;
+  const int FIREWALL_CHAIN_STANDBY = 2;
+  const int FIREWALL_CHAIN_POWERSAVE = 3;
+  const int FIREWALL_CHAIN_RESTRICTED = 4;
+  const String IF_STATE_UP = "up";
+  const String IF_STATE_DOWN = "down";
+  const String IF_FLAG_BROADCAST = "broadcast";
+  const String IF_FLAG_LOOPBACK = "loopback";
+  const String IF_FLAG_POINTOPOINT = "point-to-point";
+  const String IF_FLAG_RUNNING = "running";
+  const String IF_FLAG_MULTICAST = "multicast";
+  const int IPSEC_DIRECTION_IN = 0;
+  const int IPSEC_DIRECTION_OUT = 1;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetdUnsolicitedEventListener.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetdUnsolicitedEventListener.aidl
new file mode 100644
index 0000000..31775df
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/INetdUnsolicitedEventListener.aidl
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2018, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetdUnsolicitedEventListener {
+  oneway void onInterfaceClassActivityChanged(boolean isActive, int timerLabel, long timestampNs, int uid);
+  oneway void onQuotaLimitReached(@utf8InCpp String alertName, @utf8InCpp String ifName);
+  oneway void onInterfaceDnsServerInfo(@utf8InCpp String ifName, long lifetimeS, in @utf8InCpp String[] servers);
+  oneway void onInterfaceAddressUpdated(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAddressRemoved(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAdded(@utf8InCpp String ifName);
+  oneway void onInterfaceRemoved(@utf8InCpp String ifName);
+  oneway void onInterfaceChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onInterfaceLinkStateChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onRouteChanged(boolean updated, @utf8InCpp String route, @utf8InCpp String gateway, @utf8InCpp String ifName);
+  oneway void onStrictCleartextDetected(int uid, @utf8InCpp String hex);
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/InterfaceConfigurationParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/InterfaceConfigurationParcel.aidl
new file mode 100644
index 0000000..1869d8d
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/InterfaceConfigurationParcel.aidl
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable InterfaceConfigurationParcel {
+  @utf8InCpp String ifName;
+  @utf8InCpp String hwAddr;
+  @utf8InCpp String ipv4Addr;
+  int prefixLength;
+  @utf8InCpp String[] flags;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/IpSecMigrateInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/IpSecMigrateInfoParcel.aidl
new file mode 100644
index 0000000..975a261
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/IpSecMigrateInfoParcel.aidl
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2022, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@JavaOnlyImmutable
+parcelable IpSecMigrateInfoParcel {
+  int requestId;
+  int selAddrFamily;
+  int direction;
+  @utf8InCpp String oldSourceAddress;
+  @utf8InCpp String oldDestinationAddress;
+  @utf8InCpp String newSourceAddress;
+  @utf8InCpp String newDestinationAddress;
+  int interfaceId;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/MarkMaskParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/MarkMaskParcel.aidl
new file mode 100644
index 0000000..8ea20d1
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/MarkMaskParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable MarkMaskParcel {
+  int mark;
+  int mask;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkConfig.aidl
new file mode 100644
index 0000000..77d814b
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkConfig.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeNetworkConfig {
+  int netId;
+  android.net.NativeNetworkType networkType = android.net.NativeNetworkType.PHYSICAL;
+  int permission;
+  boolean secure;
+  android.net.NativeVpnType vpnType = android.net.NativeVpnType.PLATFORM;
+  boolean excludeLocalRoutes = false;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkType.aidl
new file mode 100644
index 0000000..e77a143
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeNetworkType.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeNetworkType {
+  PHYSICAL = 0,
+  VIRTUAL = 1,
+  PHYSICAL_LOCAL = 2,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeVpnType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeVpnType.aidl
new file mode 100644
index 0000000..8a8be83
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/NativeVpnType.aidl
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeVpnType {
+  SERVICE = 1,
+  PLATFORM = 2,
+  LEGACY = 3,
+  OEM = 4,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/RouteInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/RouteInfoParcel.aidl
new file mode 100644
index 0000000..5ef95e6
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/RouteInfoParcel.aidl
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2020, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+parcelable RouteInfoParcel {
+  @utf8InCpp String destination;
+  @utf8InCpp String ifName;
+  @utf8InCpp String nextHop;
+  int mtu;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherConfigParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherConfigParcel.aidl
new file mode 100644
index 0000000..7b39c22
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherConfigParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherConfigParcel {
+  boolean usingLegacyDnsProxy;
+  @utf8InCpp String[] dhcpRanges;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherOffloadRuleParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherOffloadRuleParcel.aidl
new file mode 100644
index 0000000..983e986
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherOffloadRuleParcel.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherOffloadRuleParcel {
+  int inputInterfaceIndex;
+  int outputInterfaceIndex;
+  byte[] destination;
+  int prefixLength;
+  byte[] srcL2Address;
+  byte[] dstL2Address;
+  int pmtu = 1500;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherStatsParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherStatsParcel.aidl
new file mode 100644
index 0000000..5f1b722
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/TetherStatsParcel.aidl
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherStatsParcel {
+  @utf8InCpp String iface;
+  long rxBytes;
+  long rxPackets;
+  long txBytes;
+  long txPackets;
+  int ifIndex = 0;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/UidRangeParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/UidRangeParcel.aidl
new file mode 100644
index 0000000..72e987a
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/UidRangeParcel.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable UidRangeParcel {
+  int start;
+  int stop;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/netd/aidl/NativeUidRangeConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/netd/aidl/NativeUidRangeConfig.aidl
new file mode 100644
index 0000000..9bb679f
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/16/android/net/netd/aidl/NativeUidRangeConfig.aidl
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net.netd.aidl;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeUidRangeConfig {
+  int netId;
+  android.net.UidRangeParcel[] uidRanges;
+  int subPriority;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash b/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash
new file mode 100644
index 0000000..f69c88b
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash
@@ -0,0 +1 @@
+a786da2fe41bda627a8c1e63b99264a415e769c8
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl
new file mode 100644
index 0000000..8351b56
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl
@@ -0,0 +1,272 @@
+/**
+ * Copyright (c) 2016, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetd {
+  boolean isAlive();
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  boolean firewallReplaceUidChain(in @utf8InCpp String chainName, boolean isAllowlist, in int[] uids);
+  boolean bandwidthEnableDataSaver(boolean enable);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreatePhysical(int netId, int permission);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreateVpn(int netId, boolean secure);
+  void networkDestroy(int netId);
+  void networkAddInterface(int netId, in @utf8InCpp String iface);
+  void networkRemoveInterface(int netId, in @utf8InCpp String iface);
+  void networkAddUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRemoveUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRejectNonSecureVpn(boolean add, in android.net.UidRangeParcel[] uidRanges);
+  void socketDestroy(in android.net.UidRangeParcel[] uidRanges, in int[] exemptUids);
+  boolean tetherApplyDnsInterfaces();
+  android.net.TetherStatsParcel[] tetherGetStats();
+  void interfaceAddAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  void interfaceDelAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  @utf8InCpp String getProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter);
+  void setProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter, in @utf8InCpp String value);
+  void ipSecSetEncapSocketOwner(in ParcelFileDescriptor socket, int newUid);
+  int ipSecAllocateSpi(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecAddSecurityAssociation(int transformId, int mode, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int underlyingNetId, int spi, int markValue, int markMask, in @utf8InCpp String authAlgo, in byte[] authKey, in int authTruncBits, in @utf8InCpp String cryptAlgo, in byte[] cryptKey, in int cryptTruncBits, in @utf8InCpp String aeadAlgo, in byte[] aeadKey, in int aeadIcvBits, int encapType, int encapLocalPort, int encapRemotePort, int interfaceId);
+  void ipSecDeleteSecurityAssociation(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecApplyTransportModeTransform(in ParcelFileDescriptor socket, int transformId, int direction, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecRemoveTransportModeTransform(in ParcelFileDescriptor socket);
+  void ipSecAddSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecUpdateSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecDeleteSecurityPolicy(int transformId, int selAddrFamily, int direction, int markValue, int markMask, int interfaceId);
+  void ipSecAddTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecUpdateTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecRemoveTunnelInterface(in @utf8InCpp String deviceName);
+  void wakeupAddInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void wakeupDelInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void setIPv6AddrGenMode(in @utf8InCpp String ifName, int mode);
+  void idletimerAddInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void idletimerRemoveInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void strictUidCleartextPenalty(int uid, int policyPenalty);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  @utf8InCpp String clatdStart(in @utf8InCpp String ifName, in @utf8InCpp String nat64Prefix);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  void clatdStop(in @utf8InCpp String ifName);
+  boolean ipfwdEnabled();
+  @utf8InCpp String[] ipfwdGetRequesterList();
+  void ipfwdEnableForwarding(in @utf8InCpp String requester);
+  void ipfwdDisableForwarding(in @utf8InCpp String requester);
+  void ipfwdAddInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void ipfwdRemoveInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void bandwidthSetInterfaceQuota(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceQuota(in @utf8InCpp String ifName);
+  void bandwidthSetInterfaceAlert(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceAlert(in @utf8InCpp String ifName);
+  void bandwidthSetGlobalAlert(long bytes);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNiceApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNiceApp(int uid);
+  void tetherStart(in @utf8InCpp String[] dhcpRanges);
+  void tetherStop();
+  boolean tetherIsEnabled();
+  void tetherInterfaceAdd(in @utf8InCpp String ifName);
+  void tetherInterfaceRemove(in @utf8InCpp String ifName);
+  @utf8InCpp String[] tetherInterfaceList();
+  void tetherDnsSet(int netId, in @utf8InCpp String[] dnsAddrs);
+  @utf8InCpp String[] tetherDnsList();
+  void networkAddRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkRemoveRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkAddLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  void networkRemoveLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  int networkGetDefault();
+  void networkSetDefault(int netId);
+  void networkClearDefault();
+  void networkSetPermissionForNetwork(int netId, int permission);
+  void networkSetPermissionForUser(int permission, in int[] uids);
+  void networkClearPermissionForUser(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSetNetPermForUids(int permission, in int[] uids);
+  void networkSetProtectAllow(int uid);
+  void networkSetProtectDeny(int uid);
+  boolean networkCanProtect(int uid);
+  void firewallSetFirewallType(int firewalltype);
+  void firewallSetInterfaceRule(in @utf8InCpp String ifName, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallSetUidRule(int childChain, int uid, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallEnableChildChain(int childChain, boolean enable);
+  @utf8InCpp String[] interfaceGetList();
+  android.net.InterfaceConfigurationParcel interfaceGetCfg(in @utf8InCpp String ifName);
+  void interfaceSetCfg(in android.net.InterfaceConfigurationParcel cfg);
+  void interfaceSetIPv6PrivacyExtensions(in @utf8InCpp String ifName, boolean enable);
+  void interfaceClearAddrs(in @utf8InCpp String ifName);
+  void interfaceSetEnableIPv6(in @utf8InCpp String ifName, boolean enable);
+  void interfaceSetMtu(in @utf8InCpp String ifName, int mtu);
+  void tetherAddForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void tetherRemoveForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void setTcpRWmemorySize(in @utf8InCpp String rmemValues, in @utf8InCpp String wmemValues);
+  void registerUnsolicitedEventListener(android.net.INetdUnsolicitedEventListener listener);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallAddUidInterfaceRules(in @utf8InCpp String ifName, in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallRemoveUidInterfaceRules(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSwapActiveStatsMap();
+  IBinder getOemNetd();
+  void tetherStartWithConfiguration(in android.net.TetherConfigParcel config);
+  android.net.MarkMaskParcel getFwmarkForNetwork(int netId);
+  void networkAddRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkUpdateRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkRemoveRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleAdd(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleRemove(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel[] tetherOffloadGetStats();
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadSetInterfaceQuota(int ifIndex, long quotaBytes);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel tetherOffloadGetAndClearStats(int ifIndex);
+  void networkCreate(in android.net.NativeNetworkConfig config);
+  void networkAddUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void networkRemoveUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void ipSecMigrate(in android.net.IpSecMigrateInfoParcel migrateInfo);
+  void setNetworkAllowlist(in android.net.netd.aidl.NativeUidRangeConfig[] allowedNetworks);
+  void networkAllowBypassVpnOnNetwork(boolean allow, int uid, int netId);
+  const int IPV4 = 4;
+  const int IPV6 = 6;
+  const int CONF = 1;
+  const int NEIGH = 2;
+  const String IPSEC_INTERFACE_PREFIX = "ipsec";
+  const int IPV6_ADDR_GEN_MODE_EUI64 = 0;
+  const int IPV6_ADDR_GEN_MODE_NONE = 1;
+  const int IPV6_ADDR_GEN_MODE_STABLE_PRIVACY = 2;
+  const int IPV6_ADDR_GEN_MODE_RANDOM = 3;
+  const int IPV6_ADDR_GEN_MODE_DEFAULT = 0;
+  const int PENALTY_POLICY_ACCEPT = 1;
+  const int PENALTY_POLICY_LOG = 2;
+  const int PENALTY_POLICY_REJECT = 3;
+  const int CLAT_MARK = 0xdeadc1a7;
+  const int LOCAL_NET_ID = 99;
+  const int DUMMY_NET_ID = 51;
+  const int UNREACHABLE_NET_ID = 52;
+  const String NEXTHOP_NONE = "";
+  const String NEXTHOP_UNREACHABLE = "unreachable";
+  const String NEXTHOP_THROW = "throw";
+  const int PERMISSION_NONE = 0;
+  const int PERMISSION_NETWORK = 1;
+  const int PERMISSION_SYSTEM = 2;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int NO_PERMISSIONS = 0;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_INTERNET = 4;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UPDATE_DEVICE_STATS = 8;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UNINSTALLED = (-1) /* -1 */;
+  /**
+   * @deprecated use FIREWALL_ALLOWLIST.
+   */
+  const int FIREWALL_WHITELIST = 0;
+  const int FIREWALL_ALLOWLIST = 0;
+  /**
+   * @deprecated use FIREWALL_DENYLIST.
+   */
+  const int FIREWALL_BLACKLIST = 1;
+  const int FIREWALL_DENYLIST = 1;
+  const int FIREWALL_RULE_ALLOW = 1;
+  const int FIREWALL_RULE_DENY = 2;
+  const int FIREWALL_CHAIN_NONE = 0;
+  const int FIREWALL_CHAIN_DOZABLE = 1;
+  const int FIREWALL_CHAIN_STANDBY = 2;
+  const int FIREWALL_CHAIN_POWERSAVE = 3;
+  const int FIREWALL_CHAIN_RESTRICTED = 4;
+  const String IF_STATE_UP = "up";
+  const String IF_STATE_DOWN = "down";
+  const String IF_FLAG_BROADCAST = "broadcast";
+  const String IF_FLAG_LOOPBACK = "loopback";
+  const String IF_FLAG_POINTOPOINT = "point-to-point";
+  const String IF_FLAG_RUNNING = "running";
+  const String IF_FLAG_MULTICAST = "multicast";
+  const int IPSEC_DIRECTION_IN = 0;
+  const int IPSEC_DIRECTION_OUT = 1;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl
new file mode 100644
index 0000000..31775df
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2018, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetdUnsolicitedEventListener {
+  oneway void onInterfaceClassActivityChanged(boolean isActive, int timerLabel, long timestampNs, int uid);
+  oneway void onQuotaLimitReached(@utf8InCpp String alertName, @utf8InCpp String ifName);
+  oneway void onInterfaceDnsServerInfo(@utf8InCpp String ifName, long lifetimeS, in @utf8InCpp String[] servers);
+  oneway void onInterfaceAddressUpdated(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAddressRemoved(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAdded(@utf8InCpp String ifName);
+  oneway void onInterfaceRemoved(@utf8InCpp String ifName);
+  oneway void onInterfaceChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onInterfaceLinkStateChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onRouteChanged(boolean updated, @utf8InCpp String route, @utf8InCpp String gateway, @utf8InCpp String ifName);
+  oneway void onStrictCleartextDetected(int uid, @utf8InCpp String hex);
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl
new file mode 100644
index 0000000..1869d8d
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable InterfaceConfigurationParcel {
+  @utf8InCpp String ifName;
+  @utf8InCpp String hwAddr;
+  @utf8InCpp String ipv4Addr;
+  int prefixLength;
+  @utf8InCpp String[] flags;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl
new file mode 100644
index 0000000..975a261
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2022, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@JavaOnlyImmutable
+parcelable IpSecMigrateInfoParcel {
+  int requestId;
+  int selAddrFamily;
+  int direction;
+  @utf8InCpp String oldSourceAddress;
+  @utf8InCpp String oldDestinationAddress;
+  @utf8InCpp String newSourceAddress;
+  @utf8InCpp String newDestinationAddress;
+  int interfaceId;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl
new file mode 100644
index 0000000..8ea20d1
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable MarkMaskParcel {
+  int mark;
+  int mask;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl
new file mode 100644
index 0000000..77d814b
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeNetworkConfig {
+  int netId;
+  android.net.NativeNetworkType networkType = android.net.NativeNetworkType.PHYSICAL;
+  int permission;
+  boolean secure;
+  android.net.NativeVpnType vpnType = android.net.NativeVpnType.PLATFORM;
+  boolean excludeLocalRoutes = false;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl
new file mode 100644
index 0000000..e77a143
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeNetworkType {
+  PHYSICAL = 0,
+  VIRTUAL = 1,
+  PHYSICAL_LOCAL = 2,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl
new file mode 100644
index 0000000..a6af5f7
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeVpnType {
+  SERVICE = 1,
+  PLATFORM = 2,
+  LEGACY = 3,
+  OEM = 4,
+  OEM_SERVICE = 5,
+  OEM_LEGACY = 6,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl
new file mode 100644
index 0000000..5ef95e6
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2020, 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+parcelable RouteInfoParcel {
+  @utf8InCpp String destination;
+  @utf8InCpp String ifName;
+  @utf8InCpp String nextHop;
+  int mtu;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl
new file mode 100644
index 0000000..7b39c22
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherConfigParcel {
+  boolean usingLegacyDnsProxy;
+  @utf8InCpp String[] dhcpRanges;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl
new file mode 100644
index 0000000..983e986
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherOffloadRuleParcel {
+  int inputInterfaceIndex;
+  int outputInterfaceIndex;
+  byte[] destination;
+  int prefixLength;
+  byte[] srcL2Address;
+  byte[] dstL2Address;
+  int pmtu = 1500;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl
new file mode 100644
index 0000000..5f1b722
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherStatsParcel {
+  @utf8InCpp String iface;
+  long rxBytes;
+  long rxPackets;
+  long txBytes;
+  long txPackets;
+  int ifIndex = 0;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl
new file mode 100644
index 0000000..72e987a
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2018 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable UidRangeParcel {
+  int start;
+  int stop;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl
new file mode 100644
index 0000000..9bb679f
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2021 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.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net.netd.aidl;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeUidRangeConfig {
+  int netId;
+  android.net.UidRangeParcel[] uidRanges;
+  int subPriority;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/INetd.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/INetd.aidl
index 80b3b62..8351b56 100644
--- a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/INetd.aidl
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/INetd.aidl
@@ -227,9 +227,21 @@
   const int PERMISSION_NONE = 0;
   const int PERMISSION_NETWORK = 1;
   const int PERMISSION_SYSTEM = 2;
+  /**
+   * @deprecated usage is internal to module.
+   */
   const int NO_PERMISSIONS = 0;
+  /**
+   * @deprecated usage is internal to module.
+   */
   const int PERMISSION_INTERNET = 4;
+  /**
+   * @deprecated usage is internal to module.
+   */
   const int PERMISSION_UPDATE_DEVICE_STATS = 8;
+  /**
+   * @deprecated usage is internal to module.
+   */
   const int PERMISSION_UNINSTALLED = (-1) /* -1 */;
   /**
    * @deprecated use FIREWALL_ALLOWLIST.
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
index 8a8be83..a6af5f7 100644
--- a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
@@ -38,4 +38,6 @@
   PLATFORM = 2,
   LEGACY = 3,
   OEM = 4,
+  OEM_SERVICE = 5,
+  OEM_LEGACY = 6,
 }
diff --git a/staticlibs/netd/binder/android/net/INetd.aidl b/staticlibs/netd/binder/android/net/INetd.aidl
index e4c63b9..be8f538 100644
--- a/staticlibs/netd/binder/android/net/INetd.aidl
+++ b/staticlibs/netd/binder/android/net/INetd.aidl
@@ -933,24 +933,27 @@
    /**
     * NO_PERMISSIONS indicates that this app is installed and doesn't have either
     * PERMISSION_INTERNET or PERMISSION_UPDATE_DEVICE_STATS.
-    * TODO: use PERMISSION_NONE to represent this case
+    * @deprecated usage is internal to module.
     */
     const int NO_PERMISSIONS = 0;
 
    /**
-    * PERMISSION_INTERNET indicates that the app can create AF_INET and AF_INET6 sockets
+    * PERMISSION_INTERNET indicates that the app can create AF_INET and AF_INET6 sockets.
+    * @deprecated usage is internal to module.
     */
     const int PERMISSION_INTERNET = 4;
 
    /**
     * PERMISSION_UPDATE_DEVICE_STATS is used for system UIDs and privileged apps
-    * that have the UPDATE_DEVICE_STATS permission
+    * that have the UPDATE_DEVICE_STATS permission.
+    * @deprecated usage is internal to module.
     */
     const int PERMISSION_UPDATE_DEVICE_STATS = 8;
 
    /**
     * PERMISSION_UNINSTALLED is used when an app is uninstalled from the device. All internet
-    * related permissions need to be cleaned
+    * related permissions need to be cleaned.
+    * @deprecated usage is internal to module.
     */
     const int PERMISSION_UNINSTALLED = -1;
 
diff --git a/staticlibs/netd/binder/android/net/NativeVpnType.aidl b/staticlibs/netd/binder/android/net/NativeVpnType.aidl
index cd1b447..aa0fdc1 100644
--- a/staticlibs/netd/binder/android/net/NativeVpnType.aidl
+++ b/staticlibs/netd/binder/android/net/NativeVpnType.aidl
@@ -34,7 +34,20 @@
   LEGACY = 3,
 
   /**
-   * An VPN created by OEM code through other means than VpnService or VpnManager.
+   * A VPN created by OEM code through other means than VpnService or VpnManager.
    */
   OEM = 4,
-}
\ No newline at end of file
+
+  /**
+   * A VPN created by OEM code using VpnService, and which OEM code desires to differentiate from
+   * other VPN types. The core networking stack will treat this VPN type similarly to SERVICE.
+   */
+  OEM_SERVICE = 5,
+
+  /**
+   * A VPN created by OEM code using the legacy VPN mechanisms, and which OEM code desires to
+   * differentiate from other VPN types. The core networking stack will treat this VPN type
+   * similarly to LEGACY.
+   */
+  OEM_LEGACY = 6,
+}
diff --git a/staticlibs/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 9d1d291..f4f1ea9 100644
--- a/staticlibs/tests/unit/Android.bp
+++ b/staticlibs/tests/unit/Android.bp
@@ -28,6 +28,7 @@
         "net-utils-device-common-struct-base",
         "net-utils-device-common-wear",
         "net-utils-service-connectivity",
+        "truth",
     ],
     libs: [
         "android.test.runner.stubs",
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 419b338..55fbe58 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -26,7 +26,8 @@
     get_apf_counter,
     get_apf_counters_from_dumpsys,
     get_ipv4_addresses,
-    get_ipv6_addresses,
+    get_non_tentative_ipv6_addresses,
+    get_exclude_all_host_ipv6_multicast_addresses,
     get_hardware_address,
     is_send_raw_packet_downstream_supported,
     is_packet_capture_supported,
@@ -103,8 +104,12 @@
       self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = """
+45: rmnet29: <POINTOPOINT,MULTICAST,NOARP> mtu 1500 qdisc ...
+ link/ether 73:01:23:45:df:e3 brd ff:ff:ff:ff:ff:ff
 46: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq ...
  link/ether 72:05:77:82:21:e0 brd ff:ff:ff:ff:ff:ff
+47: wlan1: <BROADCAST,MULTICAST> mtu 1500 qdisc ...
+ link/ether 6a:16:81:ff:82:9b brd ff:ff:ff:ff:ff:ff
 """
     mac_address = get_hardware_address(self.mock_ad, "wlan0")
     asserts.assert_equal(mac_address, "72:05:77:82:21:E0")
@@ -140,7 +145,7 @@
     asserts.assert_equal(ip_addresses, [])
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
-  def test_get_ipv6_addresses_success(
+  def test_get_non_tentative_ipv6_addresses_success(
       self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = """
@@ -152,7 +157,7 @@
     inet6 fe80::3aff:2199:2d8e:20d1/64 scope link noprefixroute
         valid_lft forever preferred_lft forever
 """
-    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    ip_addresses = get_non_tentative_ipv6_addresses(self.mock_ad, "wlan0")
     asserts.assert_equal(ip_addresses,
                          ["fe80::10a3:5dff:fe52:de32",
                           "2001:b400:e53f:164e:9c1e:780e:d1:4658",
@@ -163,7 +168,32 @@
           self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = ""
-    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    ip_addresses = get_non_tentative_ipv6_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, [])
+
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_exclude_all_host_ipv6_multicast_addresses_success(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = """
+47:     wlan0
+        inet6 ff02::1:ff99:37b0
+        inet6 ff02::1:ffb7:cba2 users 2
+        inet6 ff02::1
+        inet6 ff01::1
+"""
+    ip_addresses = get_exclude_all_host_ipv6_multicast_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses,
+                         ["ff02::1:ff99:37b0",
+                          "ff02::1:ffb7:cba2"])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_exclude_all_host_ipv6_multicast_addresses_not_found(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = ""
+    ip_addresses = get_exclude_all_host_ipv6_multicast_addresses(self.mock_ad, "wlan0")
     asserts.assert_equal(ip_addresses, [])
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
diff --git a/staticlibs/tests/unit/host/python/packet_utils_test.py b/staticlibs/tests/unit/host/python/packet_utils_test.py
deleted file mode 100644
index 8ad9576..0000000
--- a/staticlibs/tests/unit/host/python/packet_utils_test.py
+++ /dev/null
@@ -1,72 +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.
-
-from mobly import asserts
-from mobly import base_test
-from net_tests_utils.host.python import packet_utils
-
-class TestPacketUtils(base_test.BaseTestClass):
-    def test_unicast_arp_request(self):
-        # Using scapy to generate unicast arp request packet:
-        #   eth = Ether(src="00:01:02:03:04:05", dst="01:02:03:04:05:06")
-        #   arp = ARP(op=1, pdst="192.168.1.1", hwsrc="00:01:02:03:04:05", psrc="192.168.1.2")
-        #   pkt = eth/arp
-        expect_arp_request = """
-            01020304050600010203040508060001080006040001000102030405c0a80102000000000000c0a80101
-        """.upper().replace(" ", "").replace("\n", "")
-        arp_request = packet_utils.construct_arp_packet(
-            src_mac="00:01:02:03:04:05",
-            dst_mac="01:02:03:04:05:06",
-            src_ip="192.168.1.2",
-            dst_ip="192.168.1.1",
-            op=packet_utils.ARP_REQUEST_OP
-        )
-        asserts.assert_equal(expect_arp_request, arp_request)
-
-    def test_broadcast_arp_request(self):
-        # Using scapy to generate unicast arp request packet:
-        #   eth = Ether(src="00:01:02:03:04:05", dst="FF:FF:FF:FF:FF:FF")
-        #   arp = ARP(op=1, pdst="192.168.1.1", hwsrc="00:01:02:03:04:05", psrc="192.168.1.2")
-        #   pkt = eth/arp
-        expect_arp_request = """
-            ffffffffffff00010203040508060001080006040001000102030405c0a80102000000000000c0a80101
-        """.upper().replace(" ", "").replace("\n", "")
-        arp_request = packet_utils.construct_arp_packet(
-            src_mac="00:01:02:03:04:05",
-            dst_mac=packet_utils.ETHER_BROADCAST_MAC_ADDRESS,
-            src_ip="192.168.1.2",
-            dst_ip="192.168.1.1",
-            op=packet_utils.ARP_REQUEST_OP
-        )
-        asserts.assert_equal(expect_arp_request, arp_request)
-
-    def test_arp_reply(self):
-        # Using scapy to generate unicast arp request packet:
-        #   eth = Ether(src="01:02:03:04:05:06", dst="00:01:02:03:04:05")
-        #   arp = ARP(op=2, pdst="192.168.1.2", \
-        #             hwsrc="01:02:03:04:05:06", \
-        #             psrc="192.168.1.1", \
-        #             hwdst="00:01:02:03:04:05")
-        #   pkt = eth/arp
-        expect_arp_reply = """
-            00010203040501020304050608060001080006040002010203040506c0a80101000102030405c0a80102
-        """.upper().replace(" ", "").replace("\n", "")
-        arp_reply = packet_utils.construct_arp_packet(
-            src_mac="01:02:03:04:05:06",
-            dst_mac="00:01:02:03:04:05",
-            src_ip="192.168.1.1",
-            dst_ip="192.168.1.2",
-            op=packet_utils.ARP_REPLY_OP
-        )
-        asserts.assert_equal(expect_arp_reply, arp_reply)
diff --git a/staticlibs/tests/unit/host/python/run_tests.py b/staticlibs/tests/unit/host/python/run_tests.py
index 498dbaf..fa6a310 100644
--- a/staticlibs/tests/unit/host/python/run_tests.py
+++ b/staticlibs/tests/unit/host/python/run_tests.py
@@ -18,7 +18,6 @@
 from host.python.adb_utils_test import TestAdbUtils
 from host.python.apf_utils_test import TestApfUtils
 from host.python.assert_utils_test import TestAssertUtils
-from host.python.packet_utils_test import TestPacketUtils
 from mobly import suite_runner
 
 
@@ -32,5 +31,5 @@
   sys.argv.pop(1)
   # TODO: make the tests can be executed without manually list classes.
   suite_runner.run_suite(
-      [TestAssertUtils, TestAdbUtils, TestApfUtils, TestPacketUtils], sys.argv
+      [TestAssertUtils, TestAdbUtils, TestApfUtils], sys.argv
   )
diff --git a/staticlibs/tests/unit/jni/Android.bp b/staticlibs/tests/unit/jni/Android.bp
index e456471..c444159 100644
--- a/staticlibs/tests/unit/jni/Android.bp
+++ b/staticlibs/tests/unit/jni/Android.bp
@@ -30,7 +30,7 @@
         "com_android_net_moduletests_util/onload.cpp",
     ],
     static_libs: [
-        "libnet_utils_device_common_timerfdjni",
+        "libserviceconnectivityjni",
     ],
     shared_libs: [
         "liblog",
diff --git a/staticlibs/tests/unit/jni/com_android_net_moduletests_util/onload.cpp b/staticlibs/tests/unit/jni/com_android_net_moduletests_util/onload.cpp
index a035540..af4810f 100644
--- a/staticlibs/tests/unit/jni/com_android_net_moduletests_util/onload.cpp
+++ b/staticlibs/tests/unit/jni/com_android_net_moduletests_util/onload.cpp
@@ -22,7 +22,7 @@
 
 namespace android {
 
-int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+int register_com_android_net_module_util_ServiceConnectivityJni(JNIEnv *env,
                                                       char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM *vm, void *) {
@@ -32,8 +32,8 @@
     return JNI_ERR;
   }
 
-  if (register_com_android_net_module_util_TimerFdUtils(
-          env, "com/android/net/moduletests/util/TimerFdUtils") < 0)
+  if (register_com_android_net_module_util_ServiceConnectivityJni(
+          env, "com/android/net/moduletests/util/ServiceConnectivityJni") < 0)
     return JNI_ERR;
 
   return JNI_VERSION_1_6;
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
index a66dacd..673d9e6 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
@@ -17,18 +17,13 @@
 package com.android.net.module.util;
 
 import static android.system.OsConstants.EPERM;
-import static android.system.OsConstants.R_OK;
-
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import android.annotation.Nullable;
 import android.system.ErrnoException;
-import android.system.Os;
 import android.util.Pair;
 
 import androidx.test.filters.SmallTest;
@@ -38,7 +33,6 @@
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.mockito.MockitoSession;
 
 import java.io.PrintWriter;
 import java.io.StringWriter;
@@ -129,9 +123,14 @@
         assertTrue(dump.contains("key=789, val=123"));
     }
 
-    private String getDumpMapStatus(final IBpfMap<Struct.S32, Struct.S32> map) {
+    private String getDumpMapStatus(final IBpfMap<Struct.S32, Struct.S32> map,
+            @Nullable final BpfDump.Dependencies deps) {
         final StringWriter sw = new StringWriter();
-        BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath");
+        if (deps == null) {
+            BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath");
+        } else {
+            BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath", deps);
+        }
         return sw.toString();
     }
 
@@ -139,25 +138,34 @@
     public void testGetMapStatus() {
         final IBpfMap<Struct.S32, Struct.S32> map =
                 new TestBpfMap<>(Struct.S32.class, Struct.S32.class);
-        assertEquals("mapName: OK\n", getDumpMapStatus(map));
+        assertEquals("mapName: OK\n", getDumpMapStatus(map, null /* deps */));
     }
 
     @Test
-    public void testGetMapStatusNull() {
-        final MockitoSession session = mockitoSession()
-                .spyStatic(Os.class)
-                .startMocking();
-        try {
-            // Os.access succeeds
-            doReturn(true).when(() -> Os.access("mapPath", R_OK));
-            assertEquals("mapName: NULL(map is pinned to mapPath)\n", getDumpMapStatus(null));
+    public void testGetMapStatusNull_accessSucceed() {
+        // Os.access succeeds
+        assertEquals("mapName: NULL(map is pinned to mapPath)\n",
+                getDumpMapStatus(null /* map */,
+                        new BpfDump.Dependencies() {
+                            @Override
+                            public boolean access(String path, int mode) {
+                                return true;
+                            }
+                        })
+        );
+    }
 
-            // Os.access throws EPERM
-            doThrow(new ErrnoException("", EPERM)).when(() -> Os.access("mapPath", R_OK));
-            assertEquals("mapName: NULL(map is not pinned to mapPath: Operation not permitted)\n",
-                    getDumpMapStatus(null));
-        } finally {
-            session.finishMocking();
-        }
+    @Test
+    public void testGetMapStatusNull_accessThrow() {
+        // Os.access throws EPERM
+        assertEquals("mapName: NULL(map is not pinned to mapPath: Operation not permitted)\n",
+                getDumpMapStatus(null /* map */,
+                        new BpfDump.Dependencies(){
+                            @Override
+                            public boolean access(String path, int mode) throws ErrnoException {
+                                throw new ErrnoException("", EPERM);
+                            }
+                        })
+        );
     }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/CollectionUtilsTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/CollectionUtilsTest.kt
index 7244803..1aa943e 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/CollectionUtilsTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/CollectionUtilsTest.kt
@@ -19,7 +19,7 @@
 import android.util.SparseArray
 import androidx.test.filters.SmallTest
 import androidx.test.runner.AndroidJUnit4
-import com.android.testutils.assertThrows
+import kotlin.test.assertContentEquals
 import org.junit.Test
 import org.junit.runner.RunWith
 import kotlin.test.assertEquals
@@ -196,4 +196,88 @@
         assertEquals(1, CollectionUtils.getIndexForValue(sparseArray, value1Copy))
         assertEquals(2, CollectionUtils.getIndexForValue(sparseArray, value2))
     }
+
+    @Test
+    fun testConcatEmptyByteArrays() {
+        assertContentEquals(
+                byteArrayOf(),
+                CollectionUtils.concatArrays(byteArrayOf(), byteArrayOf())
+        )
+    }
+
+    @Test
+    fun testConcatEmptyStringArrays() {
+        assertContentEquals(
+                arrayOf<String>(),
+                CollectionUtils.concatArrays(
+                        String::class.java,
+                        arrayOf<String>(),
+                        arrayOf<String>()
+                )
+        )
+    }
+
+    @Test
+    fun testConcatByteArrays() {
+        val byteArr1 = byteArrayOf(1, 2, 3)
+        val byteArr2 = byteArrayOf(4, 5, 6)
+        val byteArr3 = byteArrayOf()
+        val byteArrExpected = byteArrayOf(1, 2, 3, 4, 5, 6)
+        assertContentEquals(
+                byteArrExpected,
+                CollectionUtils.concatArrays(byteArr1, byteArr2, byteArr3)
+        )
+    }
+
+    @Test
+    fun testConcatStringArrays() {
+        val stringArr1 = arrayOf("1", "2", "3")
+        val stringArr2 = arrayOf("4", "5", "6")
+        val strinvArr3 = arrayOf<String>()
+        val stringArrExpected = arrayOf("1", "2", "3", "4", "5", "6")
+        assertContentEquals(
+                stringArrExpected,
+                CollectionUtils.concatArrays(String::class.java, stringArr1, stringArr2, strinvArr3)
+        )
+    }
+
+    @Test
+    fun testPrependByteArrays() {
+        val byteArr2 = byteArrayOf(4, 5, 6)
+        val byteArrExpected = byteArrayOf(1, 2, 3, 4, 5, 6)
+        assertContentEquals(
+                byteArrExpected,
+                CollectionUtils.prependArray(byteArr2, 1, 2, 3)
+        )
+    }
+
+    @Test
+    fun testPrependStringArrays() {
+        val stringArr2 = arrayOf("4", "5", "6")
+        val stringArrExpected = arrayOf("1", "2", "3", "4", "5", "6")
+        assertContentEquals(
+                stringArrExpected,
+                CollectionUtils.prependArray(String::class.java, stringArr2, "1", "2", "3")
+        )
+    }
+
+    @Test
+    fun testAppendByteArrays() {
+        val byteArr1 = byteArrayOf(1, 2, 3)
+        val byteArrExpected = byteArrayOf(1, 2, 3, 4, 5, 6)
+        assertContentEquals(
+                byteArrExpected,
+                CollectionUtils.appendArray(byteArr1, 4, 5, 6)
+        )
+    }
+
+    @Test
+    fun testAppendStringArrays() {
+        val stringArr1 = arrayOf("1", "2", "3")
+        val stringArrExpected = arrayOf("1", "2", "3", "4", "5", "6")
+        assertContentEquals(
+                stringArrExpected,
+                CollectionUtils.appendArray(String::class.java, stringArr1, "4", "5", "6")
+        )
+    }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/LruCacheWithExpiryTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/LruCacheWithExpiryTest.kt
new file mode 100644
index 0000000..b6af892
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/LruCacheWithExpiryTest.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util
+
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.function.LongSupplier
+
+@RunWith(DevSdkIgnoreRunner::class)
+class LruCacheWithExpiryTest {
+
+    companion object {
+        private const val CACHE_SIZE = 2
+        private const val EXPIRY_DURATION_MS = 1000L
+    }
+
+    private val timeSupplier = object : LongSupplier {
+        private var currentTimeMillis = 0L
+        override fun getAsLong(): Long = currentTimeMillis
+        fun advanceTime(millis: Long) { currentTimeMillis += millis }
+    }
+
+    private val cache = LruCacheWithExpiry<Int, String>(
+            timeSupplier, EXPIRY_DURATION_MS, CACHE_SIZE) { true }
+
+    @Test
+    fun testPutIfAbsent_keyNotPresent() {
+        val value = cache.putIfAbsent(1, "value1")
+        assertNull(value)
+        assertEquals("value1", cache.get(1))
+    }
+
+    @Test
+    fun testPutIfAbsent_keyPresent() {
+        cache.put(1, "value1")
+        val value = cache.putIfAbsent(1, "value2")
+        assertEquals("value1", value)
+        assertEquals("value1", cache.get(1))
+    }
+
+    @Test
+    fun testPutIfAbsent_keyPresentButExpired() {
+        cache.put(1, "value1")
+        // Advance time to expire the entry
+        timeSupplier.advanceTime(EXPIRY_DURATION_MS + 1)
+        val value = cache.putIfAbsent(1, "value2")
+        assertNull(value)
+        assertEquals("value2", cache.get(1))
+    }
+
+    @Test
+    fun testPutIfAbsent_maxSizeReached() {
+        cache.put(1, "value1")
+        cache.put(2, "value2")
+        cache.putIfAbsent(3, "value3") // This should evict the least recently used entry (1)
+        assertNull(cache.get(1))
+        assertEquals("value2", cache.get(2))
+        assertEquals("value3", cache.get(3))
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt
new file mode 100644
index 0000000..30b530f
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/RealtimeSchedulerTest.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util
+
+import android.os.Build
+import android.os.ConditionVariable
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Looper
+import android.os.Message
+import android.os.SystemClock
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.tryTest
+import com.android.testutils.visibleOnHandlerThread
+import com.google.common.collect.Range
+import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertEquals
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class RealtimeSchedulerTest {
+
+    private val TIMEOUT_MS = 1000L
+    private val TOLERANCE_MS = 50L
+    private class TestHandler(looper: Looper) : Handler(looper) {
+        override fun handleMessage(msg: Message) {
+            val pair = msg.obj as Pair<ConditionVariable, MutableList<Long>>
+            val cv = pair.first
+            cv.open()
+            val executionTimes = pair.second
+            executionTimes.add(SystemClock.elapsedRealtime())
+        }
+    }
+    private val thread = HandlerThread(RealtimeSchedulerTest::class.simpleName).apply { start() }
+    private val handler by lazy { TestHandler(thread.looper) }
+
+    @After
+    fun tearDown() {
+        thread.quitSafely()
+        thread.join()
+    }
+
+    @Test
+    fun testMultiplePostDelayedTasks() {
+        val scheduler = RealtimeScheduler(handler)
+        tryTest {
+            val initialTimeMs = SystemClock.elapsedRealtime()
+            val executionTimes = mutableListOf<Long>()
+            val cv = ConditionVariable()
+            handler.post {
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 0)
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 200)
+                val toBeRemoved = Runnable {
+                    executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs)
+                }
+                scheduler.postDelayed(toBeRemoved, 250)
+                scheduler.removeDelayedRunnable(toBeRemoved)
+                scheduler.postDelayed(
+                    { executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs) }, 100)
+                scheduler.postDelayed({
+                    executionTimes.add(SystemClock.elapsedRealtime() - initialTimeMs)
+                    cv.open() }, 300)
+            }
+            cv.block(TIMEOUT_MS)
+            assertEquals(4, executionTimes.size)
+            assertThat(executionTimes[0]).isIn(Range.closed(0L, TOLERANCE_MS))
+            assertThat(executionTimes[1]).isIn(Range.closed(100L, 100 + TOLERANCE_MS))
+            assertThat(executionTimes[2]).isIn(Range.closed(200L, 200 + TOLERANCE_MS))
+            assertThat(executionTimes[3]).isIn(Range.closed(300L, 300 + TOLERANCE_MS))
+        } cleanup {
+            visibleOnHandlerThread(handler) { scheduler.close() }
+        }
+    }
+
+    @Test
+    fun testMultipleSendDelayedMessages() {
+        val scheduler = RealtimeScheduler(handler)
+        tryTest {
+            val MSG_ID_0 = 0
+            val MSG_ID_1 = 1
+            val MSG_ID_2 = 2
+            val MSG_ID_3 = 3
+            val MSG_ID_4 = 4
+            val initialTimeMs = SystemClock.elapsedRealtime()
+            val executionTimes = mutableListOf<Long>()
+            val cv = ConditionVariable()
+            handler.post {
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_0, Pair(ConditionVariable(), executionTimes)), 0)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_1, Pair(ConditionVariable(), executionTimes)),
+                    200)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_4, Pair(ConditionVariable(), executionTimes)),
+                    250)
+                scheduler.removeDelayedMessage(MSG_ID_4)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_2, Pair(ConditionVariable(), executionTimes)),
+                    100)
+                scheduler.sendDelayedMessage(
+                    Message.obtain(handler, MSG_ID_3, Pair(cv, executionTimes)),
+                    300)
+            }
+            cv.block(TIMEOUT_MS)
+            assertEquals(4, executionTimes.size)
+            assertThat(executionTimes[0] - initialTimeMs).isIn(Range.closed(0L, TOLERANCE_MS))
+            assertThat(executionTimes[1] - initialTimeMs)
+                .isIn(Range.closed(100L, 100 + TOLERANCE_MS))
+            assertThat(executionTimes[2] - initialTimeMs)
+                .isIn(Range.closed(200L, 200 + TOLERANCE_MS))
+            assertThat(executionTimes[3] - initialTimeMs)
+                .isIn(Range.closed(300L, 300 + TOLERANCE_MS))
+        } cleanup {
+            visibleOnHandlerThread(handler) { scheduler.close() }
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/SkDestroyListenerTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/SkDestroyListenerTest.kt
similarity index 89%
rename from tests/unit/java/com/android/server/net/SkDestroyListenerTest.kt
rename to staticlibs/tests/unit/src/com/android/net/module/util/SkDestroyListenerTest.kt
index 18785e5..e4b47fe 100644
--- a/tests/unit/java/com/android/server/net/SkDestroyListenerTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/SkDestroyListenerTest.kt
@@ -14,11 +14,11 @@
  * limitations under the License.
  */
 
-package com.android.server.net
+package com.android.net.module.util
 
 import android.os.Handler
 import android.os.HandlerThread
-import com.android.net.module.util.SharedLog
+import com.android.net.module.util.SkDestroyListener.makeSkDestroyListener
 import com.android.testutils.DevSdkIgnoreRunner
 import java.io.PrintWriter
 import org.junit.After
@@ -54,7 +54,7 @@
         doReturn(sharedLog).`when`(sharedLog).forSubComponent(any())
 
         val handler = Handler(handlerThread.looper)
-        val skDestroylistener = SkDestroyListener(null /* cookieTagMap */, handler, sharedLog)
+        val skDestroylistener = makeSkDestroyListener({} /* consumer */, handler, sharedLog)
         val pw = PrintWriter(System.out)
         skDestroylistener.dump(pw)
 
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/TerribleErrorLogTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/TerribleErrorLogTest.kt
new file mode 100644
index 0000000..5fd634e
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/TerribleErrorLogTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util
+
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.testutils.tryTest
+import kotlin.test.assertContentEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class TerribleErrorLogTest {
+    @Test
+    fun testLogTerribleError() {
+        val wtfCaptures = mutableListOf<String>()
+        val prevHandler = Log.setWtfHandler { tag, what, system ->
+            wtfCaptures.add("$tag,${what.message}")
+        }
+        val statsLogCapture = mutableListOf<Pair<Int, Int>>()
+        val testStatsLog = object {
+            fun write(protoType: Int, errorType: Int) {
+                statsLogCapture.add(protoType to errorType)
+            }
+        }
+        tryTest {
+            TerribleErrorLog.logTerribleError(testStatsLog::write, "error", 1, 2)
+            assertContentEquals(listOf(1 to 2), statsLogCapture)
+            assertContentEquals(listOf("TerribleErrorLog,error"), wtfCaptures)
+        } cleanup {
+            Log.setWtfHandler(prevHandler)
+        }
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt
deleted file mode 100644
index f5e47c9..0000000
--- a/staticlibs/tests/unit/src/com/android/net/module/util/TimerFileDescriptorTest.kt
+++ /dev/null
@@ -1,114 +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 com.android.net.module.util
-
-import android.os.Build
-import android.os.ConditionVariable
-import android.os.Handler
-import android.os.HandlerThread
-import android.os.Looper
-import android.os.Message
-import androidx.test.filters.SmallTest
-import com.android.net.module.util.TimerFileDescriptor.ITask
-import com.android.net.module.util.TimerFileDescriptor.MessageTask
-import com.android.net.module.util.TimerFileDescriptor.RunnableTask
-import com.android.testutils.DevSdkIgnoreRule
-import com.android.testutils.DevSdkIgnoreRunner
-import com.android.testutils.tryTest
-import com.android.testutils.visibleOnHandlerThread
-import org.junit.After
-import org.junit.Test
-import org.junit.runner.RunWith
-import java.time.Duration
-import java.time.Instant
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
-
-private const val MSG_TEST = 1
-
-@DevSdkIgnoreRunner.MonitorThreadLeak
-@RunWith(DevSdkIgnoreRunner::class)
-@SmallTest
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
-class TimerFileDescriptorTest {
-    private class TestHandler(looper: Looper) : Handler(looper) {
-        override fun handleMessage(msg: Message) {
-            val cv = msg.obj as ConditionVariable
-            cv.open()
-        }
-    }
-    private val thread = HandlerThread(TimerFileDescriptorTest::class.simpleName).apply { start() }
-    private val handler by lazy { TestHandler(thread.looper) }
-
-    @After
-    fun tearDown() {
-        thread.quitSafely()
-        thread.join()
-    }
-
-    private fun assertDelayedTaskPost(
-            timerFd: TimerFileDescriptor,
-            task: ITask,
-            cv: ConditionVariable
-    ) {
-        val delayTime = 10L
-        val startTime1 = Instant.now()
-        handler.post { timerFd.setDelayedTask(task, delayTime) }
-        assertTrue(cv.block(100L /* timeoutMs*/))
-        assertTrue(Duration.between(startTime1, Instant.now()).toMillis() >= delayTime)
-    }
-
-    @Test
-    fun testSetDelayedTask() {
-        val timerFd = TimerFileDescriptor(handler)
-        tryTest {
-            // Verify the delayed task is executed with the self-implemented ITask
-            val cv1 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, { cv1.open() }, cv1)
-
-            // Verify the delayed task is executed with the RunnableTask
-            val cv2 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, RunnableTask{ cv2.open() }, cv2)
-
-            // Verify the delayed task is executed with the MessageTask
-            val cv3 = ConditionVariable()
-            assertDelayedTaskPost(timerFd, MessageTask(handler.obtainMessage(MSG_TEST, cv3)), cv3)
-        } cleanup {
-            visibleOnHandlerThread(handler) { timerFd.close() }
-        }
-    }
-
-    @Test
-    fun testCancelTask() {
-        // The task is posted and canceled within the same handler loop, so the short delay used
-        // here won't cause flakes.
-        val delayTime = 10L
-        val timerFd = TimerFileDescriptor(handler)
-        val cv = ConditionVariable()
-        tryTest {
-            handler.post {
-                timerFd.setDelayedTask({ cv.open() }, delayTime)
-                assertTrue(timerFd.hasDelayedTask())
-                timerFd.cancelTask()
-                assertFalse(timerFd.hasDelayedTask())
-            }
-            assertFalse(cv.block(20L /* timeoutMs*/))
-        } cleanup {
-            visibleOnHandlerThread(handler) { timerFd.close() }
-        }
-    }
-}
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/ip/ConntrackMonitorTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/ip/ConntrackMonitorTest.java
index 7ee376c..90476a7 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/ip/ConntrackMonitorTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/ip/ConntrackMonitorTest.java
@@ -29,7 +29,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.fail;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
 
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
index bd0e31d..08cab03 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
@@ -290,7 +290,7 @@
     @Test
     public void testCreateGetLinkMessage() {
         final String expectedHexBytes =
-                "20000000120001006824000000000000"    // struct nlmsghdr
+                "20000000120005006824000000000000"    // struct nlmsghdr
                 + "00000000080000000000000000000000"; // struct ifinfomsg
         final String interfaceName = "wlan0";
         final int interfaceIndex = 8;
@@ -306,6 +306,50 @@
     }
 
     @Test
+    public void testCreateSetInterfaceFlagsMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"    // struct nlmsghdr
+                        + "00000000080000000100000001000100"; // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetFlagsMessage(
+                interfaceName,
+                sequenceNumber,
+                mOsAccess,
+                NetlinkConstants.IFF_UP,
+                ~NetlinkConstants.IFF_LOWER_UP);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetInterfaceMtuMessage() {
+        final String expectedHexBytes =
+                "280000001000050068240000000000000000000008000000"   // struct nlmsghdr
+                        + "000000000000000008000400DC050000"; // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final int mtu = 1500;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetMtuMessage(
+                interfaceName,
+                sequenceNumber,
+                mtu,
+                mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
     public void testToString() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 86aa8f1..b59ccc6 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -25,7 +25,6 @@
     ],
     defaults: [
         "framework-connectivity-test-defaults",
-        "lib_mockito_extended",
     ],
     libs: [
         "androidx.annotation_annotation",
@@ -36,6 +35,7 @@
         "collector-device-lib",
         "kotlin-reflect",
         "libnanohttpd",
+        "mockito-target-minus-junit4",
         "net-tests-utils-host-device-common",
         "net-utils-device-common",
         "net-utils-device-common-async",
@@ -93,6 +93,7 @@
     libs: ["tradefed"],
     test_suites: [
         "ats",
+        "automotive-general-tests",
         "device-tests",
         "general-tests",
         "cts",
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
index 8e27c62..c42d9e5 100644
--- a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/ConnectivityCheckTest.kt
@@ -19,7 +19,7 @@
 import android.Manifest.permission.NETWORK_SETTINGS
 import android.content.pm.PackageManager.FEATURE_TELEPHONY
 import android.content.pm.PackageManager.FEATURE_WIFI
-import android.net.LinkAddress
+import android.net.InetAddresses.parseNumericAddress
 import android.net.Network
 import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
@@ -66,7 +66,8 @@
     // Skip IPv6 checks on virtual devices which do not support it. Tests that require IPv6 will
     // still fail even if the preparer does not.
     private fun ipv6Unsupported(wifiSsid: String?) = ConnectUtil.VIRTUAL_SSIDS.contains(
-        WifiInfo.sanitizeSsid(wifiSsid))
+        WifiInfo.sanitizeSsid(wifiSsid)
+    )
 
     @Test
     fun testCheckWifiSetup() {
@@ -89,13 +90,25 @@
                 pos = 0,
                 timeoutMs = 30_000L
             ) {
-                it is LinkPropertiesChanged &&
-                it.network == network &&
-                it.lp.allLinkAddresses.any(LinkAddress::isIpv4) &&
-                        (ipv6Unsupported(ssid) || it.lp.hasGlobalIpv6Address())
+                if (it !is LinkPropertiesChanged || it.network != network) {
+                    false
+                } else {
+                    // Same check as used by DnsResolver for AI_ADDRCONFIG (have_ipv4)
+                    val ipv4Reachable = it.lp.isReachable(parseNumericAddress("8.8.8.8"))
+                    // Same check as used by DnsResolver for AI_ADDRCONFIG (have_ipv6)
+                    val ipv6Reachable = it.lp.isReachable(parseNumericAddress("2000::"))
+                    ipv4Reachable && (ipv6Unsupported(ssid) || ipv6Reachable)
+                }
             }
-            assertNotNull(lpChange, "Wifi network $network needs an IPv4 address" +
-                    if (ipv6Unsupported(ssid)) "" else " and a global IPv6 address")
+            assertNotNull(
+                lpChange,
+                "Wifi network $network needs an IPv4 address and default route" +
+                        if (ipv6Unsupported(ssid)) {
+                            ""
+                        } else {
+                            " and a global IPv6 address and default route"
+                        }
+            )
 
             Pair(network, ssid)
         }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
new file mode 100644
index 0000000..076398e
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
@@ -0,0 +1,358 @@
+/*
+ * Copyright (C) 2025 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.testutils.com.android.testutils
+
+import android.Manifest.permission.MODIFY_PHONE_STATE
+import android.Manifest.permission.READ_PHONE_STATE
+import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager
+import android.os.ConditionVariable
+import android.os.ParcelFileDescriptor
+import android.os.PersistableBundle
+import android.os.Process
+import android.telephony.CarrierConfigManager
+import android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED
+import android.telephony.SubscriptionManager
+import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.CarrierPrivilegesCallback
+import android.util.Log
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.modules.utils.build.SdkLevel
+import com.android.testutils.runAsShell
+import com.android.testutils.tryTest
+import java.security.MessageDigest
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+private val TAG = CarrierConfigRule::class.simpleName
+private const val CARRIER_CONFIG_CHANGE_TIMEOUT_MS = 10_000L
+
+/**
+ * A [TestRule] that helps set [CarrierConfigManager] overrides for tests and clean up the test
+ * configuration automatically on teardown.
+ */
+class CarrierConfigRule : TestRule {
+    private val HEX_CHARS: CharArray = charArrayOf(
+        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+    )
+
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val uiAutomation by lazy { InstrumentationRegistry.getInstrumentation().uiAutomation }
+    private val ccm by lazy { context.getSystemService(CarrierConfigManager::class.java) }
+
+    // Map of (subId) -> (original values of overridden settings)
+    private val originalConfigs = mutableMapOf<Int, PersistableBundle>()
+
+    // Map of (subId) -> (original values of carrier service package)
+    private val originalCarrierServicePackages = mutableMapOf<Int, String?>()
+
+    override fun apply(base: Statement, description: Description): Statement {
+        return CarrierConfigStatement(base, description)
+    }
+
+    private inner class CarrierConfigStatement(
+        private val base: Statement,
+        private val description: Description
+    ) : Statement() {
+        override fun evaluate() {
+            tryTest {
+                base.evaluate()
+            } cleanup {
+                cleanUpNow()
+            }
+        }
+    }
+
+    private class ConfigChangeReceiver(private val subId: Int) : BroadcastReceiver() {
+        val cv = ConditionVariable()
+        override fun onReceive(context: Context, intent: Intent) {
+            if (intent.action != ACTION_CARRIER_CONFIG_CHANGED ||
+                intent.getIntExtra(EXTRA_SUBSCRIPTION_INDEX, -1) != subId) {
+                return
+            }
+            // This may race with other config changes for the same subId, but there is no way to
+            // know which update is being reported, and querying the override would return the
+            // latest values even before the config is applied. Config changes should be rare, so it
+            // is unlikely they would happen exactly after the override applied here and cause
+            // flakes.
+            cv.open()
+        }
+    }
+
+    private fun overrideConfigAndWait(subId: Int, config: PersistableBundle) {
+        val changeReceiver = ConfigChangeReceiver(subId)
+        context.registerReceiver(changeReceiver, IntentFilter(ACTION_CARRIER_CONFIG_CHANGED))
+        ccm.overrideConfig(subId, config)
+        assertTrue(
+            changeReceiver.cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+            "Timed out waiting for config change for subId $subId"
+        )
+        context.unregisterReceiver(changeReceiver)
+    }
+
+    /**
+     * Add carrier config overrides with the specified configuration.
+     *
+     * The overrides will automatically be cleaned up when the test case finishes.
+     */
+    fun addConfigOverrides(subId: Int, config: PersistableBundle) {
+        val originalConfig = originalConfigs.computeIfAbsent(subId) { PersistableBundle() }
+        val overrideKeys = config.keySet()
+        val previousValues = runAsShell(READ_PHONE_STATE) {
+            ccm.getConfigForSubIdCompat(subId, overrideKeys)
+        }
+        // If a key is already in the originalConfig, keep the oldest original overrides
+        originalConfig.keySet().forEach {
+            previousValues.remove(it)
+        }
+        originalConfig.putAll(previousValues)
+
+        runAsShell(MODIFY_PHONE_STATE) {
+            overrideConfigAndWait(subId, config)
+        }
+    }
+
+    private fun runShellCommand(cmd: String) {
+        val fd: ParcelFileDescriptor = uiAutomation.executeShellCommand(cmd)
+        fd.close() // Don't care about the output.
+    }
+
+    /**
+     * Converts a byte array into a String of hexadecimal characters.
+     *
+     * @param bytes an array of bytes
+     * @return hex string representation of bytes array
+     */
+    private fun bytesToHexString(bytes: ByteArray?): String? {
+        if (bytes == null) return null
+
+        val ret = StringBuilder(2 * bytes.size)
+
+        for (i in bytes.indices) {
+            var b: Int
+            b = 0x0f and (bytes[i].toInt() shr 4)
+            ret.append(HEX_CHARS[b])
+            b = 0x0f and bytes[i].toInt()
+            ret.append(HEX_CHARS[b])
+        }
+
+        return ret.toString()
+    }
+
+    private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
+        if (!SdkLevel.isAtLeastT()) {
+            throw UnsupportedOperationException(
+                "Acquiring carrier privilege requires at least T SDK"
+            )
+        }
+
+        fun getCertHash(): String {
+            val pkgInfo = context.packageManager.getPackageInfo(
+                context.opPackageName,
+                PackageManager.GET_SIGNATURES
+            )
+            val digest = MessageDigest.getInstance("SHA-256")
+            val certHash = digest.digest(pkgInfo.signatures!![0]!!.toByteArray())
+            return bytesToHexString(certHash)!!
+        }
+
+        val tm = context.getSystemService(TelephonyManager::class.java)!!
+
+        val cv = ConditionVariable()
+        val cpb = PrivilegeWaiterCallback(cv)
+        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
+        // T. This means the lambda will compile as a private method of this class taking a
+        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
+        // including private methods, this would fail with a link error when running on S-.
+        // To solve this, make the lambda serializable, which causes the compiler to emit a
+        // synthetic class instead of a synthetic method.
+        tryTest @JvmSerializableLambda {
+            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
+            }
+            // Wait for the callback to be registered
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't register CarrierPrivilegesCallback")
+            if (cpb.hasPrivilege == hold) {
+                if (hold) {
+                    Log.w(TAG, "Package ${context.opPackageName} already is privileged")
+                } else {
+                    Log.w(TAG, "Package ${context.opPackageName} already isn't privileged")
+                }
+                return@tryTest
+            }
+            cv.close()
+            if (hold) {
+                addConfigOverrides(subId, PersistableBundle().also {
+                    it.putStringArray(CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
+                        arrayOf(getCertHash()))
+                })
+            } else {
+                cleanUpNow()
+            }
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Timed out waiting for CarrierPrivilegesCallback")
+            assertEquals(cpb.hasPrivilege, hold, "Couldn't set carrier privilege")
+        } cleanup @JvmSerializableLambda {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.unregisterCarrierPrivilegesCallback(cpb)
+            }
+        }
+    }
+
+    /**
+     * Acquires carrier privilege on the given subscription ID.
+     */
+    fun acquireCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(true, subId)
+
+    /**
+     * Drops carrier privilege from the given subscription ID.
+     */
+    fun dropCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(false, subId)
+
+    /**
+     * Sets the carrier service package override for the given subscription ID. A null argument will
+     * clear any previously-set override.
+     */
+    fun setCarrierServicePackageOverride(subId: Int, pkg: String?) {
+        if (!SdkLevel.isAtLeastU()) {
+            throw UnsupportedOperationException(
+                "Setting carrier service package override requires at least U SDK"
+            )
+        }
+
+        val tm = context.getSystemService(TelephonyManager::class.java)!!
+
+        val cv = ConditionVariable()
+        val cpb = CarrierServiceChangedWaiterCallback(cv)
+        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
+        // T. This means the lambda will compile as a private method of this class taking a
+        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
+        // including private methods, this would fail with a link error when running on S-.
+        // To solve this, make the lambda serializable, which causes the compiler to emit a
+        // synthetic class instead of a synthetic method.
+        tryTest @JvmSerializableLambda {
+            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
+            }
+            // Wait for the callback to be registered
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't register CarrierPrivilegesCallback")
+            if (cpb.pkgName == pkg) {
+                Log.w(TAG, "Carrier service package was already $pkg")
+                return@tryTest
+            }
+            if (!originalCarrierServicePackages.contains(subId)) {
+                originalCarrierServicePackages.put(subId, cpb.pkgName)
+            }
+            cv.close()
+            runAsShell(MODIFY_PHONE_STATE) {
+                if (null == pkg) {
+                    // There is a bug in clear-carrier-service-package-override where not adding
+                    // the -s argument will use the wrong slot index : b/299604822
+                    runShellCommand("cmd phone clear-carrier-service-package-override" +
+                            " -s $subId")
+                } else {
+                    runShellCommand("cmd phone set-carrier-service-package-override $pkg" +
+                            " -s $subId")
+                }
+            }
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't modify carrier service package")
+        } cleanup @JvmSerializableLambda {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.unregisterCarrierPrivilegesCallback(cpb)
+            }
+        }
+    }
+
+    private class PrivilegeWaiterCallback(private val cv: ConditionVariable) :
+        CarrierPrivilegesCallback {
+        var hasPrivilege = false
+        override fun onCarrierPrivilegesChanged(p: MutableSet<String>, uids: MutableSet<Int>) {
+            hasPrivilege = uids.contains(Process.myUid())
+            cv.open()
+        }
+    }
+
+    private class CarrierServiceChangedWaiterCallback(private val cv: ConditionVariable) :
+        CarrierPrivilegesCallback {
+        var pkgName: String? = null
+        override fun onCarrierPrivilegesChanged(p: MutableSet<String>, u: MutableSet<Int>) {}
+        override fun onCarrierServiceChanged(pkgName: String?, uid: Int) {
+            this.pkgName = pkgName
+            cv.open()
+        }
+    }
+
+    /**
+     * Cleanup overrides that were added by the test case.
+     *
+     * This will be called automatically on test teardown, so it does not need to be called by the
+     * test case unless cleaning up earlier is required.
+     */
+    fun cleanUpNow() {
+        runAsShell(MODIFY_PHONE_STATE) {
+            originalConfigs.forEach { (subId, config) ->
+                try {
+                    // Do not use null as the config to reset, as it would reset configs that may
+                    // have been set by target preparers such as
+                    // ConnectivityTestTargetPreparer / CarrierConfigSetupTest.
+                    overrideConfigAndWait(subId, config)
+                } catch (e: Throwable) {
+                    Log.e(TAG, "Error resetting carrier config for subId $subId")
+                }
+            }
+            originalConfigs.clear()
+        }
+        originalCarrierServicePackages.forEach { (subId, pkg) ->
+            setCarrierServicePackageOverride(subId, pkg)
+        }
+        originalCarrierServicePackages.clear()
+    }
+}
+
+private fun CarrierConfigManager.getConfigForSubIdCompat(
+    subId: Int,
+    keys: Set<String>
+): PersistableBundle {
+    return if (SdkLevel.isAtLeastU()) {
+        // This method is U+
+        getConfigForSubId(subId, *keys.toTypedArray())
+    } else {
+        @Suppress("DEPRECATION")
+        val config = assertNotNull(getConfigForSubId(subId))
+        val allKeys = config.keySet().toList()
+        allKeys.forEach {
+            if (!keys.contains(it)) {
+                config.remove(it)
+            }
+        }
+        config
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
index e5b8471..1db5765 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -36,6 +36,7 @@
 import android.net.wifi.WifiManager
 import android.os.Build
 import android.os.ParcelFileDescriptor
+import android.os.ParcelFileDescriptor.AutoCloseInputStream
 import android.telephony.TelephonyManager
 import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN
 import android.util.Log
@@ -43,9 +44,14 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import java.io.ByteArrayOutputStream
+import java.io.CharArrayWriter
 import java.io.File
-import java.io.FileOutputStream
+import java.io.FileReader
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.OutputStreamWriter
 import java.io.PrintWriter
+import java.io.Reader
 import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 import java.util.concurrent.CompletableFuture
@@ -80,7 +86,38 @@
         var instance: ConnectivityDiagnosticsCollector? = null
     }
 
+    /**
+     * Indicates tcpdump should be started and written to the diagnostics file on test case failure.
+     */
+    annotation class CollectTcpdumpOnFailure
+
+    private class DumpThread(
+        // Keep a reference to the ParcelFileDescriptor otherwise GC would close it
+        private val fd: ParcelFileDescriptor,
+        private val reader: Reader
+    ) : Thread() {
+        private val writer = CharArrayWriter()
+        override fun run() {
+            reader.copyTo(writer)
+        }
+
+        fun closeAndWriteTo(output: OutputStream?) {
+            join()
+            fd.close()
+            if (output != null) {
+                val outputWriter = OutputStreamWriter(output)
+                outputWriter.write("--- tcpdump stopped at ${ZonedDateTime.now()} ---\n")
+                writer.writeTo(outputWriter)
+            }
+        }
+    }
+
+    private data class TcpdumpRun(val pid: Int, val reader: DumpThread)
+
     private var failureHeader: String? = null
+
+    // Accessed from the test listener methods which are synchronized by junit (see TestListener)
+    private var tcpdumpRun: TcpdumpRun? = null
     private val buffer = ByteArrayOutputStream()
     private val failureHeaderExtras = mutableMapOf<String, Any>()
     private val collectorDir: File by lazy {
@@ -132,7 +169,8 @@
                     .addCapability(NET_CAPABILITY_INTERNET)
                     .addTransportType(TRANSPORT_WIFI)
                     .addTransportType(TRANSPORT_CELLULAR)
-                    .build(), networkCallback
+                    .build(),
+                networkCallback
             )
         }
     }
@@ -148,16 +186,69 @@
         // when iterating on failing tests.
         if (!runOnFailure(failure.exception)) return
         if (outputFiles.size >= MAX_DUMPS) return
-        Log.i(TAG, "Collecting diagnostics for test failure. Disable by running tests with: " +
+        Log.i(
+            TAG,
+            "Collecting diagnostics for test failure. Disable by running tests with: " +
                 "atest MyModule -- " +
-                "--module-arg MyModule:instrumentation-arg:$ARG_RUN_ON_FAILURE:=false")
+                "--module-arg MyModule:instrumentation-arg:$ARG_RUN_ON_FAILURE:=false"
+        )
         collectTestFailureDiagnostics(failure.exception)
 
         val baseFilename = "${description.className}#${description.methodName}_failure"
         flushBufferToFileMetric(testData, baseFilename)
     }
 
+    override fun onTestStart(testData: DataRecord, description: Description) {
+        val tcpdumpAnn = description.annotations.firstOrNull { it is CollectTcpdumpOnFailure }
+                as? CollectTcpdumpOnFailure
+        if (tcpdumpAnn != null) {
+            startTcpdumpForTestcaseIfSupported()
+        }
+    }
+
+    private fun startTcpdumpForTestcaseIfSupported() {
+        if (!DeviceInfoUtils.isDebuggable()) {
+            Log.d(TAG, "Cannot start tcpdump, build is not debuggable")
+            return
+        }
+        if (tcpdumpRun != null) {
+            Log.e(TAG, "Cannot start tcpdump: it is already running")
+            return
+        }
+        // executeShellCommand won't tokenize quoted arguments containing spaces (like pcap filters)
+        // properly, so pass in the command in stdin instead of using sh -c 'command'
+        val fds = instrumentation.uiAutomation.executeShellCommandRw("sh")
+
+        val stdout = fds[0]
+        val stdin = fds[1]
+        ParcelFileDescriptor.AutoCloseOutputStream(stdin).use { writer ->
+            // Echo the current pid, and replace it (with exec) with the tcpdump process, so the
+            // tcpdump pid is known.
+            writer.write(
+                "echo $$; exec su 0 tcpdump -n -i any -l -xx".encodeToByteArray()
+            )
+        }
+        val reader = FileReader(stdout.fileDescriptor).buffered()
+        val tcpdumpPid = Integer.parseInt(reader.readLine())
+        val dumpThread = DumpThread(stdout, reader)
+        dumpThread.start()
+        tcpdumpRun = TcpdumpRun(tcpdumpPid, dumpThread)
+    }
+
+    private fun stopTcpdumpIfRunning(output: OutputStream?) {
+        val run = tcpdumpRun ?: return
+        // Send SIGTERM for graceful shutdown of tcpdump so that it can flush its output
+        executeCommandBlocking("su 0 kill ${run.pid}")
+        run.reader.closeAndWriteTo(output)
+        tcpdumpRun = null
+    }
+
     override fun onTestEnd(testData: DataRecord, description: Description) {
+        // onTestFail is called before onTestEnd, so if the test failed tcpdump would already have
+        // been stopped and output dumped. Here this stops tcpdump if the test succeeded, throwing
+        // away its output.
+        stopTcpdumpIfRunning(output = null)
+
         // Tests may call methods like collectDumpsysConnectivity to collect diagnostics at any time
         // during the run, for example to observe state at various points to investigate a flake
         // and compare passing/failing cases.
@@ -190,12 +281,13 @@
         }
         val outFile = File(collectorDir, filename + FILENAME_SUFFIX)
         outputFiles.add(filename)
-        FileOutputStream(outFile).use { fos ->
+        getOutputStreamViaShell(outFile).use { fos ->
             failureHeader?.let {
                 fos.write(it.toByteArray())
                 fos.write("\n".toByteArray())
             }
             fos.write(buffer.toByteArray())
+            stopTcpdumpIfRunning(fos)
         }
         failureHeader = null
         buffer.reset()
@@ -239,8 +331,11 @@
                 }
             }
         } else {
-            Log.w(TAG, "The test is still holding shell permissions, cannot collect privileged " +
-                    "device info")
+            Log.w(
+                TAG,
+                "The test is still holding shell permissions, cannot collect privileged " +
+                    "device info"
+            )
             headerObj.put("shellPermissionsUnavailable", true)
         }
         failureHeader = headerObj.apply {
@@ -292,7 +387,9 @@
         cbHelper.registerNetworkCallback(
             NetworkRequest.Builder()
                 .addTransportType(TRANSPORT_WIFI)
-                .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
+                .addCapability(NET_CAPABILITY_INTERNET).build(),
+            cb
+        )
         return try {
             cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS)
         } catch (e: TimeoutException) {
@@ -323,16 +420,74 @@
      * @param exceptionContext An exception to write a stacktrace to the dump for context.
      */
     fun collectDumpsysConnectivity(exceptionContext: Throwable? = null) {
-        Log.i(TAG, "Collecting dumpsys connectivity for test artifacts")
+        collectDumpsys("connectivity --dump-priority HIGH", exceptionContext)
+    }
+
+    /**
+     * Add a dumpsys to the test data dump.
+     *
+     * <p>The dump will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param dumpsysCmd The dumpsys command to run (for example "connectivity").
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectDumpsys(dumpsysCmd: String, exceptionContext: Throwable? = null) =
+        collectCommandOutput("dumpsys $dumpsysCmd", exceptionContext = exceptionContext)
+
+    /**
+     * Add the output of a command to the test data dump.
+     *
+     * <p>The output will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param cmd The command to run. Stdout of the command will be collected.
+     * @param shell The shell to run the command in, for example "sh".
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    @RequiresApi(Build.VERSION_CODES.S)
+    fun collectCommandOutput(
+        cmd: String,
+        shell: String,
+        exceptionContext: Throwable? = null
+    ) = collectCommandOutput(cmd, exceptionContext) { c, outputProcessor ->
+        runCommandInShell(c, shell, outputProcessor)
+    }
+
+    /**
+     * Add the output of a command to the test data dump.
+     *
+     * <p>The output will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     *
+     * <p>Note this does not support shell pipes, redirections, or quoted arguments. See the S+
+     * overload if that is needed.
+     * @param cmd The command to run. Stdout of the command will be collected.
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectCommandOutput(
+        cmd: String,
+        exceptionContext: Throwable? = null
+    ) = collectCommandOutput(cmd, exceptionContext) { c, outputProcessor ->
+        AutoCloseInputStream(
+            InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(c)
+        ).use {
+            outputProcessor(it)
+        }
+    }
+
+    private fun collectCommandOutput(
+        cmd: String,
+        exceptionContext: Throwable? = null,
+        commandRunner: (String, (InputStream) -> Unit) -> Unit
+    ) {
+        Log.i(TAG, "Collecting '$cmd' for test artifacts")
         PrintWriter(buffer).let {
-            it.println("--- Dumpsys connectivity at ${ZonedDateTime.now()} ---")
+            it.println("--- $cmd at ${ZonedDateTime.now()} ---")
             maybeWriteExceptionContext(it, exceptionContext)
             it.flush()
         }
-        ParcelFileDescriptor.AutoCloseInputStream(
-            InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
-                "dumpsys connectivity --dump-priority HIGH")).use {
-            it.copyTo(buffer)
+
+        commandRunner(cmd) { stdout ->
+            stdout.copyTo(buffer)
         }
     }
 
@@ -350,4 +505,4 @@
         writer.println("At: ")
         exceptionContext.printStackTrace(writer)
     }
-}
\ No newline at end of file
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt b/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt
new file mode 100644
index 0000000..a6e7ead
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2025 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.testutils
+
+private const val POLLING_INTERVAL_MS: Int = 100
+
+/** Calls condition() until it returns true or timeout occurs. */
+fun pollingCheck(timeout_ms: Long, condition: () -> Boolean): Boolean {
+    var polling_time = 0
+    do {
+        Thread.sleep(POLLING_INTERVAL_MS.toLong())
+        polling_time += POLLING_INTERVAL_MS
+        if (condition()) return true
+    } while (polling_time < timeout_ms)
+    return false
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
index 7b970d3..0b239b4 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
@@ -68,10 +68,15 @@
      * If any `@FeatureFlag` annotation is found, it passes every feature flag's name
      * and enabled state into the user-specified lambda to apply custom actions.
      */
+    private val parameterizedRegexp = Regex("\\[\\d+\\]$")
     override fun apply(base: Statement, description: Description): Statement {
         return object : Statement() {
             override fun evaluate() {
-                val testMethod = description.testClass.getMethod(description.methodName)
+                // If the same class also uses Parameterized, depending on evaluation order the
+                // method names here may be synthetic method names, where [0] [1] or so are added
+                // at the end of the method name. Find the original method name.
+                val methodName = description.methodName.replace(parameterizedRegexp, "")
+                val testMethod = description.testClass.getMethod(methodName)
                 val featureFlagAnnotations = testMethod.getAnnotationsByType(
                     FeatureFlag::class.java
                 )
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ShellUtil.kt b/staticlibs/testutils/devicetests/com/android/testutils/ShellUtil.kt
new file mode 100644
index 0000000..2b74036
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ShellUtil.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2025 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.
+ */
+
+@file:JvmName("ShellUtil")
+
+package com.android.testutils
+
+import android.app.UiAutomation
+import android.os.Build
+import android.os.ParcelFileDescriptor.AutoCloseInputStream
+import android.os.ParcelFileDescriptor.AutoCloseOutputStream
+import androidx.annotation.RequiresApi
+import androidx.test.platform.app.InstrumentationRegistry
+import java.io.InputStream
+
+/**
+ * Run a command in a shell.
+ *
+ * Compared to [UiAutomation.executeShellCommand], this allows running commands with pipes and
+ * redirections. [UiAutomation.executeShellCommand] splits the command on spaces regardless of
+ * quotes, so it is not able to run commands like `sh -c "echo 123 > some_file"`.
+ *
+ * @param cmd Shell command to run.
+ * @param shell Command used to run the shell.
+ * @param outputProcessor Function taking stdout, stderr as argument. The streams will be closed
+ *                        when this function returns.
+ * @return Result of [outputProcessor].
+ */
+@RequiresApi(Build.VERSION_CODES.S) // executeShellCommandRw is 31+
+fun <T> runCommandInShell(
+    cmd: String,
+    shell: String = "sh",
+    outputProcessor: (InputStream) -> T,
+): T {
+    val (stdout, stdin) = InstrumentationRegistry.getInstrumentation().uiAutomation
+        .executeShellCommandRw(shell)
+    AutoCloseOutputStream(stdin).bufferedWriter().use { it.write(cmd) }
+    AutoCloseInputStream(stdout).use { outStream ->
+        return outputProcessor(outStream)
+    }
+}
+
+/**
+ * Run a command in a shell.
+ *
+ * Overload of [runCommandInShell] that reads and returns stdout as String.
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+fun runCommandInShell(
+    cmd: String,
+    shell: String = "sh",
+) = runCommandInShell(cmd, shell) { stdout ->
+    stdout.reader().use { it.readText() }
+}
+
+/**
+ * Run a command in a root shell.
+ *
+ * This is generally only usable on devices on which [DeviceInfoUtils.isDebuggable] is true.
+ * @see runCommandInShell
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+fun runCommandInRootShell(
+    cmd: String
+) = runCommandInShell(cmd, shell = "su root sh")
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
index 8dc1bc4..1a82c5b 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
@@ -14,19 +14,37 @@
  * limitations under the License.
  */
 
-package com.android.testutils;
+package com.android.testutils
 
 import android.content.Context
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER
+import android.net.InetAddresses.parseNumericAddress
 import android.net.KeepalivePacketData
+import android.net.LinkAddress
 import android.net.LinkProperties
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.TRANSPORT_TEST
 import android.net.NetworkProvider
+import android.net.NetworkRequest
+import android.net.NetworkScore
 import android.net.QosFilter
 import android.net.Uri
 import android.os.Looper
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import android.system.OsConstants.EADDRNOTAVAIL
+import android.system.OsConstants.ENETUNREACH
+import android.system.OsConstants.ENONET
+import android.system.OsConstants.IPPROTO_UDP
+import android.system.OsConstants.SOCK_DGRAM
+import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import com.android.net.module.util.ArrayTrackRecord
+import com.android.testutils.CompatUtil.makeTestNetworkSpecifier
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAddKeepalivePacketFilter
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAutomaticReconnectDisabled
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnBandwidthUpdateRequested
@@ -42,21 +60,28 @@
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnStopSocketKeepalive
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnUnregisterQosCallback
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnValidationStatus
+import java.net.NetworkInterface
+import java.net.SocketException
 import java.time.Duration
 import kotlin.test.assertEquals
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlin.test.fail
 import org.junit.Assert.assertArrayEquals
 
 // Any legal score (0~99) for the test network would do, as it is going to be kept up by the
 // requests filed by the test and should never match normal internet requests. 70 is the default
 // score of Ethernet networks, it's as good a value as any other.
+// Note that this can't use NetworkScore.Builder() because this test must be able to
+// run on R, which doesn't know this class.
 private const val TEST_NETWORK_SCORE = 70
 
 private class Provider(context: Context, looper: Looper) :
             NetworkProvider(context, looper, "NetworkAgentTest NetworkProvider")
 
+private val enabledFeatures = mutableMapOf<Long, Boolean>()
+
 public open class TestableNetworkAgent(
     context: Context,
     looper: Looper,
@@ -64,7 +89,107 @@
     val lp: LinkProperties,
     conf: NetworkAgentConfig
 ) : NetworkAgent(context, looper, TestableNetworkAgent::class.java.simpleName /* tag */,
-        nc, lp, TEST_NETWORK_SCORE, conf, Provider(context, looper)) {
+    nc, lp, TEST_NETWORK_SCORE, conf, Provider(context, looper)) {
+
+    override fun isFeatureEnabled(context: Context, feature: Long): Boolean {
+        when (val it = enabledFeatures.get(feature)) {
+            null -> {
+                val cm = context.getSystemService(ConnectivityManager::class.java)
+                val res = cm.isFeatureEnabled(feature)
+                enabledFeatures[feature] = res
+                return res
+            }
+            else -> return it
+        }
+    }
+
+    companion object {
+        fun setFeatureEnabled(flag: Long, enabled: Boolean) = enabledFeatures.set(flag, enabled)
+
+        /**
+         * Convenience method to create a [NetworkRequest] matching [TestableNetworkAgent]s from
+         * [createOnInterface].
+         */
+        fun makeNetworkRequestForInterface(ifaceName: String) = NetworkRequest.Builder()
+            .removeCapability(NET_CAPABILITY_TRUSTED)
+            .addTransportType(TRANSPORT_TEST)
+            .setNetworkSpecifier(makeTestNetworkSpecifier(ifaceName))
+            .build()
+
+        /**
+         * Convenience method to initialize a [TestableNetworkAgent] on a given interface.
+         *
+         * This waits for link-local addresses to be setup and ensures LinkProperties are updated
+         * with the addresses.
+         */
+        fun createOnInterface(
+            context: Context,
+            looper: Looper,
+            ifaceName: String,
+            timeoutMs: Long
+        ): TestableNetworkAgent {
+            val lp = LinkProperties().apply {
+                interfaceName = ifaceName
+            }
+            val agent = TestableNetworkAgent(
+                context,
+                looper,
+                NetworkCapabilities().apply {
+                    removeCapability(NET_CAPABILITY_TRUSTED)
+                    addTransportType(TRANSPORT_TEST)
+                    setNetworkSpecifier(makeTestNetworkSpecifier(ifaceName))
+                },
+                lp,
+                NetworkAgentConfig.Builder().build()
+            )
+            val network = agent.register()
+            agent.markConnected()
+            if (isAtLeastS()) {
+                // OnNetworkCreated was added in S
+                agent.eventuallyExpect<OnNetworkCreated>()
+            }
+
+            // Wait until the link-local address can be used. Address flags are not available
+            // without elevated permissions, so check that bindSocket works.
+            assertEventuallyTrue("No usable v6 address after $timeoutMs ms", timeoutMs) {
+                // To avoid race condition between socket connection succeeding and interface
+                // returning a non-empty address list. Verify that interface returns a non-empty
+                // list, before trying the socket connection.
+                if (NetworkInterface.getByName(ifaceName).interfaceAddresses.isEmpty()) {
+                    return@assertEventuallyTrue false
+                }
+
+                val sock = Os.socket(OsConstants.AF_INET6, SOCK_DGRAM, IPPROTO_UDP)
+                tryTest {
+                    network.bindSocket(sock)
+                    Os.connect(sock, parseNumericAddress("ff02::fb%$ifaceName"), 12345)
+                    true
+                }.catch<ErrnoException> {
+                    if (it.errno != ENETUNREACH && it.errno != EADDRNOTAVAIL) {
+                        throw it
+                    }
+                    false
+                }.catch<SocketException> {
+                    // OnNetworkCreated does not exist on R, so a SocketException caused by ENONET
+                    // may be seen before the network is created
+                    if (isAtLeastS()) throw it
+                    val cause = it.cause as? ErrnoException ?: throw it
+                    if (cause.errno != ENONET) {
+                        throw it
+                    }
+                    false
+                } cleanup {
+                    Os.close(sock)
+                }
+            }
+
+            agent.lp.setLinkAddresses(NetworkInterface.getByName(ifaceName).interfaceAddresses.map {
+                LinkAddress(it.address, it.networkPrefixLength.toInt())
+            })
+            agent.sendLinkProperties(agent.lp)
+            return agent
+        }
+    }
 
     val DEFAULT_TIMEOUT_MS = 5000L
 
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkCallback.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkCallback.kt
index ae43c15..d9c51e5 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkCallback.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkCallback.kt
@@ -32,6 +32,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LocalInfoChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Losing
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.RecorderCallback.CallbackEntry.Reserved
 import com.android.testutils.RecorderCallback.CallbackEntry.Resumed
 import com.android.testutils.RecorderCallback.CallbackEntry.Suspended
 import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
@@ -66,6 +67,12 @@
         // constructor by specifying override.
         abstract val network: Network
 
+        data class Reserved private constructor(
+                override val network: Network,
+                val caps: NetworkCapabilities
+        ): CallbackEntry() {
+            constructor(caps: NetworkCapabilities) : this(NULL_NETWORK, caps)
+        }
         data class Available(override val network: Network) : CallbackEntry()
         data class CapabilitiesChanged(
             override val network: Network,
@@ -100,6 +107,8 @@
         // Convenience constants for expecting a type
         companion object {
             @JvmField
+            val RESERVED = Reserved::class
+            @JvmField
             val AVAILABLE = Available::class
             @JvmField
             val NETWORK_CAPS_UPDATED = CapabilitiesChanged::class
@@ -127,6 +136,11 @@
     val history = backingRecord.newReadHead()
     val mark get() = history.mark
 
+    override fun onReserved(caps: NetworkCapabilities) {
+        Log.d(logTag, "onReserved $caps")
+        history.add(Reserved(caps))
+    }
+
     override fun onAvailable(network: Network) {
         Log.d(logTag, "onAvailable $network")
         history.add(Available(network))
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkOfferCallback.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkOfferCallback.kt
index 21bd60c..a0078d2 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkOfferCallback.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkOfferCallback.kt
@@ -52,10 +52,11 @@
 
     inline fun <reified T : CallbackEntry> expectCallbackThat(
         crossinline predicate: (T) -> Boolean
-    ) {
+    ): T {
         val event = history.poll(timeoutMs)
                 ?: fail("Did not receive callback after ${timeoutMs}ms")
         if (event !is T || !predicate(event)) fail("Received unexpected callback $event")
+        return event
     }
 
     fun expectOnNetworkNeeded(capabilities: NetworkCapabilities) =
diff --git a/staticlibs/testutils/host/python/apf_test_base.py b/staticlibs/testutils/host/python/apf_test_base.py
index 2552aa3..c9cda41 100644
--- a/staticlibs/testutils/host/python/apf_test_base.py
+++ b/staticlibs/testutils/host/python/apf_test_base.py
@@ -60,16 +60,19 @@
     self.client_ipv4_addresses = apf_utils.get_ipv4_addresses(
         self.clientDevice, self.client_iface_name
     )
-    self.server_ipv6_addresses = apf_utils.get_ipv6_addresses(
+    self.server_ipv6_addresses = apf_utils.get_non_tentative_ipv6_addresses(
         self.serverDevice, self.server_iface_name
     )
-    self.client_ipv6_addresses = apf_utils.get_ipv6_addresses(
+    self.client_ipv6_addresses = apf_utils.get_non_tentative_ipv6_addresses(
         self.clientDevice, self.client_iface_name
     )
 
     # Enable doze mode to activate APF.
     adb_utils.set_doze_mode(self.clientDevice, True)
 
+    # Longer wait time is required for APF to become active in CTS test suite.
+    time.sleep(5)
+
   def teardown_class(self):
     adb_utils.set_doze_mode(self.clientDevice, False)
     tether_utils.cleanup_tethering_for_upstream_type(
@@ -103,7 +106,15 @@
     try:
         apf_utils.start_capture_packets(self.serverDevice, self.server_iface_name)
 
-        self.send_packet_and_expect_counter_increased(send_packet, counter_name)
+        count_before_test = apf_utils.get_apf_counter(
+            self.clientDevice,
+            self.client_iface_name,
+            counter_name,
+        )
+
+        apf_utils.send_raw_packet_downstream(
+            self.serverDevice, self.server_iface_name, send_packet
+        )
 
         assert_utils.expect_with_retry(
             lambda: apf_utils.get_matched_packet_counts(
@@ -111,5 +122,17 @@
             )
             == 1
         )
+
+        # TODO: re-enable once the test passes reliably.
+        if False:
+            assert_utils.expect_with_retry(
+                lambda: apf_utils.get_apf_counter(
+                    self.clientDevice,
+                    self.client_iface_name,
+                    counter_name,
+                )
+                > count_before_test
+            )
+
     finally:
         apf_utils.stop_capture_packets(self.serverDevice, self.server_iface_name)
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index 55ac860..4835c23 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -18,6 +18,7 @@
 from mobly.controllers import android_device
 from mobly.controllers.android_device_lib.adb import AdbError
 from net_tests_utils.host.python import adb_utils, assert_utils
+import functools
 
 
 class PatternNotFoundException(Exception):
@@ -115,12 +116,12 @@
   else:
     return []
 
-def get_ipv6_addresses(
+def get_non_tentative_ipv6_addresses(
     ad: android_device.AndroidDevice, iface_name: str
 ) -> list[str]:
-  """Retrieves the IPv6 addresses of a given interface on an Android device.
+  """Retrieves the non-tentative IPv6 addresses of a given interface on an Android device.
 
-  This function executes an ADB shell command (`ip -6 address show`) to get the
+  This function executes an ADB shell command (`ip -6 address show -tentative`) to get the
   network interface information and extracts the IPv6 address from the output.
   If devices have no IPv6 address, raise PatternNotFoundException.
 
@@ -138,7 +139,7 @@
   #         valid_lft forever preferred_lft forever
   #     inet6 fe80::1233:aadb:3d32:1234/64 scope link
   #         valid_lft forever preferred_lft forever
-  output = adb_utils.adb_shell(ad, f"ip -6 address show {iface_name}")
+  output = adb_utils.adb_shell(ad, f"ip -6 address show -tentative {iface_name}")
   pattern = r"inet6\s+([0-9a-fA-F:]+)\/\d+"
   matches = re.findall(pattern, output)
 
@@ -147,6 +148,38 @@
   else:
     return []
 
+def get_exclude_all_host_ipv6_multicast_addresses(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> list[str]:
+  """Retrieves the IPv6 multicast addresses of a given interface on an Android device.
+
+  This function executes an ADB shell command (`ip -6 maddr show`) to get the
+  network interface information and extracts the IPv6 multicast address from the output.
+  If devices have no IPv6 multicast address, raise PatternNotFoundException.
+
+  Args:
+      ad: The Android device object.
+      iface_name: The name of the network interface (e.g., "wlan0").
+
+  Returns:
+      The IPv6 multicast addresses of the interface as a list of string.
+      Return empty list if no IPv6 multicast address.
+  """
+  # output format
+  # 47:     wlan0
+  #         inet6 ff02::1:ff99:37b0
+  #         inet6 ff02::1:ffb7:cba2 users 2
+  #         inet6 ff02::1
+  #         inet6 ff01::1
+  output = adb_utils.adb_shell(ad, f"ip -6 maddr show {iface_name}")
+  pattern = r"inet6\s+([a-fA-F0-9:]+)(?:\s+users\s+\d+)?"
+  matches = re.findall(pattern, output)
+
+  if matches:
+    return [addr for addr in matches if addr not in ("ff02::1", "ff01::1")]
+  else:
+    return []
+
 def get_hardware_address(
     ad: android_device.AndroidDevice, iface_name: str
 ) -> str:
@@ -162,14 +195,18 @@
   """
 
   # Run the "ip link" command and get its output.
-  ip_link_output = adb_utils.adb_shell(ad, f"ip link show {iface_name}")
+  ip_link_output = adb_utils.adb_shell(ad, f"ip link")
 
   # Regular expression to extract the MAC address.
   # Parse hardware address from ip link output like below:
+  # 45: rmnet29: <POINTOPOINT,MULTICAST,NOARP> mtu 1500 qdisc ...
+  #    link/ether 73:01:23:45:df:e3 brd ff:ff:ff:ff:ff:ff
   # 46: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq ...
   #    link/ether 72:05:77:82:21:e0 brd ff:ff:ff:ff:ff:ff
-  pattern = r"link/ether (([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})"
-  match = re.search(pattern, ip_link_output)
+  # 47: wlan1: <BROADCAST,MULTICAST> mtu 1500 qdisc ...
+  #    link/ether 6a:16:81:ff:82:9b brd ff:ff:ff:ff:ff:ff"
+  pattern = rf"{iface_name}:.*?link/ether (([0-9a-fA-F]{{2}}:){{5}}[0-9a-fA-F]{{2}})"
+  match = re.search(pattern, ip_link_output, re.DOTALL)
 
   if match:
     return match.group(1).upper()  # Extract the MAC address string.
@@ -396,6 +433,38 @@
       f" {expected_version}",
   )
 
+def at_least_B():
+  def decorator(test_function):
+    @functools.wraps(test_function)
+    def wrapper(self, *args, **kwargs):
+      asserts.abort_class_if(
+        (not hasattr(self, 'client')) or (not hasattr(self.client, 'isAtLeastB')),
+        "no valid client attribute"
+      )
+
+      asserts.abort_class_if(not self.client.isAtLeastB(), "client device is not Android B+")
+      return test_function(self, *args, **kwargs)
+    return wrapper
+  return decorator
+
+def apf_ram_at_least(size):
+  def decorator(test_function):
+    @functools.wraps(test_function)
+    def wrapper(self, *args, **kwargs):
+      asserts.abort_class_if(
+        (not hasattr(self, 'clientDevice')) or (not hasattr(self, 'client_iface_name')),
+        "no valid client attribute"
+      )
+
+      caps = get_apf_capabilities(self.clientDevice, self.client_iface_name)
+      asserts.skip_if(
+        caps.apf_ram_size < size,
+        f'APF rame size {caps.apf_ram_size} < {size}'
+      )
+      return test_function(self, *args, **kwargs)
+    return wrapper
+  return decorator
+
 class AdbOutputHandler:
   def __init__(self, ad, cmd):
     self._ad = ad
diff --git a/staticlibs/testutils/host/python/multi_devices_test_base.py b/staticlibs/testutils/host/python/multi_devices_test_base.py
index 677329a..72bac0c 100644
--- a/staticlibs/testutils/host/python/multi_devices_test_base.py
+++ b/staticlibs/testutils/host/python/multi_devices_test_base.py
@@ -53,3 +53,4 @@
         raise_on_exception=True,
     )
     self.client = self.clientDevice.connectivity_multi_devices_snippet
+    self.server = self.serverDevice.connectivity_multi_devices_snippet
diff --git a/staticlibs/testutils/host/python/packet_utils.py b/staticlibs/testutils/host/python/packet_utils.py
deleted file mode 100644
index b613f03..0000000
--- a/staticlibs/testutils/host/python/packet_utils.py
+++ /dev/null
@@ -1,70 +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.
-from ipaddress import IPv4Address
-from socket import inet_aton
-
-ETHER_BROADCAST_MAC_ADDRESS = "FF:FF:FF:FF:FF:FF"
-ARP_REQUEST_OP = 1
-ARP_REPLY_OP = 2
-
-"""
-This variable defines a template for constructing ARP packets in hexadecimal format.
-It's used to provide the common fields for ARP packet, and replaced needed fields when constructing
-"""
-ARP_TEMPLATE = (
-    # Ether Header (14 bytes)
-    "{dst_mac}" + # DA
-    "{src_mac}" + # SA
-    "0806" + # ARP
-    # ARP Header (28 bytes)
-    "0001" + # Hardware type (Ethernet)
-    "0800" + # Protocol type (IPv4)
-    "06" + # hardware address length
-    "04" + # protocol address length
-    "{opcode}" + # opcode
-    "{sender_mac}" + # sender MAC
-    "{sender_ip}" + # sender IP
-    "{target_mac}" + # target MAC
-    "{target_ip}" # target IP
-)
-
-def construct_arp_packet(src_mac, dst_mac, src_ip, dst_ip, op) -> str:
-    """Constructs an ARP packet as a hexadecimal string.
-
-    This function creates an ARP packet by filling in the required fields
-    in a predefined ARP packet template.
-
-    Args:
-    src_mac: The MAC address of the sender. (e.g. "11:22:33:44:55:66")
-    dst_mac: The MAC address of the recipient. (e.g. "aa:bb:cc:dd:ee:ff")
-    src_ip: The IP address of the sender. (e.g. "1.1.1.1")
-    dst_ip: The IP address of the target machine. (e.g. "2.2.2.2")
-    op: The op code of the ARP packet, refer to ARP_*_OP
-
-    Returns:
-    A string representing the ARP packet in hexadecimal format.
-    """
-    # Replace the needed fields from packet template
-    arp_pkt = ARP_TEMPLATE.format(
-            dst_mac=dst_mac.replace(":",""),
-            src_mac=src_mac.replace(":",""),
-            opcode=str(op).rjust(4, "0"),
-            sender_mac=src_mac.replace(":",""),
-            sender_ip=inet_aton(src_ip).hex(),
-            target_mac=("000000000000" if op == ARP_REQUEST_OP else dst_mac.replace(":", "")),
-            target_ip=inet_aton(dst_ip).hex()
-    )
-
-    # always convert to upper case hex string
-    return arp_pkt.upper()
\ No newline at end of file
diff --git a/staticlibs/testutils/host/python/tether_utils.py b/staticlibs/testutils/host/python/tether_utils.py
index c63de1f..710f8a8 100644
--- a/staticlibs/testutils/host/python/tether_utils.py
+++ b/staticlibs/testutils/host/python/tether_utils.py
@@ -95,7 +95,9 @@
   hotspot_interface = server.startHotspot(test_ssid, test_passphrase)
 
   # Make the client connects to the hotspot.
-  client_network = client.connectToWifi(test_ssid, test_passphrase)
+  client_network = client.connectToWifi(
+      test_ssid, test_passphrase, upstream_type != UpstreamType.NONE
+  )
 
   return hotspot_interface, client_network
 
diff --git a/tests/common/java/android/net/CaptivePortalTest.java b/tests/common/java/android/net/CaptivePortalTest.java
index 15d3398..6655827 100644
--- a/tests/common/java/android/net/CaptivePortalTest.java
+++ b/tests/common/java/android/net/CaptivePortalTest.java
@@ -19,6 +19,7 @@
 import static org.junit.Assert.assertEquals;
 
 import android.os.Build;
+import android.os.IBinder;
 import android.os.RemoteException;
 
 import androidx.test.filters.SmallTest;
@@ -55,6 +56,10 @@
             mCode = request;
         }
 
+        @Override
+        public void setDelegateUid(int uid, IBinder binder, IIntResultListener listener) {
+        }
+
         // This is only @Override on R-
         public void logEvent(int eventId, String packageName) throws RemoteException {
             mCode = eventId;
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index 0f0e2f1..3fc2af0 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -56,6 +56,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
 import static android.net.NetworkCapabilities.TRANSPORT_SATELLITE;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
+import static android.net.NetworkCapabilities.TRANSPORT_THREAD;
 import static android.net.NetworkCapabilities.TRANSPORT_USB;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
@@ -382,6 +383,7 @@
             netCap.setAllowedUids(allowedUids);
             netCap.setSubscriptionIds(Set.of(TEST_SUBID1, TEST_SUBID2));
             netCap.setUids(uids);
+            netCap.setReservationId(42);
         }
 
         netCap.setOwnerUid(123);
@@ -1493,4 +1495,131 @@
         // nc1 and nc2 are the same since invalid capability is ignored
         assertEquals(nc1, nc2);
     }
+
+    @Test
+    public void testReservationIdMatching() {
+        final NetworkCapabilities requestNc = new NetworkCapabilities();
+        final NetworkCapabilities reservationNc = new NetworkCapabilities();
+        reservationNc.setReservationId(42);
+
+        final NetworkCapabilities reservedNetworkNc = new NetworkCapabilities(reservationNc);
+        final NetworkCapabilities otherNetworkNc = new NetworkCapabilities();
+        final NetworkCapabilities otherReservedNetworkNc = new NetworkCapabilities();
+        otherReservedNetworkNc.setReservationId(99);
+        final NetworkCapabilities offerNc = new NetworkCapabilities();
+        offerNc.setReservationId(NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS);
+
+        // A regular request can match any network or offer except one with MATCH_ALL_RESERVATIONS
+        assertTrue(requestNc.satisfiedByNetworkCapabilities(reservedNetworkNc));
+        assertTrue(requestNc.satisfiedByNetworkCapabilities(otherNetworkNc));
+        assertTrue(requestNc.satisfiedByNetworkCapabilities(otherReservedNetworkNc));
+        assertFalse(requestNc.satisfiedByNetworkCapabilities(offerNc));
+
+        // A reservation request can only match the reservedNetwork and the blanket offer.
+        assertTrue(reservationNc.satisfiedByNetworkCapabilities(reservedNetworkNc));
+        assertFalse(reservationNc.satisfiedByNetworkCapabilities(otherNetworkNc));
+        assertFalse(reservationNc.satisfiedByNetworkCapabilities(otherReservedNetworkNc));
+        assertTrue(reservationNc.satisfiedByNetworkCapabilities(offerNc));
+    }
+
+    @Test @IgnoreUpTo(SC_V2)
+    public void testReservationIdEquals() {
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        nc.setReservationId(42);
+        final NetworkCapabilities other = new NetworkCapabilities(nc);
+
+        assertEquals(nc, other);
+
+        nc.setReservationId(43);
+        assertNotEquals(nc, other);
+    }
+
+    @Test
+    public void testMatchNonThreadLocalNetworks_equals() {
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        nc.setMatchNonThreadLocalNetworks(true);
+        final NetworkCapabilities other = new NetworkCapabilities(nc);
+        assertEquals(nc, other);
+
+        nc.setMatchNonThreadLocalNetworks(false);
+        assertNotEquals(nc, other);
+    }
+
+    @Test
+    public void testMatchNonThreadLocalNetworks_enabled() {
+        doTestMatchNonThreadLocalNetworks(true);
+    }
+
+    @Test
+    public void testMatchNonThreadLocalNetworks_disabled() {
+        doTestMatchNonThreadLocalNetworks(false);
+    }
+
+    private void doTestMatchNonThreadLocalNetworks(boolean enabled) {
+        // Setup request NCs.
+        final NetworkCapabilities noTransportRequestNc = new NetworkCapabilities();
+        final NetworkCapabilities threadRequestNc =
+                new NetworkCapabilities.Builder().addTransportType(TRANSPORT_THREAD).build();
+        final NetworkCapabilities wifiRequestNc =
+                new NetworkCapabilities.Builder().addTransportType(TRANSPORT_WIFI).build();
+        final NetworkCapabilities multiTransportRequestNc =
+                new NetworkCapabilities.Builder().addTransportType(
+                        TRANSPORT_THREAD).addTransportType(TRANSPORT_WIFI).build();
+
+        // Setup network NCs.
+        final NetworkCapabilities localNoTransportNc = new NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK).build();
+        final NetworkCapabilities localThreadsNc = new NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .addTransportType(TRANSPORT_THREAD).build();
+        final NetworkCapabilities localWifiNc = new NetworkCapabilities.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .addTransportType(TRANSPORT_WIFI).build();
+        final NetworkCapabilities wanWifiNc = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI).build();
+
+        // Mark flags accordingly.
+        noTransportRequestNc.setMatchNonThreadLocalNetworks(enabled);
+        threadRequestNc.setMatchNonThreadLocalNetworks(enabled);
+        wifiRequestNc.setMatchNonThreadLocalNetworks(enabled);
+        multiTransportRequestNc.setMatchNonThreadLocalNetworks(enabled);
+
+        if (enabled) {
+            // A request with no specific transport matches all networks.
+            assertTrue(noTransportRequestNc.satisfiedByNetworkCapabilities(localNoTransportNc));
+            assertTrue(noTransportRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        } else {
+            // A request with no specific transport only matches thread networks.
+            assertFalse(noTransportRequestNc.satisfiedByNetworkCapabilities(localNoTransportNc));
+            assertFalse(noTransportRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        }
+        assertTrue(noTransportRequestNc.satisfiedByNetworkCapabilities(localThreadsNc));
+        assertTrue(noTransportRequestNc.satisfiedByNetworkCapabilities(wanWifiNc));
+
+        // A request with TRANSPORT_THREAD only matches thread networks.
+        assertFalse(threadRequestNc.satisfiedByNetworkCapabilities(localNoTransportNc));
+        assertTrue(threadRequestNc.satisfiedByNetworkCapabilities(localThreadsNc));
+        assertFalse(threadRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        assertFalse(threadRequestNc.satisfiedByNetworkCapabilities(wanWifiNc));
+
+        assertFalse(multiTransportRequestNc.satisfiedByNetworkCapabilities(localNoTransportNc));
+        assertTrue(multiTransportRequestNc.satisfiedByNetworkCapabilities(localThreadsNc));
+        assertTrue(multiTransportRequestNc.satisfiedByNetworkCapabilities(wanWifiNc));
+        if (enabled) {
+            assertTrue(multiTransportRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        } else {
+            // A request with multiple transports only matches thread networks.
+            assertFalse(multiTransportRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        }
+
+        assertFalse(wifiRequestNc.satisfiedByNetworkCapabilities(localNoTransportNc));
+        assertFalse(wifiRequestNc.satisfiedByNetworkCapabilities(localThreadsNc));
+        assertTrue(wifiRequestNc.satisfiedByNetworkCapabilities(wanWifiNc));
+        if (enabled) {
+            assertTrue(wifiRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        } else {
+            // A request without TRANSPORT_THREAD matches nothing.
+            assertFalse(wifiRequestNc.satisfiedByNetworkCapabilities(localWifiNc));
+        }
+    }
 }
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 0ac9ce1..0b4375a 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -27,7 +27,7 @@
     // Note that some of the test helper apps (e.g., CtsHostsideNetworkCapTestsAppSdk33) override
     // this with older SDK versions.
     // Also note that unlike android_test targets, "current" does not work: the target SDK is set to
-    // something like "VanillaIceCream" instead of 100000. This means that the tests will not run on
+    // something like "VanillaIceCream" instead of 10000. This means that the tests will not run on
     // released devices with errors such as "Requires development platform VanillaIceCream but this
     // is a release platform".
     target_sdk_version: "10000",
diff --git a/tests/cts/hostside/app/AndroidManifest.xml b/tests/cts/hostside/app/AndroidManifest.xml
index e0f4cdc..2ef53b9 100644
--- a/tests/cts/hostside/app/AndroidManifest.xml
+++ b/tests/cts/hostside/app/AndroidManifest.xml
@@ -27,6 +27,7 @@
     <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
     <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
     <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>
+    <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
     <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"/>
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
     <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
index ad98a29..65844a3 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
@@ -16,18 +16,24 @@
 
 package com.android.cts.net.hostside;
 
+import static android.net.TetheringManager.TETHERING_WIFI;
+
 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assume.assumeTrue;
 
 import android.content.Context;
 import android.net.TetheringInterface;
 import android.net.cts.util.CtsTetheringUtils;
+import android.net.cts.util.CtsTetheringUtils.TestTetheringEventCallback;
 import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiSsid;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -37,6 +43,7 @@
 public class TetheringTest {
     private CtsTetheringUtils mCtsTetheringUtils;
     private TetheringHelperClient mTetheringHelperClient;
+    private TestTetheringEventCallback mTetheringEventCallback;
 
     @Before
     public void setUp() throws Exception {
@@ -44,11 +51,14 @@
         mCtsTetheringUtils = new CtsTetheringUtils(targetContext);
         mTetheringHelperClient = new TetheringHelperClient(targetContext);
         mTetheringHelperClient.bind();
+        mTetheringEventCallback = mCtsTetheringUtils.registerTetheringEventCallback();
     }
 
     @After
     public void tearDown() throws Exception {
         mTetheringHelperClient.unbind();
+        mCtsTetheringUtils.unregisterTetheringEventCallback(mTetheringEventCallback);
+        mCtsTetheringUtils.stopAllTethering();
     }
 
     /**
@@ -57,24 +67,25 @@
      */
     @Test
     public void testSoftApConfigurationRedactedForOtherUids() throws Exception {
-        final CtsTetheringUtils.TestTetheringEventCallback tetherEventCallback =
-                mCtsTetheringUtils.registerTetheringEventCallback();
+        assumeTrue(SdkLevel.isAtLeastB());
+
+        mTetheringEventCallback.assumeWifiTetheringSupported(
+                getInstrumentation().getTargetContext());
         SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
                 .setWifiSsid(WifiSsid.fromBytes("This is an SSID!"
                         .getBytes(StandardCharsets.UTF_8))).build();
         final TetheringInterface tetheringInterface =
-                mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+                mCtsTetheringUtils.startWifiTethering(mTetheringEventCallback, softApConfig);
         assertNotNull(tetheringInterface);
         assertEquals(softApConfig, tetheringInterface.getSoftApConfiguration());
-        try {
-            TetheringInterface tetheringInterfaceForApp2 =
-                    mTetheringHelperClient.getTetheredWifiInterface();
-            assertNotNull(tetheringInterfaceForApp2);
-            assertNull(tetheringInterfaceForApp2.getSoftApConfiguration());
-            assertEquals(
-                    tetheringInterface.getInterface(), tetheringInterfaceForApp2.getInterface());
-        } finally {
-            mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
-        }
+        assertEquals(new TetheringInterface(
+                TETHERING_WIFI, tetheringInterface.getInterface(), softApConfig),
+                tetheringInterface);
+        TetheringInterface tetheringInterfaceForApp2 =
+                mTetheringHelperClient.getTetheredWifiInterface();
+        assertNotNull(tetheringInterfaceForApp2);
+        assertNull(tetheringInterfaceForApp2.getSoftApConfiguration());
+        assertEquals(
+                tetheringInterface.getInterface(), tetheringInterfaceForApp2.getInterface());
     }
 }
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 3430196..29e2ebb 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
@@ -28,6 +28,7 @@
 import static android.net.ConnectivityManager.BLOCKED_REASON_NONE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_BACKGROUND;
 import static android.net.ConnectivityManager.TYPE_VPN;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.os.Process.INVALID_UID;
@@ -266,6 +267,11 @@
         mNetwork = null;
         mTestContext = getInstrumentation().getContext();
         mTargetContext = getInstrumentation().getTargetContext();
+        getInstrumentation()
+                .getUiAutomation()
+                .grantRuntimePermission(
+                        "com.android.cts.net.hostside",
+                        "android.permission.NEARBY_WIFI_DEVICES");
         storePrivateDnsSetting();
         mDevice = UiDevice.getInstance(getInstrumentation());
         mActivity = launchActivity(mTargetContext.getPackageName(), MyActivity.class);
@@ -1024,7 +1030,10 @@
         checkTrafficOnVpn();
 
         final Network vpnNetwork = mCM.getActiveNetwork();
-        myUidCallback.expectAvailableThenValidatedCallbacks(vpnNetwork, TIMEOUT_MS);
+        myUidCallback.eventuallyExpect(CallbackEntry.NETWORK_CAPS_UPDATED,
+                NETWORK_CALLBACK_TIMEOUT_MS,
+                entry -> entry.getNetwork().equals(vpnNetwork)
+                        && entry.getCaps().hasCapability(NET_CAPABILITY_VALIDATED));
         assertEquals(vpnNetwork, mCM.getActiveNetwork());
         assertNotEqual(defaultNetwork, vpnNetwork);
         maybeExpectVpnTransportInfo(vpnNetwork);
@@ -1843,11 +1852,11 @@
         final DetailedBlockedStatusCallback remoteUidCallback = new DetailedBlockedStatusCallback();
 
         // Create a TUN interface
-        final FileDescriptor tunFd = runWithShellPermissionIdentity(() -> {
+        final ParcelFileDescriptor tunFd = runWithShellPermissionIdentity(() -> {
             final TestNetworkManager tnm = mTestContext.getSystemService(TestNetworkManager.class);
             final TestNetworkInterface iface = tnm.createTunInterface(List.of(
                     TEST_IP4_DST_ADDR, TEST_IP6_DST_ADDR));
-            return iface.getFileDescriptor().getFileDescriptor();
+            return iface.getFileDescriptor();
         }, MANAGE_TEST_NETWORKS);
 
         // Create a remote UDP socket
@@ -1861,7 +1870,7 @@
             remoteUidCallback.expectAvailableCallbacksWithBlockedReasonNone(network);
 
             // The remote UDP socket can receive packets coming from the TUN interface
-            checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_PASS);
+            checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_PASS);
 
             // Lockdown uid that has the remote UDP socket
             runWithShellPermissionIdentity(() -> {
@@ -1877,7 +1886,7 @@
             if (SdkLevel.isAtLeastT()) {
                 // On T and above, lockdown rule drop packets not coming from lo regardless of the
                 // VPN connectivity.
-                checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_BLOCK);
+                checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_BLOCK);
             }
 
             // Start the VPN that has default routes. This VPN should have interface filtering rule
@@ -1889,9 +1898,9 @@
                     null /* proxyInfo */, null /* underlyingNetworks */,
                     false /* isAlwaysMetered */);
 
-            checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_BLOCK);
+            checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_BLOCK);
         }, /* cleanup */ () -> {
-                Os.close(tunFd);
+                tunFd.close();
             }, /* cleanup */ () -> {
                 Os.close(remoteUdpFd);
             }, /* cleanup */ () -> {
diff --git a/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java b/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
index ae572e6..b5e2450 100644
--- a/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
+++ b/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
@@ -91,8 +91,8 @@
     }
 
     private String[] getSysctlDirs() throws Exception {
-        String interfaceDirs[] = mDevice.executeAdbCommand("shell", "ls", "-1",
-                IPV6_SYSCTL_DIR).split("\n");
+        String[] interfaceDirs = mDevice.executeShellCommand("ls -1 " + IPV6_SYSCTL_DIR)
+                .split("\n");
         List<String> interfaceDirsList = new ArrayList<String>(Arrays.asList(interfaceDirs));
         interfaceDirsList.remove("all");
         interfaceDirsList.remove("lo");
@@ -109,13 +109,13 @@
     }
 
     public int readIntFromPath(String path) throws Exception {
-        String mode = mDevice.executeAdbCommand("shell", "stat", "-c", "%a", path).trim();
-        String user = mDevice.executeAdbCommand("shell", "stat", "-c", "%u", path).trim();
-        String group = mDevice.executeAdbCommand("shell", "stat", "-c", "%g", path).trim();
+        String mode = mDevice.executeShellCommand("stat -c %a " + path).trim();
+        String user = mDevice.executeShellCommand("stat -c %u " + path).trim();
+        String group = mDevice.executeShellCommand("stat -c %g " + path).trim();
         assertEquals(mode, "644");
         assertEquals(user, "0");
         assertEquals(group, "0");
-        return Integer.parseInt(mDevice.executeAdbCommand("shell", "cat", path).trim());
+        return Integer.parseInt(mDevice.executeShellCommand("cat " + path).trim());
     }
 
     /**
@@ -191,7 +191,7 @@
         assumeTrue(new DeviceSdkLevel(mDevice).isDeviceAtLeastV());
 
         String path = "/proc/sys/net/ipv4/tcp_congestion_control";
-        String value = mDevice.executeAdbCommand("shell", "cat", path).trim();
+        String value = mDevice.executeShellCommand("cat " + path).trim();
         assertEquals("cubic", value);
     }
 }
diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp
index 949be85..bf73848 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -21,13 +21,15 @@
     name: "CtsConnectivityMultiDevicesTestCases",
     main: "run_tests.py",
     srcs: [
-        "apfv4_test.py",
+        // "apfv4_test.py",
+        // "apfv6_test.py",
         "connectivity_multi_devices_test.py",
         "run_tests.py",
     ],
     libs: [
         "absl-py",
         "mobly",
+        "scapy",
         "net-tests-utils-host-python-common",
     ],
     test_suites: [
@@ -41,9 +43,4 @@
         // Package the snippet with the mobly test
         ":connectivity_multi_devices_snippet",
     ],
-    version: {
-        py3: {
-            embedded_launcher: true,
-        },
-    },
 }
diff --git a/tests/cts/multidevices/apfv4_test.py b/tests/cts/multidevices/apfv4_test.py
index 7795be5..2f212fc 100644
--- a/tests/cts/multidevices/apfv4_test.py
+++ b/tests/cts/multidevices/apfv4_test.py
@@ -15,10 +15,13 @@
 from absl.testing import parameterized
 from mobly import asserts
 from net_tests_utils.host.python import apf_test_base, apf_utils
+from scapy.layers.l2 import Ether
+import time
 
 # Constants.
 COUNTER_DROPPED_ETHERTYPE_NOT_ALLOWED = "DROPPED_ETHERTYPE_NOT_ALLOWED"
 ETHER_BROADCAST_ADDR = "FFFFFFFFFFFF"
+MIN_PACKET_SIZE = 60
 
 
 class ApfV4Test(apf_test_base.ApfTestBase, parameterized.TestCase):
@@ -45,20 +48,21 @@
   # 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)
+      0x88a2,  # ATA over Ethernet
+      0x88a4,  # EtherCAT
+      0x88b8,  # GOOSE (Generic Object Oriented Substation event)
+      0x88cd,  # SERCOS III
+      0x88e3,  # 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 += blocked_ether_type
+    eth = Ether(src=self.server_mac_address, dst=self.client_mac_address, type=blocked_ether_type)
+    packet = bytes(eth).hex()
 
-    # Pad with zeroes to minimum ethernet frame length.
-    packet += "00" * 46
+    # Add zero padding up to minimum ethernet frame length
+    packet = packet.ljust(MIN_PACKET_SIZE * 2, "0")
+
+    # Pause packet sending between tests to avoid APF disablement due to high throughput.
+    time.sleep(3)
     self.send_packet_and_expect_counter_increased(
         packet, COUNTER_DROPPED_ETHERTYPE_NOT_ALLOWED
     )
diff --git a/tests/cts/multidevices/apfv6_test.py b/tests/cts/multidevices/apfv6_test.py
new file mode 100644
index 0000000..e06af653
--- /dev/null
+++ b/tests/cts/multidevices/apfv6_test.py
@@ -0,0 +1,210 @@
+#  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.
+
+from mobly import asserts
+from scapy.layers.inet import IP, ICMP, IPOption_Router_Alert
+from scapy.layers.inet6 import (
+    IPv6,
+    IPv6ExtHdrHopByHop,
+    ICMPv6EchoRequest,
+    ICMPv6EchoReply,
+    ICMPv6MLQuery2,
+    ICMPv6MLReport2,
+    ICMPv6MLDMultAddrRec,
+    ICMPv6NDOptSrcLLAddr,
+    ICMPv6NDOptDstLLAddr,
+    ICMPv6ND_NS,
+    ICMPv6ND_NA,
+    RouterAlert
+)
+from scapy.layers.l2 import ARP, Ether
+from scapy.contrib.igmpv3 import IGMPv3, IGMPv3mq, IGMPv3mr, IGMPv3gr
+from net_tests_utils.host.python import apf_test_base, apf_utils, adb_utils, assert_utils
+
+APFV6_VERSION = 6000
+ARP_OFFLOAD_REPLY_LEN = 60
+
+class ApfV6Test(apf_test_base.ApfTestBase):
+    def setup_class(self):
+        super().setup_class()
+
+        # Skip tests for APF version < 6000
+        apf_utils.assume_apf_version_support_at_least(
+            self.clientDevice, self.client_iface_name, APFV6_VERSION
+        )
+
+    def teardown_class(self):
+        # force to stop capture on the server device if any test case failed
+        try:
+            apf_utils.stop_capture_packets(self.serverDevice, self.server_iface_name)
+        except assert_utils.UnexpectedBehaviorError:
+            pass
+        super().teardown_class()
+
+    def test_unicast_arp_request_offload(self):
+        eth = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        arp = ARP(
+            op=1,
+            psrc=self.server_ipv4_addresses[0],
+            pdst=self.client_ipv4_addresses[0],
+            hwsrc=self.server_mac_address
+        )
+        arp_request = bytes(eth/arp).hex()
+
+        eth = Ether(src=self.client_mac_address, dst=self.server_mac_address)
+        arp = ARP(
+            op=2,
+            psrc=self.client_ipv4_addresses[0],
+            pdst=self.server_ipv4_addresses[0],
+            hwsrc=self.client_mac_address,
+            hwdst=self.server_mac_address
+        )
+        expected_arp_reply = bytes(eth/arp).hex()
+
+        # Add zero padding up to 60 bytes, since APFv6 ARP offload always sent out 60 bytes reply
+        expected_arp_reply = expected_arp_reply.ljust(ARP_OFFLOAD_REPLY_LEN * 2, "0")
+
+        self.send_packet_and_expect_reply_received(
+            arp_request, "DROPPED_ARP_REQUEST_REPLIED", expected_arp_reply
+        )
+
+    def test_non_dad_ipv6_neighbor_solicitation_offload(self):
+        eth = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IPv6(src=self.server_ipv6_addresses[0], dst=self.client_ipv6_addresses[0])
+        icmpv6 = ICMPv6ND_NS(tgt=self.client_ipv6_addresses[0])
+        opt = ICMPv6NDOptSrcLLAddr(lladdr=self.server_mac_address)
+        neighbor_solicitation = bytes(eth/ip/icmpv6/opt).hex()
+
+        eth = Ether(src=self.client_mac_address, dst=self.server_mac_address)
+        ip = IPv6(src=self.client_ipv6_addresses[0], dst=self.server_ipv6_addresses[0])
+        icmpv6 = ICMPv6ND_NA(tgt=self.client_ipv6_addresses[0], R=1, S=1, O=1)
+        opt = ICMPv6NDOptDstLLAddr(lladdr=self.client_mac_address)
+        expected_neighbor_advertisement = bytes(eth/ip/icmpv6/opt).hex()
+        self.send_packet_and_expect_reply_received(
+            neighbor_solicitation,
+            "DROPPED_IPV6_NS_REPLIED_NON_DAD",
+            expected_neighbor_advertisement
+        )
+
+    @apf_utils.at_least_B()
+    def test_ipv4_icmp_echo_request_offload(self):
+        eth = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IP(src=self.server_ipv4_addresses[0], dst=self.client_ipv4_addresses[0])
+        icmp = ICMP(id=1, seq=123)
+        echo_request = bytes(eth/ip/icmp/b"hello").hex()
+
+        eth = Ether(src=self.client_mac_address, dst=self.server_mac_address)
+        ip = IP(src=self.client_ipv4_addresses[0], dst=self.server_ipv4_addresses[0])
+        icmp = ICMP(type=0, id=1, seq=123)
+        expected_echo_reply = bytes(eth/ip/icmp/b"hello").hex()
+        self.send_packet_and_expect_reply_received(
+            echo_request, "DROPPED_IPV4_PING_REQUEST_REPLIED", expected_echo_reply
+        )
+
+    @apf_utils.at_least_B()
+    @apf_utils.apf_ram_at_least(3000)
+    def test_ipv6_icmp_echo_request_offload(self):
+        eth = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IPv6(src=self.server_ipv6_addresses[0], dst=self.client_ipv6_addresses[0])
+        icmp = ICMPv6EchoRequest(id=1, seq=123)
+        echo_request = bytes(eth/ip/icmp/b"hello").hex()
+
+        eth = Ether(src=self.client_mac_address, dst=self.server_mac_address)
+        ip = IPv6(src=self.client_ipv6_addresses[0], dst=self.server_ipv6_addresses[0])
+        icmp = ICMPv6EchoReply(id=1, seq=123)
+        expected_echo_reply = bytes(eth/ip/icmp/b"hello").hex()
+
+        self.send_packet_and_expect_reply_received(
+            echo_request, "DROPPED_IPV6_ICMP6_ECHO_REQUEST_REPLIED", expected_echo_reply
+        )
+
+    def is_running_as_root(self):
+        uid = adb_utils.adb_shell(self.clientDevice, "id -u")
+        return uid == "0"
+
+    @apf_utils.at_least_B()
+    def test_igmpv3_general_query_offload(self):
+        if not self.is_running_as_root():
+            return
+        # use unicast to replace multicast ether dst to prevent flaky due to DTIM skip
+        ether = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IP(
+            src=self.server_ipv4_addresses[0],
+            dst='224.0.0.1',
+            options=[IPOption_Router_Alert()]
+        )
+        igmp = IGMPv3(type=0x11)/IGMPv3mq()
+        igmpv3_general_query = bytes(ether/ip/igmp).hex()
+
+        mcast_addrs = ['239.0.0.1', '239.0.0.2', '239.0.0.3']
+
+        for addr in mcast_addrs:
+            adb_utils.adb_shell(
+                self.clientDevice,
+                f'ip addr add {addr}/32 dev {self.client_iface_name} autojoin'
+            )
+
+        ether = Ether(src=self.client_mac_address, dst='01:00:5e:00:00:16')
+        ip = IP(
+            src=self.client_ipv4_addresses[0],
+            dst='224.0.0.22',
+            options=[IPOption_Router_Alert()],
+            id=0,
+            flags="DF"
+        )
+        igmpv3_hdr = IGMPv3(type=0x22)
+        mcast_records = []
+        for addr in mcast_addrs:
+            mcast_records.append(IGMPv3gr(rtype=2, maddr=addr))
+
+        igmp = IGMPv3mr(records=mcast_records)
+        expected_igmpv3_report = bytes(ether/ip/igmpv3_hdr/igmp).hex()
+        try:
+            self.send_packet_and_expect_reply_received(
+                igmpv3_general_query,
+                "DROPPED_IGMP_V3_GENERAL_QUERY_REPLIED",
+                expected_igmpv3_report
+            )
+        finally:
+            for addr in mcast_addrs:
+                adb_utils.adb_shell(
+                    self.clientDevice,
+                    f'ip addr del {addr}/32 dev {self.client_iface_name}'
+                )
+
+    @apf_utils.at_least_B()
+    @apf_utils.apf_ram_at_least(3000)
+    def test_mldv2_general_query_offload(self):
+        # use unicast to replace multicast ether dst to prevent flaky due to DTIM skip
+        ether = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IPv6(src=self.server_ipv6_addresses[0], dst='ff02::1', hlim=1)
+        hopOpts = IPv6ExtHdrHopByHop(options=[RouterAlert(otype=5)])
+        mld = ICMPv6MLQuery2()
+        mldv2_general_query = bytes(ether/ip/hopOpts/mld).hex()
+
+        ether = Ether(src=self.client_mac_address, dst='33:33:00:00:00:16')
+        ip = IPv6(src=self.client_ipv6_addresses[0], dst='ff02::16', hlim=1)
+
+        mcast_addrs = apf_utils.get_exclude_all_host_ipv6_multicast_addresses(
+            self.clientDevice, self.client_iface_name
+        )
+
+        mld_records = []
+        for addr in mcast_addrs:
+            mld_records.append(ICMPv6MLDMultAddrRec(dst=addr, rtype=2))
+        mld = ICMPv6MLReport2(records=mld_records)
+        expected_mldv2_report = bytes(ether/ip/hopOpts/mld).hex()
+        self.send_packet_and_expect_reply_received(
+            mldv2_general_query, "DROPPED_IPV6_MLD_V2_GENERAL_QUERY_REPLIED", expected_mldv2_report
+        )
diff --git a/tests/cts/multidevices/run_tests.py b/tests/cts/multidevices/run_tests.py
index 1391d13..1ac43b9 100644
--- a/tests/cts/multidevices/run_tests.py
+++ b/tests/cts/multidevices/run_tests.py
@@ -15,7 +15,6 @@
 """Main entrypoint for all of test cases."""
 
 import sys
-from apfv4_test import ApfV4Test
 from connectivity_multi_devices_test import ConnectivityMultiDevicesTest
 from mobly import suite_runner
 
@@ -35,4 +34,4 @@
     index = sys.argv.index("--")
     sys.argv = sys.argv[:1] + sys.argv[index + 1 :]
   # TODO: make the tests can be executed without manually list classes.
-  suite_runner.run_suite([ConnectivityMultiDevicesTest, ApfV4Test], sys.argv)
+  suite_runner.run_suite([ConnectivityMultiDevicesTest], sys.argv)
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 6da7e9a..e1c6bf1 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -27,14 +27,14 @@
 import android.net.NetworkRequest
 import android.net.cts.util.CtsNetUtils
 import android.net.cts.util.CtsTetheringUtils
-import android.net.wifi.ScanResult
 import android.net.wifi.SoftApConfiguration
 import android.net.wifi.SoftApConfiguration.SECURITY_TYPE_WPA2_PSK
 import android.net.wifi.WifiConfiguration
 import android.net.wifi.WifiInfo
 import android.net.wifi.WifiManager
-import android.net.wifi.WifiNetworkSpecifier
 import android.net.wifi.WifiSsid
+import android.os.Build.VERSION.CODENAME
+import android.os.Build.VERSION.SDK_INT
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.compatibility.common.util.PropertyUtil
 import com.android.modules.utils.build.SdkLevel
@@ -64,6 +64,17 @@
         cbHelper.unregisterAll()
     }
 
+    private fun isAtLeastPreReleaseCodename(codeName: String): Boolean {
+        // Special case "REL", which means the build is not a pre-release build.
+        if ("REL".equals(CODENAME)) {
+            return false
+        }
+
+        // Otherwise lexically compare them. Return true if the build codename is equal to or
+        // greater than the requested codename.
+        return CODENAME.compareTo(codeName) >= 0
+    }
+
     @Rpc(description = "Check whether the device has wifi feature.")
     fun hasWifiFeature() = pm.hasSystemFeature(FEATURE_WIFI)
 
@@ -79,6 +90,11 @@
     @Rpc(description = "Return whether the Sdk level is at least V.")
     fun isAtLeastV() = SdkLevel.isAtLeastV()
 
+    @Rpc(description = "Check whether the device is at least B.")
+    fun isAtLeastB(): Boolean {
+        return SDK_INT >= 36 || (SDK_INT == 35 && isAtLeastPreReleaseCodename("Baklava"))
+    }
+
     @Rpc(description = "Return the API level that the VSR requirement must be fulfilled.")
     fun getVsrApiLevel() = PropertyUtil.getVsrApiLevel()
 
@@ -109,10 +125,7 @@
     // Suppress warning because WifiManager methods to connect to a config are
     // documented not to be deprecated for privileged users.
     @Suppress("DEPRECATION")
-    fun connectToWifi(ssid: String, passphrase: String): Long {
-        val specifier = WifiNetworkSpecifier.Builder()
-            .setBand(ScanResult.WIFI_BAND_24_GHZ)
-            .build()
+    fun connectToWifi(ssid: String, passphrase: String, requireValidation: Boolean): Long {
         val wifiConfig = WifiConfiguration()
         wifiConfig.SSID = "\"" + ssid + "\""
         wifiConfig.preSharedKey = "\"" + passphrase + "\""
@@ -141,7 +154,8 @@
             return@runAsShell networkCallback.eventuallyExpect<CapabilitiesChanged> {
                 // Remove double quotes.
                 val ssidFromCaps = (WifiInfo::sanitizeSsid)(it.caps.ssid)
-                ssidFromCaps == ssid && it.caps.hasCapability(NET_CAPABILITY_VALIDATED)
+                ssidFromCaps == ssid && (!requireValidation ||
+                        it.caps.hasCapability(NET_CAPABILITY_VALIDATED))
             }.network.networkHandle
         }
     }
diff --git a/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
index f8c9351..3816537 100644
--- a/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
@@ -21,8 +21,8 @@
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.pm.PackageManager.FEATURE_WIFI_DIRECT
 import android.net.MacAddress
-import android.net.wifi.WifiManager
 import android.net.wifi.p2p.WifiP2pConfig
 import android.net.wifi.p2p.WifiP2pDevice
 import android.net.wifi.p2p.WifiP2pDeviceList
@@ -44,10 +44,6 @@
 
 class Wifip2pMultiDevicesSnippet : Snippet {
     private val context by lazy { InstrumentationRegistry.getInstrumentation().getTargetContext() }
-    private val wifiManager by lazy {
-        context.getSystemService(WifiManager::class.java)
-                ?: fail("Could not get WifiManager service")
-    }
     private val wifip2pManager by lazy {
         context.getSystemService(WifiP2pManager::class.java)
                 ?: fail("Could not get WifiP2pManager service")
@@ -84,7 +80,7 @@
     }
 
     @Rpc(description = "Check whether the device supports Wi-Fi P2P.")
-    fun isP2pSupported() = wifiManager.isP2pSupported()
+    fun isP2pSupported() = context.packageManager.hasSystemFeature(FEATURE_WIFI_DIRECT)
 
     @Rpc(description = "Start Wi-Fi P2P")
     fun startWifiP2p() {
diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp
index a9ac29c..1ba581a 100644
--- a/tests/cts/net/Android.bp
+++ b/tests/cts/net/Android.bp
@@ -95,6 +95,7 @@
         "NetworkStackApiCurrentShims",
     ],
     test_suites: [
+        "automotive-general-tests",
         "cts",
         "mts-tethering",
         "mcts-tethering",
@@ -160,6 +161,7 @@
     min_sdk_version: "30",
     // Tag this module as a cts test artifact
     test_suites: [
+        "automotive-general-tests",
         "cts",
         "general-tests",
     ],
diff --git a/tests/cts/net/AndroidManifest.xml b/tests/cts/net/AndroidManifest.xml
index 098cc0a..acf89be 100644
--- a/tests/cts/net/AndroidManifest.xml
+++ b/tests/cts/net/AndroidManifest.xml
@@ -26,6 +26,7 @@
     <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
     <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
+    <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
     <uses-permission android:name="android.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS" />
     <uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
     <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" />
diff --git a/tests/cts/net/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index 55b6494..cb0e575 100644
--- a/tests/cts/net/AndroidTestTemplate.xml
+++ b/tests/cts/net/AndroidTestTemplate.xml
@@ -42,6 +42,7 @@
     </target_preparer>
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="package" value="{PACKAGE}" />
+        <option name="shell-timeout" value="1500s"/>
         <option name="runtime-hint" value="9m4s" />
         <option name="hidden-api-checks" value="false" />
         <option name="isolated-storage" value="false" />
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index 320622b..7dbb9b2 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -19,10 +19,8 @@
 
 package android.net.cts
 
-import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
-import android.Manifest.permission.WRITE_DEVICE_CONFIG
-import android.content.pm.PackageManager
 import android.content.pm.PackageManager.FEATURE_AUTOMOTIVE
+import android.content.pm.PackageManager.FEATURE_LEANBACK
 import android.content.pm.PackageManager.FEATURE_WIFI
 import android.net.ConnectivityManager
 import android.net.Network
@@ -38,7 +36,8 @@
 import android.net.apf.ApfConstants.IPV6_NEXT_HEADER_OFFSET
 import android.net.apf.ApfConstants.IPV6_SRC_ADDR_OFFSET
 import android.net.apf.ApfCounterTracker
-import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_MULTICAST_PING
+import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_NS_INVALID
+import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_NS_REPLIED_NON_DAD
 import android.net.apf.ApfCounterTracker.Counter.FILTER_AGE_16384THS
 import android.net.apf.ApfCounterTracker.Counter.PASSED_IPV6_ICMP
 import android.net.apf.ApfV4Generator
@@ -52,14 +51,15 @@
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.PowerManager
+import android.os.SystemProperties
 import android.os.UserManager
 import android.platform.test.annotations.AppModeFull
-import android.provider.DeviceConfig
-import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
 import android.system.Os
 import android.system.OsConstants
 import android.system.OsConstants.AF_INET6
 import android.system.OsConstants.ETH_P_IPV6
+import android.system.OsConstants.ICMP6_ECHO_REPLY
+import android.system.OsConstants.ICMP6_ECHO_REQUEST
 import android.system.OsConstants.IPPROTO_ICMPV6
 import android.system.OsConstants.SOCK_DGRAM
 import android.system.OsConstants.SOCK_NONBLOCK
@@ -87,7 +87,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.SkipPresubmit
 import com.android.testutils.TestableNetworkCallback
-import com.android.testutils.runAsShell
+import com.android.testutils.pollingCheck
 import com.android.testutils.waitForIdle
 import com.google.common.truth.Expect
 import com.google.common.truth.Truth.assertThat
@@ -100,10 +100,12 @@
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.TimeoutException
 import kotlin.random.Random
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertNotNull
 import org.junit.After
 import org.junit.AfterClass
+import org.junit.Assume.assumeFalse
 import org.junit.Before
 import org.junit.BeforeClass
 import org.junit.Rule
@@ -112,8 +114,6 @@
 
 private const val TAG = "ApfIntegrationTest"
 private const val TIMEOUT_MS = 2000L
-private const val APF_NEW_RA_FILTER_VERSION = "apf_new_ra_filter_version"
-private const val POLLING_INTERVAL_MS: Int = 100
 private const val RCV_BUFFER_SIZE = 1480
 private const val PING_HEADER_LENGTH = 8
 
@@ -131,16 +131,6 @@
         private val powerManager = context.getSystemService(PowerManager::class.java)!!
         private val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG)
 
-        fun pollingCheck(condition: () -> Boolean, timeout_ms: Int): Boolean {
-            var polling_time = 0
-            do {
-                Thread.sleep(POLLING_INTERVAL_MS.toLong())
-                polling_time += POLLING_INTERVAL_MS
-                if (condition()) return true
-            } while (polling_time < timeout_ms)
-            return false
-        }
-
         fun turnScreenOff() {
             if (!wakeLock.isHeld()) wakeLock.acquire()
             runShellCommandOrThrow("input keyevent KEYCODE_SLEEP")
@@ -162,7 +152,9 @@
                 // This is a workaround for b/366037029.
                 Thread.sleep(2000L)
             } else {
-                val result = pollingCheck({ powerManager.isInteractive() }, timeout_ms = 2000)
+                val result = pollingCheck(timeout_ms = 2000) {
+                    powerManager.isInteractive()
+                }
                 assertThat(result).isEqualTo(interactive)
             }
         }
@@ -170,8 +162,8 @@
         private fun isAutomotiveWithVisibleBackgroundUser(): Boolean {
             val packageManager = context.getPackageManager()
             val userManager = context.getSystemService(UserManager::class.java)!!
-            return (packageManager.hasSystemFeature(FEATURE_AUTOMOTIVE)
-                    && userManager.isVisibleBackgroundUsersSupported)
+            return (packageManager.hasSystemFeature(FEATURE_AUTOMOTIVE) &&
+                    userManager.isVisibleBackgroundUsersSupported)
         }
 
         @BeforeClass
@@ -188,16 +180,6 @@
             Thread.sleep(1000)
             // TODO: check that there is no active wifi network. Otherwise, ApfFilter has already been
             // created.
-            // APF adb cmds are only implemented in ApfFilter.java. Enable experiment to prevent
-            // LegacyApfFilter.java from being used.
-            runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
-                DeviceConfig.setProperty(
-                        NAMESPACE_CONNECTIVITY,
-                        APF_NEW_RA_FILTER_VERSION,
-                        "1",  // value => force enabled
-                        false // makeDefault
-                )
-            }
         }
 
         @AfterClass
@@ -211,8 +193,13 @@
             handler: Handler,
             private val network: Network
     ) : PacketReader(handler, RCV_BUFFER_SIZE) {
+        private data class PingContext(
+            val futureReply: CompletableFuture<List<ByteArray>>,
+            val expectReplyCount: Int,
+            val replyPayloads: MutableList<ByteArray> = mutableListOf()
+        )
         private var sockFd: FileDescriptor? = null
-        private var futureReply: CompletableFuture<ByteArray>? = null
+        private var pingContext: PingContext? = null
 
         override fun createFd(): FileDescriptor {
             // sockFd is closed by calling super.stop()
@@ -224,6 +211,8 @@
         }
 
         override fun handlePacket(recvbuf: ByteArray, length: Int) {
+            val context = pingContext ?: return
+
             // If zero-length or Type is not echo reply: ignore.
             if (length == 0 || recvbuf[0] != 0x81.toByte()) {
                 return
@@ -231,10 +220,14 @@
             // Only copy the ping data and complete the future.
             val result = recvbuf.sliceArray(8..<length)
             Log.i(TAG, "Received ping reply: ${result.toHexString()}")
-            futureReply!!.complete(recvbuf.sliceArray(8..<length))
+            context.replyPayloads.add(recvbuf.sliceArray(8..<length))
+            if (context.replyPayloads.size == context.expectReplyCount) {
+                context.futureReply.complete(context.replyPayloads)
+                pingContext = null
+            }
         }
 
-        fun sendPing(data: ByteArray, payloadSize: Int) {
+        fun sendPing(data: ByteArray, payloadSize: Int, expectReplyCount: Int = 1) {
             require(data.size == payloadSize)
 
             // rfc4443#section-4.1: Echo Request Message
@@ -250,17 +243,20 @@
             val icmp6Header = byteArrayOf(0x80.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
             val packet = icmp6Header + data
             Log.i(TAG, "Sent ping: ${packet.toHexString()}")
-            futureReply = CompletableFuture<ByteArray>()
+            pingContext = PingContext(
+                futureReply = CompletableFuture<List<ByteArray>>(),
+                expectReplyCount = expectReplyCount
+            )
             Os.sendto(sockFd!!, packet, 0, packet.size, 0, PING_DESTINATION)
         }
 
-        fun expectPingReply(timeoutMs: Long = TIMEOUT_MS): ByteArray {
-            return futureReply!!.get(timeoutMs, TimeUnit.MILLISECONDS)
+        fun expectPingReply(timeoutMs: Long = TIMEOUT_MS): List<ByteArray> {
+            return pingContext!!.futureReply.get(timeoutMs, TimeUnit.MILLISECONDS)
         }
 
         fun expectPingDropped() {
             assertFailsWith(TimeoutException::class) {
-                futureReply!!.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
+                pingContext!!.futureReply.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
             }
         }
 
@@ -299,10 +295,23 @@
         return ApfCapabilities(version, maxLen, packetFormat)
     }
 
+    private fun isTvDeviceSupportFullNetworkingUnder2w(): Boolean {
+        return (pm.hasSystemFeature(FEATURE_LEANBACK) &&
+            pm.hasSystemFeature("com.google.android.tv.full_networking_under_2w"))
+    }
+
     @Before
     fun setUp() {
         assume().that(pm.hasSystemFeature(FEATURE_WIFI)).isTrue()
 
+        // Based on GTVS-16, Android Packet Filtering (APF) is OPTIONAL for devices that fully
+        // process all network packets on CPU at all times, even in standby, while meeting
+        // the <= 2W standby power demand requirement.
+        assumeFalse(
+            "Skipping test: TV device process full networking on CPU under 2W",
+            isTvDeviceSupportFullNetworkingUnder2w()
+        )
+
         networkCallback = TestableNetworkCallback()
         cm.requestNetwork(
                 NetworkRequest.Builder()
@@ -349,10 +358,8 @@
     @Test
     fun testApfCapabilities() {
         // APF became mandatory in Android 14 VSR.
-        assume().that(getVsrApiLevel()).isAtLeast(34)
-
-        // ApfFilter does not support anything but ARPHRD_ETHER.
-        assertThat(caps.apfPacketFormat).isEqualTo(OsConstants.ARPHRD_ETHER)
+        val vsrApiLevel = getVsrApiLevel()
+        assume().that(vsrApiLevel).isAtLeast(34)
 
         // DEVICEs launching with Android 14 with CHIPSETs that set ro.board.first_api_level to 34:
         // - [GMS-VSR-5.3.12-003] MUST return 4 or higher as the APF version number from calls to
@@ -365,16 +372,37 @@
 
         if (caps.apfVersionSupported > 4) {
             assertThat(caps.maximumApfProgramSize).isAtLeast(2048)
-            assertThat(caps.apfVersionSupported).isEqualTo(6000) // v6.0000
+            assertThat(caps.apfVersionSupported).isAnyOf(6000, 6100) // v6.000 or v6.100
         }
 
         // DEVICEs launching with Android 15 (AOSP experimental) or higher with CHIPSETs that set
         // ro.board.first_api_level or ro.board.api_level to 202404 or higher:
         // - [GMS-VSR-5.3.12-009] MUST indicate at least 2048 bytes of usable memory from calls to
         //   the getApfPacketFilterCapabilities HAL method.
-        if (getVsrApiLevel() >= 202404) {
+        if (vsrApiLevel >= 202404) {
             assertThat(caps.maximumApfProgramSize).isAtLeast(2048)
         }
+
+        // DEVICEs with CHIPSETs that set ro.board.first_api_level or ro.board.api_level to 202504
+        // or higher:
+        // - [VSR-5.3.12-018] MUST implement version 6 or version 6.1 of the Android Packet
+        //   Filtering (APF) interpreter in the Wi-Fi firmware.
+        // - [VSR-5.3.12-019] MUST provide at least 4000 bytes of APF RAM when version 6 is
+        //   implemented OR 3000 bytes when version 6.1 is implemented.
+        // - Note, the APF RAM requirement for APF version 6.1 will become 4000 bytes in Android 17
+        //   with CHIPSETs that set ro.board.first_api_level or ro.board.api_level to 202604 or
+        //   higher.
+        if (vsrApiLevel >= 202504) {
+            assertThat(caps.apfVersionSupported).isAnyOf(6000, 6100)
+            if (caps.apfVersionSupported == 6000) {
+                assertThat(caps.maximumApfProgramSize).isAtLeast(4000)
+            } else {
+                assertThat(caps.maximumApfProgramSize).isAtLeast(3000)
+            }
+        }
+
+        // ApfFilter does not support anything but ARPHRD_ETHER.
+        assertThat(caps.apfPacketFormat).isEqualTo(OsConstants.ARPHRD_ETHER)
     }
 
     // APF is backwards compatible, i.e. a v6 interpreter supports both v2 and v4 functionality.
@@ -382,6 +410,10 @@
         assume().that(caps.apfVersionSupported).isAtLeast(version)
     }
 
+    fun assumeNotCuttlefish() {
+        assume().that(SystemProperties.get("ro.product.board", "")).isNotEqualTo("cutf")
+    }
+
     fun installProgram(bytes: ByteArray) {
         val prog = bytes.toHexString()
         val result = runShellCommandOrThrow("cmd network_stack apf $ifname install $prog").trim()
@@ -441,18 +473,18 @@
         assertThat(readResult).isEqualTo(program)
     }
 
-    fun ApfV4GeneratorBase<*>.addPassIfNotIcmpv6EchoReply() {
+    fun ApfV4GeneratorBase<*>.addPassIfNotIcmpv6EchoReply(skipPacketLabel: Short) {
         // If not IPv6 -> PASS
-        addLoad16(R0, ETH_ETHERTYPE_OFFSET)
-        addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), BaseApfGenerator.PASS_LABEL)
+        addLoad16intoR0(ETH_ETHERTYPE_OFFSET)
+        addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), skipPacketLabel)
 
         // If not ICMPv6 -> PASS
-        addLoad8(R0, IPV6_NEXT_HEADER_OFFSET)
-        addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), BaseApfGenerator.PASS_LABEL)
+        addLoad8intoR0(IPV6_NEXT_HEADER_OFFSET)
+        addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), skipPacketLabel)
 
         // If not echo reply -> PASS
-        addLoad8(R0, ICMP6_TYPE_OFFSET)
-        addJumpIfR0NotEquals(0x81, BaseApfGenerator.PASS_LABEL)
+        addLoad8intoR0(ICMP6_TYPE_OFFSET)
+        addJumpIfR0NotEquals(0x81, skipPacketLabel)
     }
 
     // APF integration is mostly broken before V
@@ -465,6 +497,7 @@
         // should be turned on.
         assume().that(getVsrApiLevel()).isAtLeast(34)
         assumeApfVersionSupportAtLeast(4)
+        assumeNotCuttlefish()
 
         // clear any active APF filter
         clearApfMemory()
@@ -478,7 +511,7 @@
         }
         val data = ByteArray(payloadSize).also { Random.nextBytes(it) }
         packetReader.sendPing(data, payloadSize)
-        assertThat(packetReader.expectPingReply()).isEqualTo(data)
+        assertThat(packetReader.expectPingReply()[0]).isEqualTo(data)
 
         // Generate an APF program that drops the next ping
         val gen = ApfV4Generator(
@@ -487,21 +520,35 @@
                 caps.maximumApfProgramSize
         )
 
+        val skipPacketLabel = gen.uniqueLabel
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(skipPacketLabel)
 
         // if not data matches -> PASS
         gen.addLoadImmediate(R0, ICMP6_TYPE_OFFSET + PING_HEADER_LENGTH)
-        gen.addJumpIfBytesAtR0NotEqual(data, BaseApfGenerator.PASS_LABEL)
+        gen.addJumpIfBytesAtR0NotEqual(data, skipPacketLabel)
 
         // else DROP
-        gen.addJump(BaseApfGenerator.DROP_LABEL)
+        // Warning: the program abuse DROPPED_IPV6_NS_INVALID/PASSED_IPV6_ICMP for debugging purpose
+        gen.addCountAndDrop(DROPPED_IPV6_NS_INVALID)
+            .defineLabel(skipPacketLabel)
+            .addCountAndPass(PASSED_IPV6_ICMP)
+            .addCountTrampoline()
 
         val program = gen.generate()
         installAndVerifyProgram(program)
 
+        val counterBefore = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_INVALID
+        )
         packetReader.sendPing(data, payloadSize)
         packetReader.expectPingDropped()
+        val counterAfter = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_INVALID
+        )
+        assertEquals(counterBefore + 1, counterAfter)
     }
 
     fun clearApfMemory() = installProgram(ByteArray(caps.maximumApfProgramSize))
@@ -517,6 +564,7 @@
         assume().that(getVsrApiLevel()).isAtLeast(34)
         // Test v4 memory slots on both v4 and v6 interpreters.
         assumeApfVersionSupportAtLeast(4)
+        assumeNotCuttlefish()
         clearApfMemory()
         val gen = ApfV4Generator(
                 caps.apfVersionSupported,
@@ -525,7 +573,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         val counterRegion = 500
@@ -543,6 +591,13 @@
 
         val program = gen.generate()
         assertThat(program.size).isLessThan(counterRegion)
+        val randomProgram = ByteArray(1) { 0 } +
+                ByteArray(counterRegion - 1).also { Random.nextBytes(it) }
+        // There are known firmware bugs where they calculate the number of non-zero bytes within
+        // the program to determine the program length. Modify the test to first install a longer
+        // program before installing a program that do the program length check. This should help us
+        // catch these types of firmware bugs in CTS. (b/395545572)
+        installAndVerifyProgram(randomProgram)
         installAndVerifyProgram(program)
 
         // Trigger the program by sending a ping and waiting on the reply.
@@ -575,6 +630,7 @@
         // should be turned on.
         assume().that(getVsrApiLevel()).isAtLeast(34)
         assumeApfVersionSupportAtLeast(4)
+        assumeNotCuttlefish()
         clearApfMemory()
         val gen = ApfV4Generator(
                 caps.apfVersionSupported,
@@ -583,7 +639,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         val counterRegion = 500
@@ -617,6 +673,7 @@
     @Test
     fun testFilterAge16384thsIncreasesBetweenPackets() {
         assumeApfVersionSupportAtLeast(6000)
+        assumeNotCuttlefish()
         clearApfMemory()
         val gen = ApfV6Generator(
                 caps.apfVersionSupported,
@@ -625,7 +682,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         gen.addLoadFromMemory(R0, MemorySlot.FILTER_AGE_16384THS)
@@ -666,6 +723,7 @@
     @Test
     fun testReplyPing() {
         assumeApfVersionSupportAtLeast(6000)
+        assumeNotCuttlefish()
         installProgram(ByteArray(caps.maximumApfProgramSize) { 0 }) // Clear previous program
         readProgram() // Ensure installation is complete
 
@@ -690,69 +748,91 @@
         //     increase PASSED_IPV6_ICMP counter
         //     pass
         //   else
-        //     transmit a ICMPv6 echo request packet with the first byte of the payload in the reply
-        //     increase DROPPED_IPV6_MULTICAST_PING counter
+        //     transmit 3 ICMPv6 echo requests with random first byte
+        //     increase DROPPED_IPV6_NS_REPLIED_NON_DAD counter
         //     drop
-        val program = gen
-                .addLoad16(R0, ETH_ETHERTYPE_OFFSET)
+        gen.addLoad16intoR0(ETH_ETHERTYPE_OFFSET)
                 .addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), skipPacketLabel)
-                .addLoad8(R0, IPV6_NEXT_HEADER_OFFSET)
+                .addLoad8intoR0(IPV6_NEXT_HEADER_OFFSET)
                 .addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), skipPacketLabel)
-                .addLoad8(R0, ICMP6_TYPE_OFFSET)
-                .addJumpIfR0NotEquals(0x81, skipPacketLabel) // Echo reply type
+                .addLoad8intoR0(ICMP6_TYPE_OFFSET)
+                .addJumpIfR0NotEquals(ICMP6_ECHO_REPLY.toLong(), skipPacketLabel)
                 .addLoadFromMemory(R0, MemorySlot.PACKET_SIZE)
                 .addCountAndPassIfR0Equals(
-                        (ETHER_HEADER_LEN + IPV6_HEADER_LEN + PING_HEADER_LENGTH + firstByte.size)
-                                .toLong(),
-                        PASSED_IPV6_ICMP
+                    (ETHER_HEADER_LEN + IPV6_HEADER_LEN + PING_HEADER_LENGTH + firstByte.size)
+                        .toLong(),
+                    PASSED_IPV6_ICMP
                 )
-                // Ping Packet Generation
-                .addAllocate(pingRequestPktLen)
-                // Eth header
-                .addPacketCopy(ETHER_SRC_ADDR_OFFSET, ETHER_ADDR_LEN) // dst MAC address
-                .addPacketCopy(ETHER_DST_ADDR_OFFSET, ETHER_ADDR_LEN) // src MAC address
-                .addWriteU16(ETH_P_IPV6) // IPv6 type
-                // IPv6 Header
-                .addWrite32(0x60000000) // IPv6 Header: version, traffic class, flowlabel
-                // payload length (2 bytes) | next header: ICMPv6 (1 byte) | hop limit (1 byte)
-                .addWrite32(pingRequestIpv6PayloadLen shl 16 or (IPPROTO_ICMPV6 shl 8 or 64))
-                .addPacketCopy(IPV6_DEST_ADDR_OFFSET, IPV6_ADDR_LEN) // src ip
-                .addPacketCopy(IPV6_SRC_ADDR_OFFSET, IPV6_ADDR_LEN) // dst ip
-                // ICMPv6
-                .addWriteU8(0x80) // type: echo request
-                .addWriteU8(0) // code
-                .addWriteU16(pingRequestIpv6PayloadLen) // checksum
-                // identifier
-                .addPacketCopy(ETHER_HEADER_LEN + IPV6_HEADER_LEN + ICMPV6_HEADER_MIN_LEN, 2)
-                .addWriteU16(0) // sequence number
-                .addDataCopy(firstByte) // data
-                .addTransmitL4(
+
+        val numOfPacketToTransmit = 3
+        val expectReplyPayloads = (0 until numOfPacketToTransmit).map { Random.nextBytes(1) }
+        expectReplyPayloads.forEach { replyPingPayload ->
+            // Ping Packet Generation
+            gen.addAllocate(pingRequestPktLen)
+                    // Eth header
+                    .addPacketCopy(ETHER_SRC_ADDR_OFFSET, ETHER_ADDR_LEN) // dst MAC address
+                    .addPacketCopy(ETHER_DST_ADDR_OFFSET, ETHER_ADDR_LEN) // src MAC address
+                    .addWriteU16(ETH_P_IPV6) // IPv6 type
+                    // IPv6 Header
+                    .addWrite32(0x60000000) // IPv6 Header: version, traffic class, flowlabel
+                    // payload length (2 bytes) | next header: ICMPv6 (1 byte) | hop limit (1 byte)
+                    .addWrite32(pingRequestIpv6PayloadLen shl 16 or (IPPROTO_ICMPV6 shl 8 or 64))
+                    .addPacketCopy(IPV6_DEST_ADDR_OFFSET, IPV6_ADDR_LEN) // src ip
+                    .addPacketCopy(IPV6_SRC_ADDR_OFFSET, IPV6_ADDR_LEN) // dst ip
+                    // ICMPv6
+                    .addWriteU8(ICMP6_ECHO_REQUEST)
+                    .addWriteU8(0) // code
+                    .addWriteU16(pingRequestIpv6PayloadLen) // checksum
+                    // identifier
+                    .addPacketCopy(ETHER_HEADER_LEN + IPV6_HEADER_LEN + ICMPV6_HEADER_MIN_LEN, 2)
+                    .addWriteU16(0) // sequence number
+                    .addDataCopy(replyPingPayload) // data
+                    .addTransmitL4(
                         ETHER_HEADER_LEN, // ip_ofs
                         ICMP6_CHECKSUM_OFFSET, // csum_ofs
                         IPV6_SRC_ADDR_OFFSET, // csum_start
                         IPPROTO_ICMPV6, // partial_sum
                         false // udp
-                )
-                // Warning: the program abuse DROPPED_IPV6_MULTICAST_PING for debugging purpose
-                .addCountAndDrop(DROPPED_IPV6_MULTICAST_PING)
-                .defineLabel(skipPacketLabel)
-                .addPass()
-                .generate()
+                    )
+        }
 
+        // Warning: the program abuse DROPPED_IPV6_NS_REPLIED_NON_DAD for debugging purpose
+        gen.addCountAndDrop(DROPPED_IPV6_NS_REPLIED_NON_DAD)
+            .defineLabel(skipPacketLabel)
+            .addPass()
+
+        val program = gen.generate()
         installAndVerifyProgram(program)
 
-        packetReader.sendPing(payload, payloadSize)
-
-        val replyPayload = try {
+        val counterBefore = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_REPLIED_NON_DAD
+        )
+        packetReader.sendPing(payload, payloadSize, expectReplyCount = numOfPacketToTransmit)
+        val replyPayloads = try {
             packetReader.expectPingReply(TIMEOUT_MS * 2)
         } catch (e: TimeoutException) {
-            byteArrayOf() // Empty payload if timeout occurs
+            emptyList()
         }
 
         val apfCounterTracker = ApfCounterTracker()
-        apfCounterTracker.updateCountersFromData(readProgram())
+        val apfRam = readProgram()
+        apfCounterTracker.updateCountersFromData(apfRam)
         Log.i(TAG, "counter map: ${apfCounterTracker.counters}")
 
-        assertThat(replyPayload).isEqualTo(firstByte)
+        val counterAfter = ApfCounterTracker.getCounterValue(
+            apfRam,
+            DROPPED_IPV6_NS_REPLIED_NON_DAD
+        )
+        assertEquals(counterBefore + 1, counterAfter)
+
+        assertThat(replyPayloads.size).isEqualTo(expectReplyPayloads.size)
+
+        // Sort the payload list before comparison to ensure consistency.
+        val sortedReplyPayloads = replyPayloads.sortedBy { it[0] }
+        val sortedExpectReplyPayloads = expectReplyPayloads.sortedBy { it[0] }
+        for (i in sortedReplyPayloads.indices) {
+            assertThat(sortedReplyPayloads[i]).isEqualTo(sortedExpectReplyPayloads[i])
+        }
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
index ceb48d4..faaadee 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
@@ -88,9 +88,11 @@
 import com.android.net.module.util.ArrayTrackRecord;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
+import com.android.testutils.com.android.testutils.CarrierConfigRule;
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -110,6 +112,9 @@
 public class ConnectivityDiagnosticsManagerTest {
     private static final String TAG = ConnectivityDiagnosticsManagerTest.class.getSimpleName();
 
+    @Rule
+    public final CarrierConfigRule mCarrierConfigRule = new CarrierConfigRule();
+
     private static final int CALLBACK_TIMEOUT_MILLIS = 5000;
     private static final int NO_CALLBACK_INVOKED_TIMEOUT = 500;
     private static final long TIMESTAMP = 123456789L;
@@ -264,9 +269,6 @@
             doBroadcastCarrierConfigsAndVerifyOnConnectivityReportAvailable(
                     subId, carrierConfigReceiver, testNetworkCallback);
         }, () -> {
-                runWithShellPermissionIdentity(
-                    () -> mCarrierConfigManager.overrideConfig(subId, null),
-                    android.Manifest.permission.MODIFY_PHONE_STATE);
             mConnectivityManager.unregisterNetworkCallback(testNetworkCallback);
             mContext.unregisterReceiver(carrierConfigReceiver);
             });
@@ -291,9 +293,9 @@
                 CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
                 new String[] {getCertHashForThisPackage()});
 
+        mCarrierConfigRule.addConfigOverrides(subId, carrierConfigs);
         runWithShellPermissionIdentity(
                 () -> {
-                    mCarrierConfigManager.overrideConfig(subId, carrierConfigs);
                     mCarrierConfigManager.notifyConfigChangedForSubId(subId);
                 },
                 android.Manifest.permission.MODIFY_PHONE_STATE);
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index feb4621..87c2b9e 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -90,7 +90,6 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
-import static android.net.cts.util.CtsNetUtils.ConnectivityActionReceiver;
 import static android.net.cts.util.CtsNetUtils.HTTP_PORT;
 import static android.net.cts.util.CtsNetUtils.NETWORK_CALLBACK_ACTION;
 import static android.net.cts.util.CtsNetUtils.TEST_HOST;
@@ -111,6 +110,7 @@
 import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_LOCKDOWN_VPN;
 import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_NONE;
 import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_EXPORTED;
+import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_NOT_EXPORTED;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 import static com.android.testutils.MiscAsserts.assertEventuallyTrue;
@@ -127,6 +127,7 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
 import android.annotation.NonNull;
@@ -177,6 +178,7 @@
 import android.os.Binder;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.ConditionVariable;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.MessageQueue;
@@ -922,6 +924,7 @@
     public void testOpenConnection() throws Exception {
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_TELEPHONY));
+        assumeFalse(Build.MODEL.contains("Cuttlefish"));
 
         Network wifiNetwork = mCtsNetUtils.ensureWifiConnected();
         Network cellNetwork = networkCallbackRule.requestCell();
@@ -1227,42 +1230,43 @@
      * {@link #testRegisterNetworkCallback} except that a {@code PendingIntent} is used instead
      * of a {@code NetworkCallback}.
      */
-    @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
     @Test
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     public void testRegisterNetworkCallback_withPendingIntent() {
-        assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
+        final ConditionVariable received = new ConditionVariable();
 
-        // Create a ConnectivityActionReceiver that has an IntentFilter for our locally defined
-        // action, NETWORK_CALLBACK_ACTION.
-        final IntentFilter filter = new IntentFilter();
-        filter.addAction(NETWORK_CALLBACK_ACTION);
+        // Register a callback with intent and a request for any Internet-providing network,
+        // which should match the currently connected network.
+        final BroadcastReceiver receiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(final Context context, final Intent intent) {
+                received.open();
+            }
+        };
 
-        final ConnectivityActionReceiver receiver = new ConnectivityActionReceiver(
-                mCm, ConnectivityManager.TYPE_WIFI, NetworkInfo.State.CONNECTED);
-        final int flags = SdkLevel.isAtLeastT() ? RECEIVER_EXPORTED : 0;
-        mContext.registerReceiver(receiver, filter, flags);
+        final int flags = SdkLevel.isAtLeastT() ? RECEIVER_NOT_EXPORTED : 0;
+        mContext.registerReceiver(receiver, new IntentFilter(NETWORK_CALLBACK_ACTION), flags);
 
         // Create a broadcast PendingIntent for NETWORK_CALLBACK_ACTION.
         final Intent intent = new Intent(NETWORK_CALLBACK_ACTION)
                 .setPackage(mContext.getPackageName());
-        // While ConnectivityService would put extra info such as network or request id before
-        // broadcasting the inner intent. The MUTABLE flag needs to be added accordingly.
         final PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0 /*requestCode*/,
                 intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
 
-        // We will register for a WIFI network being available or lost.
-        mCm.registerNetworkCallback(makeWifiNetworkRequest(), pendingIntent);
+        // Register for a network providing Internet being available or lost.
+        final NetworkRequest nr = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build();
+        mCm.registerNetworkCallback(nr, pendingIntent);
 
         try {
-            mCtsNetUtils.ensureWifiConnected();
-
-            // Now we expect to get the Intent delivered notifying of the availability of the wifi
-            // network even if it was already connected as a state-based action when the callback
-            // is registered.
-            assertTrue("Did not receive expected Intent " + intent + " for TRANSPORT_WIFI",
-                    receiver.waitForState());
-        } catch (InterruptedException e) {
-            fail("Broadcast receiver or NetworkCallback wait was interrupted.");
+            // Wait for delivery of the Intent notifying of the availability of the
+            // INTERNET-providing network. Test setup makes sure it's already connected.
+            assertTrue("Did not receive expected Intent " + intent + " for INTERNET",
+                    received.block(NETWORK_CALLBACK_TIMEOUT_MS));
         } finally {
             mCm.unregisterNetworkCallback(pendingIntent);
             pendingIntent.cancel();
@@ -1270,6 +1274,33 @@
         }
     }
 
+    // Up to R ConnectivityService can't be updated through mainline, and there was a bug
+    // where registering a callback with a canceled pending intent would crash the system.
+    @Test
+    // Running this test without aosp/3482151 will likely crash the device.
+    @ConnectivityModuleTest
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testRegisterNetworkCallback_pendingIntent_classNotFound() {
+        final Intent intent = new Intent()
+                .setClassName(mContext.getPackageName(), "NonExistent");
+        final PendingIntent pi = PendingIntent.getActivity(mContext, /* requestCode */ 1,
+                intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
+
+        final NetworkRequest nr = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build();
+        try {
+            // Before the fix delivered through Mainline, this used to crash the system, because
+            // trying to send the pending intent would throw which would prompt ConnectivityService
+            // to release the wake lock, but it would still send a finished notification at which
+            // point CS would try to release the wake lock again and crash.
+            mCm.registerNetworkCallback(nr, pi);
+        } finally {
+            mCm.unregisterNetworkCallback(pi);
+            pi.cancel();
+        }
+    }
+
     private void runIdenticalPendingIntentsRequestTest(boolean useListen) throws Exception {
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
 
@@ -1375,12 +1406,20 @@
     }
 
     @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     @Test
     public void testRegisterNetworkRequest_identicalPendingIntents() throws Exception {
         runIdenticalPendingIntentsRequestTest(false /* useListen */);
     }
 
     @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     @Test
     public void testRegisterNetworkCallback_identicalPendingIntents() throws Exception {
         runIdenticalPendingIntentsRequestTest(true /* useListen */);
@@ -1615,7 +1654,7 @@
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
         final ContentResolver resolver = mContext.getContentResolver();
         mCtsNetUtils.ensureWifiConnected();
-        final String ssid = unquoteSSID(mWifiManager.getConnectionInfo().getSSID());
+        final String ssid = unquoteSSID(getSSID());
         final String oldMeteredSetting = getWifiMeteredStatus(ssid);
         final String oldMeteredMultipathPreference = Settings.Global.getString(
                 resolver, NETWORK_METERED_MULTIPATH_PREFERENCE);
@@ -1628,7 +1667,7 @@
             // since R.
             final Network network = setWifiMeteredStatusAndWait(ssid, true /* isMetered */,
                     false /* waitForValidation */);
-            assertEquals(ssid, unquoteSSID(mWifiManager.getConnectionInfo().getSSID()));
+            assertEquals(ssid, unquoteSSID(getSSID()));
             assertEquals(mCm.getNetworkCapabilities(network).hasCapability(
                     NET_CAPABILITY_NOT_METERED), false);
             assertMultipathPreferenceIsEventually(network, initialMeteredPreference,
@@ -2329,8 +2368,10 @@
 
             // Verify that turning airplane mode off takes effect as expected.
             // connectToCell only registers a request, it cannot / does not need to be called twice
-            mCtsNetUtils.ensureWifiConnected();
-            if (verifyWifi) waitForAvailable(wifiCb);
+            if (verifyWifi) {
+                mCtsNetUtils.ensureWifiConnected();
+                waitForAvailable(wifiCb);
+            }
             if (supportTelephony) {
                 telephonyCb.eventuallyExpect(
                         CallbackEntry.AVAILABLE, CELL_DATA_AVAILABLE_TIMEOUT_MS);
@@ -2429,7 +2470,7 @@
                 mPackageManager.hasSystemFeature(FEATURE_WIFI));
 
         final Network network = mCtsNetUtils.ensureWifiConnected();
-        final String ssid = unquoteSSID(mWifiManager.getConnectionInfo().getSSID());
+        final String ssid = unquoteSSID(getSSID());
         assertNotNull("Ssid getting from WifiManager is null", ssid);
         // This package should have no NETWORK_SETTINGS permission. Verify that no ssid is contained
         // in the NetworkCapabilities.
@@ -2940,6 +2981,15 @@
                         new Handler(Looper.getMainLooper())), NETWORK_SETTINGS);
     }
 
+    /**
+     * It needs android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
+     * to use WifiManager.getConnectionInfo() on the visible background user.
+     */
+    private String getSSID() {
+        return runWithShellPermissionIdentity(() ->
+                mWifiManager.getConnectionInfo().getSSID());
+    }
+
     private static final class OnCompleteListenerCallback {
         final CompletableFuture<Object> mDone = new CompletableFuture<>();
 
@@ -4074,4 +4124,11 @@
         // shims, and @IgnoreUpTo does not check that.
         assumeTrue(TestUtils.shouldTestSApis());
     }
+
+    @Test
+    public void testLegacyTetherApisThrowUnsupportedOperationExceptionAfterV() {
+        assumeTrue(Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM);
+        assertThrows(UnsupportedOperationException.class, () -> mCm.tether("iface"));
+        assertThrows(UnsupportedOperationException.class, () -> mCm.untether("iface"));
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt b/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt
new file mode 100644
index 0000000..ff608f2
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/DnsResolverTapTest.kt
@@ -0,0 +1,185 @@
+/*
+ * 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.cts
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.Manifest.permission.READ_DEVICE_CONFIG
+import android.net.DnsResolver
+import android.net.InetAddresses.parseNumericAddress
+import android.net.IpPrefix
+import android.net.MacAddress
+import android.net.RouteInfo
+import android.os.CancellationSignal
+import android.os.HandlerThread
+import android.os.SystemClock
+import android.platform.test.annotations.AppModeFull
+import android.provider.DeviceConfig
+import android.provider.DeviceConfig.NAMESPACE_NETD_NATIVE
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.NetworkStackConstants.ETHER_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
+import com.android.testutils.AutoReleaseNetworkCallbackRule
+import com.android.testutils.DeviceConfigRule
+import com.android.testutils.DnsResolverModuleTest
+import com.android.testutils.IPv6UdpFilter
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.RouterAdvertisementResponder
+import com.android.testutils.TapPacketReaderRule
+import com.android.testutils.TestableNetworkAgent
+import com.android.testutils.TestDnsPacket
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule
+import com.android.testutils.runAsShell
+import java.net.Inet6Address
+import java.net.InetAddress
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val TEST_DNSSERVER_MAC = MacAddress.fromString("00:11:22:33:44:55")
+private val TAG = DnsResolverTapTest::class.java.simpleName
+private const val TEST_TIMEOUT_MS = 10_000L
+
+@AppModeFull(reason = "Test networks cannot be created in instant app mode")
+@DnsResolverModuleTest
+@RunWith(AndroidJUnit4::class)
+class DnsResolverTapTest {
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val handlerThread = HandlerThread(TAG)
+
+    @get:Rule(order = 1)
+    val deviceConfigRule = DeviceConfigRule()
+
+    @get:Rule(order = 2)
+    val featureFlagsRule = SetFeatureFlagsRule(
+        setFlagsMethod = { name, enabled ->
+            val value = when (enabled) {
+                null -> null
+                true -> "1"
+                false -> "0"
+            }
+            deviceConfigRule.setConfig(NAMESPACE_NETD_NATIVE, name, value)
+        },
+        getFlagsMethod = {
+            runAsShell(READ_DEVICE_CONFIG) {
+                DeviceConfig.getInt(NAMESPACE_NETD_NATIVE, it, 0) == 1
+            }
+        }
+    )
+
+    @get:Rule(order = 3)
+    val packetReaderRule = TapPacketReaderRule()
+
+    @get:Rule(order = 4)
+    val cbRule = AutoReleaseNetworkCallbackRule()
+
+    private val ndResponder by lazy { RouterAdvertisementResponder(packetReaderRule.reader) }
+    private val dnsServerAddr by lazy {
+        parseNumericAddress("fe80::124%${packetReaderRule.iface.interfaceName}") as Inet6Address
+    }
+    private lateinit var agent: TestableNetworkAgent
+
+    @Before
+    fun setUp() {
+        handlerThread.start()
+        val interfaceName = packetReaderRule.iface.interfaceName
+        val cb = cbRule.requestNetwork(TestableNetworkAgent.makeNetworkRequestForInterface(
+            interfaceName))
+        agent = runAsShell(MANAGE_TEST_NETWORKS) {
+            TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+                interfaceName, TEST_TIMEOUT_MS)
+        }
+        ndResponder.addNeighborEntry(TEST_DNSSERVER_MAC, dnsServerAddr)
+        ndResponder.start()
+        agent.lp.apply {
+            addDnsServer(dnsServerAddr)
+            // A default route is needed for DnsResolver.java to send queries over IPv6
+            // (see usage of DnsUtils.haveIpv6).
+            addRoute(RouteInfo(IpPrefix("::/0"), null, null))
+        }
+        agent.sendLinkProperties(agent.lp)
+        cb.eventuallyExpect<LinkPropertiesChanged> { it.lp.dnsServers.isNotEmpty() }
+    }
+
+    @After
+    fun tearDown() {
+        ndResponder.stop()
+        if (::agent.isInitialized) {
+            agent.unregister()
+        }
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    private class DnsCallback : DnsResolver.Callback<List<InetAddress>> {
+        override fun onAnswer(answer: List<InetAddress>, rcode: Int) = Unit
+        override fun onError(error: DnsResolver.DnsException) = Unit
+    }
+
+    /**
+     * Run a cancellation test.
+     *
+     * @param domain Domain name to query
+     * @param waitTimeForNoRetryAfterCancellationMs If positive, cancel the query and wait for that
+     *                                              delay to check no retry is sent.
+     * @return The duration it took to receive all expected replies.
+     */
+    fun doCancellationTest(domain: String, waitTimeForNoRetryAfterCancellationMs: Long): Long {
+        val cancellationSignal = CancellationSignal()
+        val dnsCb = DnsCallback()
+        val queryStart = SystemClock.elapsedRealtime()
+        DnsResolver.getInstance().query(
+            agent.network, domain, 0 /* flags */,
+            Runnable::run /* executor */, cancellationSignal, dnsCb
+        )
+
+        if (waitTimeForNoRetryAfterCancellationMs > 0) {
+            cancellationSignal.cancel()
+        }
+        // Filter for queries on UDP port 53 for the specified domain
+        val filter = IPv6UdpFilter(dstPort = 53).and {
+            TestDnsPacket(
+                it.copyOfRange(ETHER_HEADER_LEN + IPV6_HEADER_LEN + UDP_HEADER_LEN, it.size),
+                dstAddr = dnsServerAddr
+            ).isQueryFor(domain, DnsResolver.TYPE_AAAA)
+        }
+
+        val reader = packetReaderRule.reader
+        assertNotNull(reader.poll(TEST_TIMEOUT_MS, filter), "Original query not found")
+        if (waitTimeForNoRetryAfterCancellationMs > 0) {
+            assertNull(reader.poll(waitTimeForNoRetryAfterCancellationMs, filter),
+                "Expected no retry query")
+        } else {
+            assertNotNull(reader.poll(TEST_TIMEOUT_MS, filter), "Retry query not found")
+        }
+        return SystemClock.elapsedRealtime() - queryStart
+    }
+
+    @SetFeatureFlagsRule.FeatureFlag("no_retry_after_cancel", true)
+    @Test
+    fun testCancellation() {
+        val timeWithRetryWhenNotCancelled = doCancellationTest("test1.example.com",
+            waitTimeForNoRetryAfterCancellationMs = 0L)
+        doCancellationTest("test2.example.com",
+            waitTimeForNoRetryAfterCancellationMs = timeWithRetryWhenNotCancelled + 50L)
+    }
+}
\ No newline at end of file
diff --git a/tests/cts/net/src/android/net/cts/DnsResolverTest.java b/tests/cts/net/src/android/net/cts/DnsResolverTest.java
index fa44ae9..b66b853 100644
--- a/tests/cts/net/src/android/net/cts/DnsResolverTest.java
+++ b/tests/cts/net/src/android/net/cts/DnsResolverTest.java
@@ -58,6 +58,7 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.net.module.util.DnsPacket;
+import com.android.testutils.ConnectivityDiagnosticsCollector;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DeviceConfigRule;
@@ -394,7 +395,22 @@
     @Test
     @DnsResolverModuleTest
     public void testRawQueryNXDomainWithPrivateDns() throws Exception {
-        doTestRawQueryNXDomainWithPrivateDns(mExecutor);
+        try {
+            doTestRawQueryNXDomainWithPrivateDns(mExecutor);
+        } catch (Throwable e) {
+            final ConnectivityDiagnosticsCollector collector =
+                    ConnectivityDiagnosticsCollector.getInstance();
+            if (collector != null) {
+                // IWLAN on U QPR3 release may cause failures in this test, see
+                // CarrierConfigSetupTest which is supposed to avoid the issue. Collect IWLAN
+                // related dumpsys if the test still fails.
+                collector.collectDumpsys("carrier_config", e);
+                collector.collectDumpsys("telecom", e);
+                collector.collectDumpsys("telephony_ims", e);
+                collector.collectDumpsys("telephony.registry", e);
+            }
+            throw e;
+        }
     }
 
     @Test
diff --git a/tests/cts/net/src/android/net/cts/DnsTest.java b/tests/cts/net/src/android/net/cts/DnsTest.java
index b1e5680..e367b7d 100644
--- a/tests/cts/net/src/android/net/cts/DnsTest.java
+++ b/tests/cts/net/src/android/net/cts/DnsTest.java
@@ -18,6 +18,7 @@
 
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
 
 import android.content.Context;
 import android.content.pm.PackageManager;
@@ -26,6 +27,7 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkInfo;
+import android.os.Build;
 import android.os.SystemClock;
 import android.util.Log;
 
@@ -299,6 +301,8 @@
     }
 
     private void ensureIpv6Connectivity() throws InterruptedException {
+        assumeFalse(Build.MODEL.contains("Cuttlefish"));
+
         CountDownLatch latch = new CountDownLatch(1);
         final int TIMEOUT_MS = 5_000;
 
diff --git a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
index 1de4cf9..d531e7a 100644
--- a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
+++ b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
@@ -44,6 +44,7 @@
 import android.net.RouteInfo
 import android.net.TestNetworkInterface
 import android.net.TestNetworkManager
+import android.net.TestNetworkManager.TestInterfaceRequest
 import android.net.cts.util.CtsNetUtils.TestNetworkCallback
 import android.os.HandlerThread
 import android.os.SystemClock
@@ -164,7 +165,11 @@
 
             // Only statically configure the IPv4 address; for IPv6, use the SLAAC generated
             // address.
-            iface = tnm.createTapInterface(arrayOf(LinkAddress(LOCAL_IPV4_ADDRESS, IP4_PREFIX_LEN)))
+            val req = TestInterfaceRequest.Builder()
+                    .setTap()
+                    .addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, IP4_PREFIX_LEN))
+                    .build()
+            iface = tnm.createTestInterface(req)
             assertNotNull(iface)
         }
 
@@ -752,6 +757,27 @@
         assertEquals(IPPROTO_UDP, policy2.protocol)
         assertParcelingIsLossless(policy2)
     }
+
+    @Test
+    fun testSendDscpPolicyWithoutInterfaceName() {
+        val nc = NetworkCapabilities().apply {
+            addTransportType(TRANSPORT_TEST)
+        }
+        val agent = TestableNetworkAgent(
+                realContext,
+                handlerThread.looper,
+                nc,
+                LinkProperties() /* note: no interface name */,
+                NetworkAgentConfig.Builder().build()
+        )
+        agentsToCleanUp.add(agent)
+        runAsShell(MANAGE_TEST_NETWORKS) { agent.register() }
+        // Without the fix, this will crash the system with SIGSEGV.
+        agent.sendAddDscpPolicy(DscpPolicy.Builder(1, 1).build())
+        // Will receive OnNetworkCreated first if the agent is created early. To avoid reading
+        // the flag here, use eventuallyExpect.
+        agent.eventuallyExpect<OnDscpPolicyStatusUpdated>()
+    }
 }
 
 private fun ByteBuffer.readAsArray(): ByteArray {
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index 9be579b..9f32132 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -51,6 +51,7 @@
 import android.net.StaticIpConfiguration
 import android.net.TestNetworkInterface
 import android.net.TestNetworkManager
+import android.net.TestNetworkManager.TestInterfaceRequest
 import android.net.cts.EthernetManagerTest.EthernetStateListener.CallbackEntry.EthernetStateChanged
 import android.net.cts.EthernetManagerTest.EthernetStateListener.CallbackEntry.InterfaceStateChanged
 import android.os.Build
@@ -77,6 +78,7 @@
 import com.android.testutils.assertThrows
 import com.android.testutils.runAsShell
 import com.android.testutils.waitForIdle
+import com.google.common.truth.Truth.assertThat
 import java.io.IOException
 import java.net.Inet6Address
 import java.net.Socket
@@ -168,7 +170,12 @@
                 // false, it is subsequently disabled. This means that the interface may briefly get
                 // link. With IPv6 provisioning delays (RS delay and DAD) disabled, this can cause
                 // tests that expect no network to come up when hasCarrier is false to become flaky.
-                tnm.createTapInterface(hasCarrier, false /* bringUp */)
+                val req = TestInterfaceRequest.Builder()
+                        .setTap()
+                        .setHasCarrier(hasCarrier)
+                        .setBringUp(false)
+                        .build()
+                tnm.createTestInterface(req)
             }
             val mtu = tapInterface.mtu
             packetReader = PollPacketReader(
@@ -604,6 +611,9 @@
     }
 
     private fun assumeNoInterfaceForTetheringAvailable() {
+         // Requesting a tethered interface will stop IpClient. Prevent it from doing so
+         // if adb is connected over ethernet.
+         assumeFalse(isAdbOverEthernet())
         // Interfaces that have configured NetworkCapabilities will never be used for tethering,
         // see aosp/2123900.
         try {
@@ -1068,6 +1078,9 @@
 
     @Test
     fun testSetTetheringInterfaceMode_disableEnableEthernet() {
+        // do not run this test if an interface that can be used for tethering already exists.
+        assumeNoInterfaceForTetheringAvailable()
+
         val listener = EthernetStateListener()
         addInterfaceStateListener(listener)
 
@@ -1089,4 +1102,24 @@
         setEthernetEnabled(true)
         listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
     }
+
+    @Test
+    fun testGetInterfaceList_disableEnableEthernet() {
+        // Test that interface list can be obtained when ethernet is disabled.
+        setEthernetEnabled(false)
+        // Create two test interfaces and check the return list contains the interface names.
+        val iface1 = createInterface()
+        val iface2 = createInterface()
+        var ifaces = em.getInterfaceList()
+        assertThat(ifaces).containsAtLeast(iface1.name, iface2.name)
+
+        // Remove one existing test interface and check the return list doesn't contain the
+        // removed interface name.
+        removeInterface(iface1)
+        ifaces = em.getInterfaceList()
+        assertThat(ifaces).doesNotContain(iface1.name)
+        assertThat(ifaces).contains(iface2.name)
+
+        removeInterface(iface2)
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/IpSecManagerTest.java b/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
index e94d94f..4a21f09 100644
--- a/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecManagerTest.java
@@ -375,6 +375,7 @@
     }
 
     private static boolean isIpv6UdpEncapSupportedByKernel() {
+        if (SdkLevel.isAtLeastB() && isKernelVersionAtLeast("5.10.0")) return true;
         return isKernelVersionAtLeast("5.15.31")
                 || (isKernelVersionAtLeast("5.10.108") && !isKernelVersionAtLeast("5.15.0"));
     }
@@ -390,8 +391,8 @@
         assumeTrue("Not supported by kernel", isIpv6UdpEncapSupportedByKernel());
     }
 
-    // TODO: b/319532485 Figure out whether to support x86_32
     private static boolean isRequestTransformStateSupportedByKernel() {
+        if (SdkLevel.isAtLeastB()) return true;
         return NetworkUtils.isKernel64Bit() || !NetworkUtils.isKernelX86();
     }
 
diff --git a/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt b/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt
new file mode 100644
index 0000000..484cce8
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/L2capNetworkSpecifierTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2025 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.cts
+
+import android.net.L2capNetworkSpecifier
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_ANY
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_NONE
+import android.net.L2capNetworkSpecifier.PSM_ANY
+import android.net.L2capNetworkSpecifier.ROLE_CLIENT
+import android.net.L2capNetworkSpecifier.ROLE_SERVER
+import android.net.MacAddress
+import android.os.Build
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.assertParcelingIsLossless
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class L2capNetworkSpecifierTest {
+    @Test
+    fun testParcelUnparcel() {
+        val remoteMac = MacAddress.fromString("01:02:03:04:05:06")
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .setPsm(42)
+                .setRemoteAddress(remoteMac)
+                .build()
+        assertParcelingIsLossless(specifier)
+    }
+
+    @Test
+    fun testGetters() {
+        val remoteMac = MacAddress.fromString("11:22:33:44:55:66")
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setPsm(123)
+                .setRemoteAddress(remoteMac)
+                .build()
+        assertEquals(ROLE_CLIENT, specifier.getRole())
+        assertEquals(HEADER_COMPRESSION_NONE, specifier.getHeaderCompression())
+        assertEquals(123, specifier.getPsm())
+        assertEquals(remoteMac, specifier.getRemoteAddress())
+    }
+
+    @Test
+    fun testCanBeSatisfiedBy() {
+        val blanketOffer = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_ANY)
+                .setPsm(PSM_ANY)
+                .build()
+
+        val reservedOffer = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .setPsm(42)
+                .build()
+
+        val clientOffer = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_ANY)
+                .build()
+
+        val serverReservation = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+
+        assertTrue(serverReservation.canBeSatisfiedBy(blanketOffer))
+        assertTrue(serverReservation.canBeSatisfiedBy(reservedOffer))
+        // Note: serverReservation can be filed using reserveNetwork, or it could be a regular
+        // request filed using requestNetwork.
+        assertFalse(serverReservation.canBeSatisfiedBy(clientOffer))
+
+        val clientRequest = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .setRemoteAddress(MacAddress.fromString("00:01:02:03:04:05"))
+                .setPsm(42)
+                .build()
+
+        assertTrue(clientRequest.canBeSatisfiedBy(clientOffer))
+        // Note: the BlanketOffer also includes a RES_ID_MATCH_ALL_RESERVATIONS. Since the
+        // clientRequest is not a reservation, it won't match that request to begin with.
+        assertFalse(clientRequest.canBeSatisfiedBy(blanketOffer))
+        assertFalse(clientRequest.canBeSatisfiedBy(reservedOffer))
+
+        val matchAny = L2capNetworkSpecifier.Builder().build()
+        assertTrue(matchAny.canBeSatisfiedBy(blanketOffer))
+        assertTrue(matchAny.canBeSatisfiedBy(reservedOffer))
+        assertTrue(matchAny.canBeSatisfiedBy(clientOffer))
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 815c3a5..02a5d1f 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -15,7 +15,7 @@
  */
 package android.net.cts
 
-import android.Manifest.permission.MODIFY_PHONE_STATE
+import android.Manifest.permission.NEARBY_WIFI_DEVICES
 import android.Manifest.permission.NETWORK_SETTINGS
 import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
 import android.app.Instrumentation
@@ -80,12 +80,10 @@
 import android.net.cts.NetworkAgentTest.TestableQosCallback.CallbackEntry.OnQosSessionLost
 import android.net.wifi.WifiInfo
 import android.os.Build
-import android.os.ConditionVariable
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.Looper
 import android.os.Message
-import android.os.PersistableBundle
 import android.os.Process
 import android.os.SystemClock
 import android.platform.test.annotations.AppModeFull
@@ -94,19 +92,15 @@
 import android.system.OsConstants.IPPROTO_TCP
 import android.system.OsConstants.IPPROTO_UDP
 import android.system.OsConstants.SOCK_DGRAM
-import android.telephony.CarrierConfigManager
 import android.telephony.SubscriptionManager
 import android.telephony.TelephonyManager
-import android.telephony.TelephonyManager.CarrierPrivilegesCallback
 import android.telephony.data.EpsBearerQosSessionAttributes
 import android.util.ArraySet
 import android.util.DebugUtils.valueToString
-import android.util.Log
 import androidx.test.InstrumentationRegistry
 import com.android.compatibility.common.util.SystemUtil.runShellCommand
 import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity
 import com.android.compatibility.common.util.ThrowingSupplier
-import com.android.compatibility.common.util.UiccUtil
 import com.android.modules.utils.build.SdkLevel
 import com.android.net.module.util.ArrayTrackRecord
 import com.android.net.module.util.NetworkStackConstants.ETHER_MTU
@@ -117,13 +111,13 @@
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.PollPacketReader
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
 import com.android.testutils.RecorderCallback.CallbackEntry.BlockedStatus
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Losing
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
-import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAddKeepalivePacketFilter
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAutomaticReconnectDisabled
@@ -140,6 +134,7 @@
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnValidationStatus
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.assertThrows
+import com.android.testutils.com.android.testutils.CarrierConfigRule
 import com.android.testutils.runAsShell
 import com.android.testutils.tryTest
 import com.android.testutils.waitForIdle
@@ -149,14 +144,13 @@
 import java.net.InetAddress
 import java.net.InetSocketAddress
 import java.net.Socket
-import java.security.MessageDigest
 import java.nio.ByteBuffer
 import java.time.Duration
 import java.util.Arrays
-import java.util.Random
 import java.util.UUID
 import java.util.concurrent.Executors
 import kotlin.collections.ArrayList
+import kotlin.random.Random
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
@@ -167,9 +161,11 @@
 import org.junit.After
 import org.junit.Assume.assumeTrue
 import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.ArgumentMatchers.argThat
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.doReturn
@@ -178,10 +174,16 @@
 import org.mockito.Mockito.verify
 
 private const val TAG = "NetworkAgentTest"
+
 // This test doesn't really have a constraint on how fast the methods should return. If it's
 // going to fail, it will simply wait forever, so setting a high timeout lowers the flake ratio
 // without affecting the run time of successful runs. Thus, set a very high timeout.
 private const val DEFAULT_TIMEOUT_MS = 5000L
+
+private const val QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER =
+    "queue_network_agent_events_in_system_server"
+
+
 // When waiting for a NetworkCallback to determine there was no timeout, waiting is the
 // only possible thing (the relevant handler is the one in the real ConnectivityService,
 // and then there is the Binder call), so have a short timeout for this as it will be
@@ -206,12 +208,6 @@
 private val PREFIX = IpPrefix("2001:db8::/64")
 private val NEXTHOP = InetAddresses.parseNumericAddress("fe80::abcd")
 
-// On T and below, the native network is only created when the agent connects.
-// Starting in U, the native network was to be created as soon as the agent is registered,
-// but this has been flagged off for now pending resolution of race conditions.
-// TODO : enable this in a Mainline update or in V.
-private const val SHOULD_CREATE_NETWORKS_IMMEDIATELY = false
-
 @AppModeFull(reason = "Instant apps can't use NetworkAgent because it needs NETWORK_FACTORY'.")
 // NetworkAgent is updated as part of the connectivity module, and running NetworkAgent tests in MTS
 // for modules other than Connectivity does not provide much value. Only run them in connectivity
@@ -223,6 +219,9 @@
 @IgnoreUpTo(Build.VERSION_CODES.R)
 @RunWith(DevSdkIgnoreRunner::class)
 class NetworkAgentTest {
+    @get:Rule
+    val carrierConfigRule = CarrierConfigRule()
+
     private val LOCAL_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.1")
     private val REMOTE_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.2")
 
@@ -234,9 +233,27 @@
     private var qosTestSocket: Closeable? = null // either Socket or DatagramSocket
     private val ifacesToCleanUp = mutableListOf<TestNetworkInterface>()
 
+    // Unless the queuing in system server feature is chickened out, native networks are created
+    // immediately. Historically they would only created as they'd connect, which would force
+    // the code to apply link properties multiple times and suffer errors early on. Creating
+    // them early required that ordering between the client and the system server is guaranteed
+    // (at least to some extent), which has been done by moving the event queue from the client
+    // to the system server. When that feature is not chickened out, create networks immediately.
+    private val SHOULD_CREATE_NETWORKS_IMMEDIATELY
+        get() = mCM.isConnectivityServiceFeatureEnabledForTesting(
+            QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER
+        )
+
+
     @Before
     fun setUp() {
         instrumentation.getUiAutomation().adoptShellPermissionIdentity()
+        if (SdkLevel.isAtLeastT()) {
+            instrumentation.getUiAutomation().grantRuntimePermission(
+                "android.net.cts",
+                NEARBY_WIFI_DEVICES
+            )
+        }
         mHandlerThread.start()
     }
 
@@ -259,7 +276,7 @@
     private class FakeConnectivityService {
         val mockRegistry = mock(INetworkAgentRegistry::class.java)
         private var agentField: INetworkAgent? = null
-        private val registry = object : INetworkAgentRegistry.Stub(),
+        val registry: INetworkAgentRegistry = object : INetworkAgentRegistry.Stub(),
                 INetworkAgentRegistry by mockRegistry {
             // asBinder has implementations in both INetworkAgentRegistry.Stub and mockRegistry, so
             // it needs to be disambiguated. Just fail the test as it should be unused here.
@@ -276,7 +293,7 @@
 
         fun connect(agent: INetworkAgent) {
             this.agentField = agent
-            agent.onRegistered(registry)
+            agent.onRegistered()
         }
 
         fun disconnect() = agent.onDisconnected()
@@ -378,11 +395,15 @@
         // Ensure this NetworkAgent is never unneeded by filing a request with its specifier.
         requestNetwork(makeTestNetworkRequest(specifier), callback)
         val nc = makeTestNetworkCapabilities(specifier, transports)
-        val agent = createNetworkAgent(context, initialConfig = initialConfig, initialLp = lp,
-            initialNc = nc)
-        agent.setTeardownDelayMillis(0)
+        val agent = createNetworkAgent(
+            context,
+            initialConfig = initialConfig,
+            initialLp = lp,
+            initialNc = nc
+        )
         // Connect the agent and verify initial status callbacks.
         agent.register()
+        agent.setTeardownDelayMillis(0)
         agent.markConnected()
         agent.expectCallback<OnNetworkCreated>()
         agent.expectPostConnectionCallbacks(expectedInitSignalStrengthThresholds)
@@ -401,7 +422,8 @@
     }
 
     private fun createNetworkAgentWithFakeCS() = createNetworkAgent().also {
-        mFakeConnectivityService.connect(it.registerForTest(Network(FAKE_NET_ID)))
+        val binder = it.registerForTest(Network(FAKE_NET_ID), mFakeConnectivityService.registry)
+        mFakeConnectivityService.connect(binder)
     }
 
     private fun TestableNetworkAgent.expectPostConnectionCallbacks(
@@ -414,7 +436,8 @@
 
     private fun createTunInterface(addrs: Collection<LinkAddress> = emptyList()):
             TestNetworkInterface = realContext.getSystemService(
-                TestNetworkManager::class.java)!!.createTunInterface(addrs).also {
+                TestNetworkManager::class.java
+            )!!.createTunInterface(addrs).also {
             ifacesToCleanUp.add(it)
     }
 
@@ -546,9 +569,12 @@
     @Test
     fun testSocketKeepalive(): Unit = createNetworkAgentWithFakeCS().let { agent ->
         val packet = NattKeepalivePacketData(
-                LOCAL_IPV4_ADDRESS /* srcAddress */, 1234 /* srcPort */,
-                REMOTE_IPV4_ADDRESS /* dstAddress */, 4567 /* dstPort */,
-                ByteArray(100 /* size */))
+            LOCAL_IPV4_ADDRESS /* srcAddress */,
+            1234 /* srcPort */,
+            REMOTE_IPV4_ADDRESS /* dstAddress */,
+            4567 /* dstPort */,
+            ByteArray(100 /* size */)
+        )
         val slot = 4
         val interval = 37
 
@@ -653,8 +679,13 @@
             uid: Int,
             expectUidsPresent: Boolean
     ) {
-        doTestAllowedUids(intArrayOf(transport), uid, expectUidsPresent,
-                specifier = null, transportInfo = null)
+        doTestAllowedUids(
+            intArrayOf(transport),
+            uid,
+            expectUidsPresent,
+            specifier = null,
+            transportInfo = null
+        )
     }
 
     private fun doTestAllowedUidsWithSubId(
@@ -687,94 +718,6 @@
         doTestAllowedUids(transports, uid, expectUidsPresent, specifier, transportInfo)
     }
 
-    private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
-        fun getCertHash(): String {
-            val pkgInfo = realContext.packageManager.getPackageInfo(realContext.opPackageName,
-                    PackageManager.GET_SIGNATURES)
-            val digest = MessageDigest.getInstance("SHA-256")
-            val certHash = digest.digest(pkgInfo.signatures!![0]!!.toByteArray())
-            return UiccUtil.bytesToHexString(certHash)!!
-        }
-
-        val tm = realContext.getSystemService(TelephonyManager::class.java)!!
-        val ccm = realContext.getSystemService(CarrierConfigManager::class.java)!!
-
-        val cv = ConditionVariable()
-        val cpb = PrivilegeWaiterCallback(cv)
-        tryTest {
-            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) {
-                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
-            }
-            // Wait for the callback to be registered
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't register CarrierPrivilegesCallback")
-            if (cpb.hasPrivilege == hold) {
-                if (hold) {
-                    Log.w(TAG, "Package ${realContext.opPackageName} already is privileged")
-                } else {
-                    Log.w(TAG, "Package ${realContext.opPackageName} already isn't privileged")
-                }
-                return@tryTest
-            }
-            cv.close()
-            runAsShell(MODIFY_PHONE_STATE) {
-                val carrierConfigs = if (hold) {
-                    PersistableBundle().also {
-                        it.putStringArray(CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
-                                arrayOf(getCertHash()))
-                    }
-                } else {
-                    null
-                }
-                ccm.overrideConfig(subId, carrierConfigs)
-            }
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't change carrier privilege")
-        } cleanup {
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) {
-                tm.unregisterCarrierPrivilegesCallback(cpb)
-            }
-        }
-    }
-
-    private fun acquireCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(true, subId)
-    private fun dropCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(false, subId)
-
-    private fun setCarrierServicePackageOverride(subId: Int, pkg: String?) {
-        val tm = realContext.getSystemService(TelephonyManager::class.java)!!
-
-        val cv = ConditionVariable()
-        val cpb = CarrierServiceChangedWaiterCallback(cv)
-        tryTest {
-            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) {
-                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
-            }
-            // Wait for the callback to be registered
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't register CarrierPrivilegesCallback")
-            if (cpb.pkgName == pkg) {
-                Log.w(TAG, "Carrier service package was already $pkg")
-                return@tryTest
-            }
-            cv.close()
-            runAsShell(MODIFY_PHONE_STATE) {
-                if (null == pkg) {
-                    // There is a bug is clear-carrier-service-package-override where not adding
-                    // the -s argument will use the wrong slot index : b/299604822
-                    runShellCommand("cmd phone clear-carrier-service-package-override" +
-                            " -s $subId")
-                } else {
-                    // -s could set the subId, but this test works with the default subId.
-                    runShellCommand("cmd phone set-carrier-service-package-override $pkg")
-                }
-            }
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't modify carrier service package")
-        } cleanup {
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) {
-                tm.unregisterCarrierPrivilegesCallback(cpb)
-            }
-        }
-    }
-
     private fun String.execute() = runShellCommand(this).trim()
 
     @Test
@@ -799,31 +742,48 @@
         val uid = try {
             realContext.packageManager.getApplicationInfo(servicePackage, 0).uid
         } catch (e: PackageManager.NameNotFoundException) {
-            fail("$servicePackage could not be installed, please check the SuiteApkInstaller" +
-                    " installed CtsCarrierServicePackage.apk", e)
+            fail(
+                "$servicePackage could not be installed, please check the SuiteApkInstaller" +
+                    " installed CtsCarrierServicePackage.apk",
+                e
+            )
         }
 
         val tm = realContext.getSystemService(TelephonyManager::class.java)!!
         val defaultSubId = SubscriptionManager.getDefaultSubscriptionId()
-        assertTrue(defaultSubId != SubscriptionManager.INVALID_SUBSCRIPTION_ID,
-                "getDefaultSubscriptionId returns INVALID_SUBSCRIPTION_ID")
+        assertTrue(
+            defaultSubId != SubscriptionManager.INVALID_SUBSCRIPTION_ID,
+            "getDefaultSubscriptionId returns INVALID_SUBSCRIPTION_ID"
+        )
         tryTest {
             // This process is not the carrier service UID, so allowedUids should be ignored in all
             // the following cases.
-            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_CELLULAR, uid,
-                    expectUidsPresent = false)
-            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_WIFI, uid,
-                    expectUidsPresent = false)
-            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_BLUETOOTH, uid,
-                    expectUidsPresent = false)
+            doTestAllowedUidsWithSubId(
+                defaultSubId,
+                TRANSPORT_CELLULAR,
+                uid,
+                    expectUidsPresent = false
+            )
+            doTestAllowedUidsWithSubId(
+                defaultSubId,
+                TRANSPORT_WIFI,
+                uid,
+                    expectUidsPresent = false
+            )
+            doTestAllowedUidsWithSubId(
+                defaultSubId,
+                TRANSPORT_BLUETOOTH,
+                uid,
+                    expectUidsPresent = false
+            )
 
             // The tools to set the carrier service package override do not exist before U,
             // so there is no way to test the rest of this test on < U.
             if (!SdkLevel.isAtLeastU()) return@tryTest
             // Acquiring carrier privilege is necessary to override the carrier service package.
             val defaultSlotIndex = SubscriptionManager.getSlotIndex(defaultSubId)
-            acquireCarrierPrivilege(defaultSubId)
-            setCarrierServicePackageOverride(defaultSubId, servicePackage)
+            carrierConfigRule.acquireCarrierPrivilege(defaultSubId)
+            carrierConfigRule.setCarrierServicePackageOverride(defaultSubId, servicePackage)
             val actualServicePackage: String? = runAsShell(READ_PRIVILEGED_PHONE_STATE) {
                 tm.getCarrierServicePackageNameForLogicalSlot(defaultSlotIndex)
             }
@@ -833,9 +793,11 @@
             val timeout = SystemClock.elapsedRealtime() + DEFAULT_TIMEOUT_MS
             while (true) {
                 if (SystemClock.elapsedRealtime() > timeout) {
-                    fail("Couldn't make $servicePackage the service package for $defaultSubId: " +
+                    fail(
+                        "Couldn't make $servicePackage the service package for $defaultSubId: " +
                             "dumpsys connectivity".execute().split("\n")
-                                    .filter { it.contains("Logical slot = $defaultSlotIndex.*") })
+                                    .filter { it.contains("Logical slot = $defaultSlotIndex.*") }
+                    )
                 }
                 if ("dumpsys connectivity"
                         .execute()
@@ -858,14 +820,18 @@
                 // TODO(b/315136340): Allow ownerUid to see allowedUids and enable below test case
                 // doTestAllowedUids(defaultSubId, TRANSPORT_WIFI, uid, expectUidsPresent = true)
             }
-            doTestAllowedUidsWithSubId(defaultSubId, TRANSPORT_BLUETOOTH, uid,
-                    expectUidsPresent = false)
-            doTestAllowedUidsWithSubId(defaultSubId, intArrayOf(TRANSPORT_CELLULAR, TRANSPORT_WIFI),
-                    uid, expectUidsPresent = false)
-        } cleanupStep {
-            if (SdkLevel.isAtLeastU()) setCarrierServicePackageOverride(defaultSubId, null)
-        } cleanup {
-            if (SdkLevel.isAtLeastU()) dropCarrierPrivilege(defaultSubId)
+            doTestAllowedUidsWithSubId(
+                defaultSubId,
+                TRANSPORT_BLUETOOTH,
+                uid,
+                    expectUidsPresent = false
+            )
+            doTestAllowedUidsWithSubId(
+                defaultSubId,
+                intArrayOf(TRANSPORT_CELLULAR, TRANSPORT_WIFI),
+                    uid,
+                expectUidsPresent = false
+            )
         }
     }
 
@@ -910,8 +876,10 @@
         // If using the int ranking, agent1 must be upgraded to a better score so that there is
         // no ambiguity when agent2 connects that agent1 is still better. If using policy
         // ranking, this is not necessary.
-        agent1.sendNetworkScore(NetworkScore.Builder().setLegacyInt(BETTER_NETWORK_SCORE)
-                .build())
+        agent1.sendNetworkScore(
+            NetworkScore.Builder().setLegacyInt(BETTER_NETWORK_SCORE)
+                .build()
+        )
 
         // Connect the second agent.
         val (agent2, _) = createConnectedNetworkAgent()
@@ -920,10 +888,12 @@
         // virtue of already satisfying the request.
         callback.assertNoCallback(NO_CALLBACK_TIMEOUT)
         // Now downgrade the score and expect the callback now prefers agent2
-        agent1.sendNetworkScore(NetworkScore.Builder()
+        agent1.sendNetworkScore(
+            NetworkScore.Builder()
                 .setLegacyInt(WORSE_NETWORK_SCORE)
                 .setExiting(true)
-                .build())
+                .build()
+        )
         callback.expect<Available>(agent2.network!!)
 
         // tearDown() will unregister the requests and agents
@@ -968,16 +938,20 @@
         // Check that the default network's transport is propagated to the VPN.
         var vpnNc = mCM.getNetworkCapabilities(agent.network!!)
         assertNotNull(vpnNc)
-        assertEquals(VpnManager.TYPE_VPN_SERVICE,
-                (vpnNc.transportInfo as VpnTransportInfo).type)
+        assertEquals(
+            VpnManager.TYPE_VPN_SERVICE,
+            (vpnNc.transportInfo as VpnTransportInfo).type
+        )
         assertEquals(mySessionId, (vpnNc.transportInfo as VpnTransportInfo).sessionId)
 
         val testAndVpn = intArrayOf(TRANSPORT_TEST, TRANSPORT_VPN)
         assertTrue(vpnNc.hasAllTransports(testAndVpn))
         assertFalse(vpnNc.hasCapability(NET_CAPABILITY_NOT_VPN))
-        assertTrue(vpnNc.hasAllTransports(defaultNetworkTransports),
-                "VPN transports ${Arrays.toString(vpnNc.transportTypes)}" +
-                        " lacking transports from ${Arrays.toString(defaultNetworkTransports)}")
+        assertTrue(
+            vpnNc.hasAllTransports(defaultNetworkTransports),
+            "VPN transports ${Arrays.toString(vpnNc.transportTypes)}" +
+                    " lacking transports from ${Arrays.toString(defaultNetworkTransports)}"
+        )
 
         // Check that when no underlying networks are announced the underlying transport disappears.
         agent.setUnderlyingNetworks(listOf<Network>())
@@ -999,9 +973,11 @@
         // underlying networks, and because not congested, not roaming, and not suspended are the
         // default anyway. It's still useful as an extra check though.
         vpnNc = mCM.getNetworkCapabilities(agent.network!!)!!
-        for (cap in listOf(NET_CAPABILITY_NOT_CONGESTED,
-                NET_CAPABILITY_NOT_ROAMING,
-                NET_CAPABILITY_NOT_SUSPENDED)) {
+        for (cap in listOf(
+            NET_CAPABILITY_NOT_CONGESTED,
+            NET_CAPABILITY_NOT_ROAMING,
+            NET_CAPABILITY_NOT_SUSPENDED
+        )) {
             val capStr = valueToString(NetworkCapabilities::class.java, "NET_CAPABILITY_", cap)
             if (defaultNetworkCapabilities.hasCapability(cap) && !vpnNc.hasCapability(cap)) {
                 fail("$capStr not propagated from underlying: $defaultNetworkCapabilities")
@@ -1012,6 +988,47 @@
         callback.expect<Lost>(agent.network!!)
     }
 
+    fun doTestOemVpnType(type: Int) {
+        val mySessionId = "MySession12345"
+        val nc = NetworkCapabilities().apply {
+            addTransportType(TRANSPORT_TEST)
+            addTransportType(TRANSPORT_VPN)
+            addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            removeCapability(NET_CAPABILITY_NOT_VPN)
+            setTransportInfo(VpnTransportInfo(type, mySessionId))
+        }
+
+        val agent = createNetworkAgent(initialNc = nc)
+        agent.register()
+        agent.markConnected()
+
+        val request = NetworkRequest.Builder()
+            .clearCapabilities()
+            .addTransportType(TRANSPORT_VPN)
+            .removeCapability(NET_CAPABILITY_NOT_VPN)
+            .build()
+        val callback = TestableNetworkCallback()
+        registerNetworkCallback(request, callback)
+
+        callback.expectAvailableThenValidatedCallbacks(agent.network!!)
+
+        var vpnNc = mCM.getNetworkCapabilities(agent.network!!)
+        assertNotNull(vpnNc)
+        assertEquals(type, (vpnNc!!.transportInfo as VpnTransportInfo).type)
+
+        agent.unregister()
+        callback.expect<Lost>(agent.network!!)
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    fun testOemVpnTypes() {
+        // TODO: why is this necessary given the @IgnoreUpTo above?
+        assumeTrue(SdkLevel.isAtLeastB())
+        doTestOemVpnType(VpnManager.TYPE_VPN_OEM_SERVICE)
+        doTestOemVpnType(VpnManager.TYPE_VPN_OEM_LEGACY)
+    }
+
     private fun unregister(agent: TestableNetworkAgent) {
         agent.unregister()
         agent.eventuallyExpect<OnNetworkUnwanted>()
@@ -1023,16 +1040,37 @@
     fun testAgentStartsInConnecting() {
         val mockContext = mock(Context::class.java)
         val mockCm = mock(ConnectivityManager::class.java)
+        val mockedResult = ConnectivityManager.MockHelpers.registerNetworkAgentResult(
+            mock(Network::class.java),
+            mock(INetworkAgentRegistry::class.java)
+        )
+        doReturn(SHOULD_CREATE_NETWORKS_IMMEDIATELY).`when`(mockCm)
+            .isFeatureEnabled(
+                eq(ConnectivityManager.FEATURE_QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER)
+            )
+        doReturn(Context.CONNECTIVITY_SERVICE).`when`(mockContext)
+            .getSystemServiceName(ConnectivityManager::class.java)
         doReturn(mockCm).`when`(mockContext).getSystemService(Context.CONNECTIVITY_SERVICE)
+        doReturn(mockedResult).`when`(mockCm).registerNetworkAgent(
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            anyInt()
+        )
         val agent = createNetworkAgent(mockContext)
         agent.register()
-        verify(mockCm).registerNetworkAgent(any(),
-                argThat<NetworkInfo> { it.detailedState == NetworkInfo.DetailedState.CONNECTING },
-                any(LinkProperties::class.java),
-                any(NetworkCapabilities::class.java),
-                any(NetworkScore::class.java),
-                any(NetworkAgentConfig::class.java),
-                eq(NetworkProvider.ID_NONE))
+        verify(mockCm).registerNetworkAgent(
+            any(),
+            argThat<NetworkInfo> { it.detailedState == NetworkInfo.DetailedState.CONNECTING },
+            any(LinkProperties::class.java),
+            any(NetworkCapabilities::class.java),
+            any(NetworkScore::class.java),
+            any(NetworkAgentConfig::class.java),
+            eq(NetworkProvider.ID_NONE)
+        )
     }
 
     @Test
@@ -1079,8 +1117,10 @@
     @Test
     fun testValidationStatus() = createNetworkAgentWithFakeCS().let { agent ->
         val uri = Uri.parse("http://www.google.com")
-        mFakeConnectivityService.agent.onValidationStatusChanged(VALID_NETWORK,
-                uri.toString())
+        mFakeConnectivityService.agent.onValidationStatusChanged(
+            VALID_NETWORK,
+            uri.toString()
+        )
         agent.expectCallback<OnValidationStatus>().let {
             assertEquals(it.status, VALID_NETWORK)
             assertEquals(it.uri, uri)
@@ -1155,7 +1195,8 @@
         }
         assertFailsWith<IllegalArgumentException> {
             agentWeaker.setLingerDuration(Duration.ofMillis(
-                    NetworkAgent.MIN_LINGER_TIMER_MS.toLong() - 1))
+                    NetworkAgent.MIN_LINGER_TIMER_MS.toLong() - 1
+            ))
         }
         // Verify valid linger timer can be set, but it should not take effect since the network
         // is still needed.
@@ -1165,11 +1206,14 @@
         agentWeaker.setLingerDuration(Duration.ofMillis(NetworkAgent.MIN_LINGER_TIMER_MS.toLong()))
         // Make a listener which can observe agentWeaker lost later.
         val callbackWeaker = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
-        registerNetworkCallback(NetworkRequest.Builder()
+        registerNetworkCallback(
+            NetworkRequest.Builder()
                 .clearCapabilities()
                 .addTransportType(TRANSPORT_TEST)
                 .setNetworkSpecifier(CompatUtil.makeEthernetNetworkSpecifier(specifierWeaker))
-                .build(), callbackWeaker)
+                .build(),
+            callbackWeaker
+        )
         callbackWeaker.expectAvailableCallbacks(agentWeaker.network!!)
 
         // Connect the agentStronger with a score better than agentWeaker. Verify the callback for
@@ -1186,8 +1230,10 @@
         val expectedRemainingLingerDuration = lingerStart +
                 NetworkAgent.MIN_LINGER_TIMER_MS.toLong() - SystemClock.elapsedRealtime()
         // If the available callback is too late. The remaining duration will be reduced.
-        assertTrue(expectedRemainingLingerDuration > 0,
-                "expected remaining linger duration is $expectedRemainingLingerDuration")
+        assertTrue(
+            expectedRemainingLingerDuration > 0,
+            "expected remaining linger duration is $expectedRemainingLingerDuration"
+        )
         callbackWeaker.assertNoCallback(expectedRemainingLingerDuration)
         callbackWeaker.expect<Lost>(agentWeaker.network!!)
     }
@@ -1205,20 +1251,24 @@
         assertEquals(imsi, testNetworkSnapshot!!.subscriberId)
     }
 
-    @Test
-    @IgnoreUpTo(Build.VERSION_CODES.R)
     // TODO: Refactor helper functions to util class and move this test case to
     //  {@link android.net.cts.ConnectivityManagerTest}.
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.R)
     fun testRegisterBestMatchingNetworkCallback() {
         // Register best matching network callback with additional condition that will be
         // exercised later. This assumes the test network agent has NOT_VCN_MANAGED in it and
         // does not have NET_CAPABILITY_TEMPORARILY_NOT_METERED.
         val bestMatchingCb = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
-        registerBestMatchingNetworkCallback(NetworkRequest.Builder()
+        registerBestMatchingNetworkCallback(
+            NetworkRequest.Builder()
                 .clearCapabilities()
                 .addTransportType(TRANSPORT_TEST)
                 .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
-                .build(), bestMatchingCb, mHandlerThread.threadHandler)
+                .build(),
+            bestMatchingCb,
+            mHandlerThread.threadHandler
+        )
 
         val (agent1, _) = createConnectedNetworkAgent(specifier = "AGENT-1")
         bestMatchingCb.expectAvailableThenValidatedCallbacks(agent1.network!!)
@@ -1296,8 +1346,10 @@
         }
 
         fun assertNoCallback() {
-            assertNull(history.poll(NO_CALLBACK_TIMEOUT),
-                    "Callback received")
+            assertNull(
+                history.poll(NO_CALLBACK_TIMEOUT),
+                "Callback received"
+            )
         }
     }
 
@@ -1312,7 +1364,8 @@
 
     private fun setupForQosDatagram() = setupForQosCallbackTest {
         agent: TestableNetworkAgent -> DatagramSocket(
-            InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
+            InetSocketAddress(InetAddress.getLoopbackAddress(), 0)
+        )
             .also { assertNotNull(agent.network?.bindSocket(it)) }
     }
 
@@ -1345,7 +1398,8 @@
 
                 assertFailsWith<QosCallbackRegistrationException>(
                         "The same callback cannot be " +
-                        "registered more than once without first being unregistered") {
+                        "registered more than once without first being unregistered"
+                ) {
                     mCM.registerQosCallback(info, executor, qosCallback)
                 }
             } finally {
@@ -1438,8 +1492,10 @@
                 qosCallback.expectCallback<OnQosSessionAvailable>()
 
                 // Check that onError is coming through correctly
-                agent.sendQosCallbackError(callbackId,
-                        QosCallbackException.EX_TYPE_FILTER_NOT_SUPPORTED)
+                agent.sendQosCallbackError(
+                    callbackId,
+                    QosCallbackException.EX_TYPE_FILTER_NOT_SUPPORTED
+                )
                 qosCallback.expectCallback<OnError> {
                     it.ex.cause is UnsupportedOperationException
                 }
@@ -1534,17 +1590,24 @@
         val remoteAddresses = ArrayList<InetSocketAddress>()
         remoteAddresses.add(InetSocketAddress(REMOTE_ADDRESS, 80))
         return EpsBearerQosSessionAttributes(
-                qci, 2, 3, 4, 5,
-                remoteAddresses
+            qci,
+            2,
+            3,
+            4,
+            5,
+            remoteAddresses
         )
     }
 
-    fun sendAndExpectUdpPacket(net: Network,
-                               reader: PollPacketReader, iface: TestNetworkInterface) {
+    fun sendAndExpectUdpPacket(
+        net: Network,
+        reader: PollPacketReader,
+        iface: TestNetworkInterface
+    ) {
         val s = Os.socket(AF_INET6, SOCK_DGRAM, 0)
         net.bindSocket(s)
         val content = ByteArray(16)
-        Random().nextBytes(content)
+        Random.nextBytes(content)
         Os.sendto(s, ByteBuffer.wrap(content), 0, REMOTE_ADDRESS, 7 /* port */)
         val match = reader.poll(DEFAULT_TIMEOUT_MS) {
             val udpStart = IPV6_HEADER_LEN + UDP_HEADER_LEN
@@ -1553,8 +1616,11 @@
                     it[IPV6_PROTOCOL_OFFSET].toInt() == IPPROTO_UDP &&
                     Arrays.equals(content, it.copyOfRange(udpStart, udpStart + content.size))
         }
-        assertNotNull(match, "Did not receive matching packet on ${iface.interfaceName} " +
-                " after ${DEFAULT_TIMEOUT_MS}ms")
+        assertNotNull(
+            match,
+            "Did not receive matching packet on ${iface.interfaceName} " +
+                " after ${DEFAULT_TIMEOUT_MS}ms"
+        )
     }
 
     fun createInterfaceAndReader(): Triple<TestNetworkInterface, PollPacketReader, LinkProperties> {
@@ -1639,16 +1705,25 @@
 
         // Connect a third network. Because network1 is awaiting replacement, network3 is preferred
         // as soon as it validates (until then, it is outscored by network1).
-        // The fact that the first events seen by matchAllCallback is the connection of network3
+        // The fact that the first event seen by matchAllCallback is the connection of network3
         // implicitly ensures that no callbacks are sent since network1 was lost.
         val (agent3, network3) = connectNetwork(lp = lp)
-        matchAllCallback.expectAvailableThenValidatedCallbacks(network3)
-        testCallback.expectAvailableDoubleValidatedCallbacks(network3)
-        sendAndExpectUdpPacket(network3, reader, iface)
-
-        // As soon as the replacement arrives, network1 is disconnected.
-        // Check that this happens before the replacement timeout (5 seconds) fires.
-        matchAllCallback.expect<Lost>(network1, 2_000 /* timeoutMs */)
+        if (SHOULD_CREATE_NETWORKS_IMMEDIATELY) {
+            // This is the correct sequence of events.
+            matchAllCallback.expectAvailableCallbacks(network3, validated = false)
+            matchAllCallback.expect<Lost>(network1, 2_000 /* timeoutMs */)
+            matchAllCallback.expectCaps(network3) { it.hasCapability(NET_CAPABILITY_VALIDATED) }
+            sendAndExpectUdpPacket(network3, reader, iface)
+            testCallback.expectAvailableDoubleValidatedCallbacks(network3)
+        } else {
+            // This is incorrect and fixed by the "create networks immediately" feature
+            matchAllCallback.expectAvailableThenValidatedCallbacks(network3)
+            testCallback.expectAvailableDoubleValidatedCallbacks(network3)
+            sendAndExpectUdpPacket(network3, reader, iface)
+            // As soon as the replacement arrives, network1 is disconnected.
+            // Check that this happens before the replacement timeout (5 seconds) fires.
+            matchAllCallback.expect<Lost>(network1, 2_000 /* timeoutMs */)
+        }
         agent1.expectCallback<OnNetworkUnwanted>()
 
         // Test lingering:
@@ -1696,7 +1771,7 @@
         val callback = TestableNetworkCallback()
         requestNetwork(makeTestNetworkRequest(specifier = specifier6), callback)
         val agent6 = createNetworkAgent(specifier = specifier6)
-        val network6 = agent6.register()
+        agent6.register()
         if (SHOULD_CREATE_NETWORKS_IMMEDIATELY) {
             agent6.expectCallback<OnNetworkCreated>()
         } else {
@@ -1750,8 +1825,10 @@
         assertNotNull(wifiSpecifier)
         assertTrue(wifiSpecifier is EthernetNetworkSpecifier)
 
-        val wifiNc = makeTestNetworkCapabilities(wifiSpecifier.interfaceName,
-                intArrayOf(TRANSPORT_WIFI))
+        val wifiNc = makeTestNetworkCapabilities(
+            wifiSpecifier.interfaceName,
+            intArrayOf(TRANSPORT_WIFI)
+        )
         wifiAgent.sendNetworkCapabilities(wifiNc)
         val wifiLp = mCM.getLinkProperties(wifiNetwork)!!
         val newRoute = RouteInfo(IpPrefix("192.0.2.42/24"))
@@ -1764,8 +1841,19 @@
 
         val (newWifiAgent, newWifiNetwork) = connectNetwork(TRANSPORT_WIFI)
         testCallback.expectAvailableCallbacks(newWifiNetwork, validated = true)
-        matchAllCallback.expectAvailableThenValidatedCallbacks(newWifiNetwork)
-        matchAllCallback.expect<Lost>(wifiNetwork)
+        if (SHOULD_CREATE_NETWORKS_IMMEDIATELY) {
+            // This is the correct sequence of events
+            matchAllCallback.expectAvailableCallbacks(newWifiNetwork, validated = false)
+            matchAllCallback.expect<Lost>(wifiNetwork)
+            matchAllCallback.expectCaps(newWifiNetwork) {
+                it.hasCapability(NET_CAPABILITY_VALIDATED)
+            }
+        } else {
+            // When networks are not created immediately, the sequence is slightly incorrect
+            // and instead is as follows
+            matchAllCallback.expectAvailableThenValidatedCallbacks(newWifiNetwork)
+            matchAllCallback.expect<Lost>(wifiNetwork)
+        }
         wifiAgent.expectCallback<OnNetworkUnwanted>()
         testCallback.expect<CapabilitiesChanged>(newWifiNetwork)
 
@@ -1822,8 +1910,14 @@
         val nc = makeTestNetworkCapabilities(ifName, transports).also {
             if (transports.contains(TRANSPORT_VPN)) {
                 val sessionId = "NetworkAgentTest-${Process.myPid()}"
-                it.setTransportInfo(VpnTransportInfo(VpnManager.TYPE_VPN_PLATFORM, sessionId,
-                    /*bypassable=*/ false, /*longLivedTcpConnectionsExpensive=*/ false))
+                it.setTransportInfo(VpnTransportInfo(
+                    VpnManager.TYPE_VPN_PLATFORM,
+                    sessionId,
+                    /*bypassable=*/
+                    false,
+                    /*longLivedTcpConnectionsExpensive=*/
+                    false
+                ))
                 it.underlyingNetworks = listOf()
             }
         }
@@ -1868,9 +1962,11 @@
         listenCallback.expect<Available>(network)
 
         requestCallback.expect<CapabilitiesChanged>(network) { it.caps.hasCapability(
-            NET_CAPABILITY_TEMPORARILY_NOT_METERED) }
+            NET_CAPABILITY_TEMPORARILY_NOT_METERED
+        ) }
         listenCallback.expect<CapabilitiesChanged>(network) { it.caps.hasCapability(
-            NET_CAPABILITY_TEMPORARILY_NOT_METERED) }
+            NET_CAPABILITY_TEMPORARILY_NOT_METERED
+        ) }
 
         requestCallback.expect<LinkPropertiesChanged>(network) { it.lp.equals(lp) }
         listenCallback.expect<LinkPropertiesChanged>(network) { it.lp.equals(lp) }
@@ -1896,7 +1992,8 @@
     fun testNativeNetworkCreation_PhysicalNetwork() {
         doTestNativeNetworkCreation(
                 expectCreatedImmediately = SHOULD_CREATE_NETWORKS_IMMEDIATELY,
-                intArrayOf(TRANSPORT_CELLULAR))
+                intArrayOf(TRANSPORT_CELLULAR)
+        )
     }
 
     @Test
@@ -1904,26 +2001,19 @@
         // VPN networks are always created as soon as the agent is registered.
         doTestNativeNetworkCreation(expectCreatedImmediately = true, intArrayOf(TRANSPORT_VPN))
     }
-}
 
-// Subclasses of CarrierPrivilegesCallback can't be inline, or they'll be compiled as
-// inner classes of the test class and will fail resolution on R as the test harness
-// uses reflection to list all methods and classes
-class PrivilegeWaiterCallback(private val cv: ConditionVariable) :
-        CarrierPrivilegesCallback {
-    var hasPrivilege = false
-    override fun onCarrierPrivilegesChanged(p: MutableSet<String>, uids: MutableSet<Int>) {
-        hasPrivilege = uids.contains(Process.myUid())
-        cv.open()
+    @Test(expected = IllegalStateException::class)
+    fun testRegisterAgain() {
+        val agent = createNetworkAgent()
+        agent.register()
+        agent.unregister()
+        agent.register()
     }
-}
 
-class CarrierServiceChangedWaiterCallback(private val cv: ConditionVariable) :
-        CarrierPrivilegesCallback {
-    var pkgName: String? = null
-    override fun onCarrierPrivilegesChanged(p: MutableSet<String>, u: MutableSet<Int>) {}
-    override fun onCarrierServiceChanged(pkgName: String?, uid: Int) {
-        this.pkgName = pkgName
-        cv.open()
+    @Test
+    fun testUnregisterBeforeRegister() {
+        // For backward compatibility, this shouldn't crash.
+        val agent = createNetworkAgent()
+        agent.unregister()
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
index ff10e1a..2fb140a 100644
--- a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
@@ -16,6 +16,7 @@
 
 package android.net.cts;
 
+import static android.net.ConnectivityManager.TYPE_NONE;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOTA;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
@@ -32,6 +33,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
 import static com.google.common.truth.Truth.assertThat;
 
 import static junit.framework.Assert.fail;
@@ -41,6 +43,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeTrue;
 
@@ -101,6 +104,16 @@
         }
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    @Test
+    public void testParceling() {
+        NetworkCapabilities nc = new NetworkCapabilities.Builder().build();
+        NetworkRequest request = new NetworkRequest(nc, TYPE_NONE, 42 /* rId */,
+                NetworkRequest.Type.RESERVATION);
+
+        assertParcelingIsLossless(request);
+    }
+
     @Test
     public void testCapabilities() {
         assertTrue(new NetworkRequest.Builder().addCapability(NET_CAPABILITY_MMS).build()
@@ -130,7 +143,7 @@
         verifyNoCapabilities(builder.build());
     }
 
-    @Test @IgnoreUpTo(Build.VERSION_CODES.Q)
+    @Test
     public void testTemporarilyNotMeteredCapability() {
         assertTrue(new NetworkRequest.Builder()
                 .addCapability(NET_CAPABILITY_TEMPORARILY_NOT_METERED).build()
@@ -157,7 +170,6 @@
     }
 
     @Test
-    @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testSpecifier() {
         assertNull(new NetworkRequest.Builder().build().getNetworkSpecifier());
         final WifiNetworkSpecifier specifier = new WifiNetworkSpecifier.Builder()
@@ -192,7 +204,6 @@
     }
 
     @Test
-    @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testRequestorPackageName() {
         assertNull(new NetworkRequest.Builder().build().getRequestorPackageName());
         final String pkgName = "android.net.test";
@@ -216,7 +227,6 @@
     }
 
     @Test
-    @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testCanBeSatisfiedBy() {
         final LocalNetworkSpecifier specifier1 = new LocalNetworkSpecifier(1234 /* id */);
         final LocalNetworkSpecifier specifier2 = new LocalNetworkSpecifier(5678 /* id */);
@@ -284,7 +294,6 @@
     }
 
     @Test
-    @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testInvariantInCanBeSatisfiedBy() {
         // Test invariant that result of NetworkRequest.canBeSatisfiedBy() should be the same with
         // NetworkCapabilities.satisfiedByNetworkCapabilities().
@@ -388,7 +397,7 @@
                 otherUidsRequest.canBeSatisfiedBy(ncWithOtherUid));
     }
 
-    @Test @IgnoreUpTo(Build.VERSION_CODES.Q)
+    @Test
     public void testRequestorUid() {
         final NetworkCapabilities nc = new NetworkCapabilities();
         // Verify default value is INVALID_UID
@@ -558,4 +567,43 @@
                 .setBssidPattern(ARBITRARY_ADDRESS, ARBITRARY_ADDRESS)
                 .build();
     }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testNetworkReservation() {
+        final NetworkCapabilities nc = new NetworkCapabilities();
+        final NetworkCapabilities blanketOffer = new NetworkCapabilities(nc);
+        blanketOffer.setReservationId(NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS);
+        final NetworkCapabilities specificOffer = new NetworkCapabilities(nc);
+        specificOffer.setReservationId(42);
+        final NetworkCapabilities otherSpecificOffer = new NetworkCapabilities(nc);
+        otherSpecificOffer.setReservationId(43);
+        final NetworkCapabilities regularOffer = new NetworkCapabilities(nc);
+
+        final NetworkRequest reservationNR = new NetworkRequest(new NetworkCapabilities(nc),
+                TYPE_NONE, 42 /* rId */, NetworkRequest.Type.RESERVATION);
+        final NetworkRequest requestNR = new NetworkRequest(new NetworkCapabilities(nc),
+                TYPE_NONE, 42 /* rId */, NetworkRequest.Type.REQUEST);
+
+        assertTrue(reservationNR.canBeSatisfiedBy(blanketOffer));
+        assertTrue(reservationNR.canBeSatisfiedBy(specificOffer));
+        assertFalse(reservationNR.canBeSatisfiedBy(otherSpecificOffer));
+        assertFalse(reservationNR.canBeSatisfiedBy(regularOffer));
+
+        assertFalse(requestNR.canBeSatisfiedBy(blanketOffer));
+        assertTrue(requestNR.canBeSatisfiedBy(specificOffer));
+        assertTrue(requestNR.canBeSatisfiedBy(otherSpecificOffer));
+        assertTrue(requestNR.canBeSatisfiedBy(regularOffer));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testNetworkRequest_throwsWhenPassingCapsWithReservationId() {
+        final NetworkCapabilities capsWithResId = new NetworkCapabilities();
+        capsWithResId.setReservationId(42);
+
+        assertThrows(IllegalArgumentException.class, () -> {
+            new NetworkRequest(capsWithResId, TYPE_NONE, 42 /* rId */, NetworkRequest.Type.REQUEST);
+        });
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
new file mode 100644
index 0000000..f43b927
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2025 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.cts
+
+import android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.Manifest.permission.NETWORK_SETTINGS
+import android.bluetooth.BluetoothManager
+import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE
+import android.net.ConnectivityManager
+import android.net.L2capNetworkSpecifier
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
+import android.net.L2capNetworkSpecifier.ROLE_SERVER
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS
+import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_TEST
+import android.net.NetworkProvider
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.platform.test.annotations.AppModeFull
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Reserved
+import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.TestableNetworkOfferCallback
+import com.android.testutils.pollingCheck
+import com.android.testutils.runAsShell
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TAG = "NetworkReservationTest"
+
+private val NETWORK_SCORE = NetworkScore.Builder().build()
+private val ETHERNET_CAPS = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_CONGESTED)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+private val BLANKET_CAPS = NetworkCapabilities(ETHERNET_CAPS).apply {
+    reservationId = RES_ID_MATCH_ALL_RESERVATIONS
+}
+private val ETHERNET_REQUEST = NetworkRequest.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+private const val TIMEOUT_MS = 5_000L
+private const val NO_CB_TIMEOUT_MS = 200L
+
+// TODO: integrate with CSNetworkReservationTest and move to common tests.
+@AppModeFull(reason = "CHANGE_NETWORK_STATE, MANAGE_TEST_NETWORKS not grantable to instant apps")
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class NetworkReservationTest {
+    private val context = InstrumentationRegistry.getInstrumentation().context
+    private val cm = context.getSystemService(ConnectivityManager::class.java)!!
+    private val handlerThread = HandlerThread("$TAG handler thread").apply { start() }
+    private val handler = Handler(handlerThread.looper)
+    private val provider = NetworkProvider(context, handlerThread.looper, TAG)
+
+    private val registeredCallbacks = ArrayList<TestableNetworkCallback>()
+    private val bm = context.getSystemService(BluetoothManager::class.java)!!
+    private var disableBluetoothInTearDown = false
+
+    @Before
+    fun setUp() {
+        runAsShell(NETWORK_SETTINGS) {
+            cm.registerNetworkProvider(provider)
+        }
+    }
+
+    private fun enableBluetooth() {
+        val adapter = bm.adapter
+        assertNotNull(adapter)
+        if (adapter.isEnabled()) return
+
+        runShellCommandOrThrow("svc bluetooth enable")
+        val bluetoothEnabled = pollingCheck(TIMEOUT_MS) {
+            adapter.isEnabled()
+        }
+        assertTrue(bluetoothEnabled)
+        // Only disable Bluetooth in tear down when it hasn't already been enabled.
+        disableBluetoothInTearDown = true
+    }
+
+    private fun disableBluetooth() {
+        // adapter can't actually be null here, because this function does not run unless
+        // disableBluetoothInTearDown is true. Just in case, refrain from throwing an exception in
+        // tearDown.
+        val adapter = bm.adapter
+        if (adapter == null) return
+
+        runShellCommandOrThrow("svc bluetooth disable")
+        // Wait for #isEnabled() to return false; ignore failures.
+        pollingCheck(TIMEOUT_MS) {
+            !adapter.isEnabled()
+        }
+    }
+
+    @After
+    fun tearDown() {
+        registeredCallbacks.forEach { cm.unregisterNetworkCallback(it) }
+        runAsShell(NETWORK_SETTINGS) {
+            // unregisterNetworkProvider unregisters all associated NetworkOffers.
+            cm.unregisterNetworkProvider(provider)
+        }
+        handlerThread.quitSafely()
+        handlerThread.join()
+
+        if (disableBluetoothInTearDown) {
+            disableBluetooth()
+        }
+    }
+
+    fun NetworkCapabilities.copyWithReservationId(resId: Int) = NetworkCapabilities(this).also {
+        it.reservationId = resId
+    }
+
+    fun reserveNetwork(nr: NetworkRequest): TestableNetworkCallback {
+        return TestableNetworkCallback().also {
+            cm.reserveNetwork(nr, handler, it)
+            registeredCallbacks.add(it)
+        }
+    }
+
+    @Test
+    fun testReserveNetwork() {
+        // register blanket offer
+        val blanketOffer = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        runAsShell(MANAGE_TEST_NETWORKS) {
+            provider.registerNetworkOffer(NETWORK_SCORE, BLANKET_CAPS, handler::post, blanketOffer)
+        }
+
+        val cb = reserveNetwork(ETHERNET_REQUEST)
+
+        // validate the reservation matches the blanket offer.
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        // bring up reserved reservation offer
+        val reservedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOffer = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        runAsShell(MANAGE_TEST_NETWORKS) {
+            provider.registerNetworkOffer(NETWORK_SCORE, reservedCaps, handler::post, reservedOffer)
+        }
+
+        // validate onReserved was sent to the app
+        val appObservedCaps = cb.expect<Reserved>().caps
+        assertEquals(reservedCaps, appObservedCaps)
+
+        // validate the reservation matches the reserved offer.
+        reservedOffer.expectOnNetworkNeeded(reservedCaps)
+
+        // reserved offer goes away
+        provider.unregisterNetworkOffer(reservedOffer)
+        cb.expect<Unavailable>()
+    }
+
+    @Test
+    fun testReserveL2capNetwork() {
+        assumeTrue(context.packageManager.hasSystemFeature(FEATURE_BLUETOOTH_LE))
+        enableBluetooth()
+
+        val l2capReservationSpecifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        val l2capRequest = NetworkRequest.Builder()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .removeCapability(NET_CAPABILITY_TRUSTED)
+                .removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
+                .setNetworkSpecifier(l2capReservationSpecifier)
+                .build()
+        val cb = runAsShell(CONNECTIVITY_USE_RESTRICTED_NETWORKS) {
+            reserveNetwork(l2capRequest)
+        }
+
+        val caps = cb.expect<Reserved>().caps
+        val reservedSpec = caps.networkSpecifier
+        assertTrue(reservedSpec is L2capNetworkSpecifier)
+        assertContains(0x80..0xFF, reservedSpec.psm, "PSM is outside of dynamic range")
+        assertEquals(HEADER_COMPRESSION_6LOWPAN, reservedSpec.headerCompression)
+        assertNull(reservedSpec.remoteAddress)
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index e3d7240..eb2dbf7 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -75,6 +75,7 @@
 import com.android.compatibility.common.util.SystemUtil;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.testutils.AutoReleaseNetworkCallbackRule;
+import com.android.testutils.ConnectivityDiagnosticsCollector;
 import com.android.testutils.ConnectivityModuleTest;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -95,8 +96,10 @@
 import java.net.UnknownHostException;
 import java.text.MessageFormat;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
@@ -711,27 +714,57 @@
         }
     }
 
-    class QueryResult {
-        public final int tag;
-        public final int state;
-        public final long total;
+    class QueryResults {
+        private static class QueryKey {
+            private final int mTag;
+            private final int mState;
 
-        QueryResult(int tag, int state, NetworkStats stats) {
-            this.tag = tag;
-            this.state = state;
-            total = getTotalAndAssertNotEmpty(stats, tag, state);
+            QueryKey(int tag, int state) {
+                this.mTag = tag;
+                this.mState = state;
+            }
+
+            @Override
+            public boolean equals(Object o) {
+                if (this == o) return true;
+                if (!(o instanceof QueryKey)) return false;
+
+                QueryKey queryKey = (QueryKey) o;
+                return mTag == queryKey.mTag && mState == queryKey.mState;
+            }
+
+            @Override
+            public int hashCode() {
+                return Objects.hash(mTag, mState);
+            }
+
+            @Override
+            public String toString() {
+                return String.format("QueryKey(tag=%s, state=%s)", tagToString(mTag),
+                        stateToString(mState));
+            }
         }
 
-        public String toString() {
-            return String.format("QueryResult(tag=%s state=%s total=%d)",
-                    tagToString(tag), stateToString(state), total);
+        private final HashMap<QueryKey, Long> mSnapshot = new HashMap<>();
+
+        public long get(int tag, int state) {
+            // Expect all results are stored before access.
+            return Objects.requireNonNull(mSnapshot.get(new QueryKey(tag, state)));
+        }
+
+        public void put(int tag, int state, long total) {
+            mSnapshot.put(new QueryKey(tag, state), total);
         }
     }
 
-    private NetworkStats getNetworkStatsForTagState(int i, int tag, int state) {
-        return mNsm.queryDetailsForUidTagState(
+    private long getTotalForTagState(int i, int tag, int state, boolean assertNotEmpty,
+            long startTime, long endTime) {
+        final NetworkStats stats = mNsm.queryDetailsForUidTagState(
                 mNetworkInterfacesToTest[i].getNetworkType(), getSubscriberId(i),
-                mStartTime, mEndTime, Process.myUid(), tag, state);
+                startTime, endTime, Process.myUid(), tag, state);
+        final long total = getTotal(stats, tag, state, assertNotEmpty, startTime, endTime);
+        stats.close();
+        return total;
     }
 
     private void assertWithinPercentage(String msg, long expected, long actual, int percentage) {
@@ -742,92 +775,103 @@
         assertTrue(msg, upperBound >= actual);
     }
 
-    private void assertAlmostNoUnexpectedTraffic(NetworkStats result, int expectedTag,
+    private void assertAlmostNoUnexpectedTraffic(long total, int expectedTag,
             int expectedState, long maxUnexpected) {
-        long total = 0;
-        NetworkStats.Bucket bucket = new NetworkStats.Bucket();
-        while (result.hasNextBucket()) {
-            assertTrue(result.getNextBucket(bucket));
-            total += bucket.getRxBytes() + bucket.getTxBytes();
-        }
         if (total <= maxUnexpected) return;
 
-        fail(String.format("More than %d bytes of traffic when querying for "
-                + "tag %s state %s. Last bucket: uid=%d tag=%s state=%s bytes=%d/%d",
-                maxUnexpected, tagToString(expectedTag), stateToString(expectedState),
-                bucket.getUid(), tagToString(bucket.getTag()), stateToString(bucket.getState()),
-                bucket.getRxBytes(), bucket.getTxBytes()));
+        fail(String.format("More than %d bytes of traffic when querying for tag %s state %s.",
+                maxUnexpected, tagToString(expectedTag), stateToString(expectedState)));
     }
 
+    @ConnectivityDiagnosticsCollector.CollectTcpdumpOnFailure
     @Test
     public void testUidTagStateDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
-            // Relatively large tolerance to accommodate for history bucket size.
-            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
-            NetworkStats result = null;
-            try {
-                int currentState = isInForeground() ? STATE_FOREGROUND : STATE_DEFAULT;
-                int otherState = (currentState == STATE_DEFAULT) ? STATE_FOREGROUND : STATE_DEFAULT;
 
-                int[] tagsWithTraffic = {NETWORK_TAG, TAG_NONE};
-                int[] statesWithTraffic = {currentState, STATE_ALL};
-                ArrayList<QueryResult> resultsWithTraffic = new ArrayList<>();
+            int currentState = isInForeground() ? STATE_FOREGROUND : STATE_DEFAULT;
+            int otherState = (currentState == STATE_DEFAULT) ? STATE_FOREGROUND : STATE_DEFAULT;
 
-                int[] statesWithNoTraffic = {otherState};
-                int[] tagsWithNoTraffic = {NETWORK_TAG + 1};
-                ArrayList<QueryResult> resultsWithNoTraffic = new ArrayList<>();
+            final List<Integer> statesWithTraffic = List.of(currentState, STATE_ALL);
+            final List<Integer> statesWithNoTraffic = List.of(otherState);
+            final ArrayList<Integer> allStates = new ArrayList<>();
+            allStates.addAll(statesWithTraffic);
+            allStates.addAll(statesWithNoTraffic);
 
-                // Expect to see traffic when querying for any combination of a tag in
-                // tagsWithTraffic and a state in statesWithTraffic.
-                for (int tag : tagsWithTraffic) {
-                    for (int state : statesWithTraffic) {
-                        result = getNetworkStatsForTagState(i, tag, state);
-                        resultsWithTraffic.add(new QueryResult(tag, state, result));
-                        result.close();
-                        result = null;
+            final List<Integer> tagsWithTraffic = List.of(NETWORK_TAG, TAG_NONE);
+            final List<Integer> tagsWithNoTraffic = List.of(NETWORK_TAG + 1);
+            final ArrayList<Integer> allTags = new ArrayList<>();
+            allTags.addAll(tagsWithTraffic);
+            allTags.addAll(tagsWithNoTraffic);
+
+            // Relatively large tolerance to accommodate for history bucket size,
+            // and covering the entire test duration.
+            final long now = System.currentTimeMillis();
+            final long startTime = now - LONG_TOLERANCE;
+            final long endTime = now + LONG_TOLERANCE;
+
+            // Collect a baseline before generating network traffic.
+            QueryResults baseline = new QueryResults();
+            final ArrayList<String> logNonEmptyBaseline = new ArrayList<>();
+            for (int tag : allTags) {
+                for (int state : allStates) {
+                    final long total = getTotalForTagState(i, tag, state, false,
+                            startTime, endTime);
+                    baseline.put(tag, state, total);
+                    if (total > 0) {
+                        logNonEmptyBaseline.add(
+                                new QueryResults.QueryKey(tag, state) + "=" + total);
                     }
                 }
-
-                // Expect that the results are within a few percentage points of each other.
-                // This is ensures that FIN retransmits after the transfer is complete don't cause
-                // the test to be flaky. The test URL currently returns just over 100k so this
-                // should not be too noisy. It also ensures that the traffic sent by the test
-                // harness, which is untagged, won't cause a failure.
-                long firstTotal = resultsWithTraffic.get(0).total;
-                for (QueryResult queryResult : resultsWithTraffic) {
-                    assertWithinPercentage(queryResult + "", firstTotal, queryResult.total, 16);
-                }
-
-                // Expect to see no traffic when querying for any tag in tagsWithNoTraffic or any
-                // state in statesWithNoTraffic.
-                for (int tag : tagsWithNoTraffic) {
-                    for (int state : statesWithTraffic) {
-                        result = getNetworkStatsForTagState(i, tag, state);
-                        assertAlmostNoUnexpectedTraffic(result, tag, state, firstTotal / 100);
-                        result.close();
-                        result = null;
-                    }
-                }
-                for (int tag : tagsWithTraffic) {
-                    for (int state : statesWithNoTraffic) {
-                        result = getNetworkStatsForTagState(i, tag, state);
-                        assertAlmostNoUnexpectedTraffic(result, tag, state, firstTotal / 100);
-                        result.close();
-                        result = null;
-                    }
-                }
-            } finally {
-                if (result != null) {
-                    result.close();
-                }
             }
+            // TODO: Remove debug log for b/368624224.
+            if (logNonEmptyBaseline.size() > 0) {
+                Log.v(LOG_TAG, "Baseline=" + logNonEmptyBaseline);
+            }
+
+            // Generate some traffic and release the network.
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
+
+            QueryResults results = new QueryResults();
+            // Collect results for all combinations of tags and states.
+            for (int tag : allTags) {
+                for (int state : allStates) {
+                    final boolean assertNotEmpty = tagsWithTraffic.contains(tag)
+                            && statesWithTraffic.contains(state);
+                    final long total = getTotalForTagState(i, tag, state, assertNotEmpty,
+                            startTime, endTime) - baseline.get(tag, state);
+                    results.put(tag, state, total);
+                }
+            }
+
+            // Expect that the results are within a few percentage points of each other.
+            // This is ensures that FIN retransmits after the transfer is complete don't cause
+            // the test to be flaky. The test URL currently returns just over 100k so this
+            // should not be too noisy. It also ensures that the traffic sent by the test
+            // harness, which is untagged, won't cause a failure.
+            long totalOfNetworkTagAndCurrentState = results.get(NETWORK_TAG, currentState);
+            for (int tag : allTags) {
+                for (int state : allStates) {
+                    final long result = results.get(tag, state);
+                    final String queryKeyStr = new QueryResults.QueryKey(tag, state).toString();
+                    if (tagsWithTraffic.contains(tag) && statesWithTraffic.contains(state)) {
+                        assertWithinPercentage(queryKeyStr,
+                                totalOfNetworkTagAndCurrentState, result, 16);
+                    } else {
+                        // Expect to see no traffic when querying for any combination with tag
+                        // in tagsWithNoTraffic or any state in statesWithNoTraffic.
+                        assertAlmostNoUnexpectedTraffic(result, tag, state,
+                                totalOfNetworkTagAndCurrentState / 100);
+                    }
+                }
+            }
+
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "deny");
             try {
-                result = mNsm.queryDetailsForUidTag(
+                mNsm.queryDetailsForUidTag(
                         mNetworkInterfacesToTest[i].getNetworkType(), getSubscriberId(i),
                         mStartTime, mEndTime, Process.myUid(), NETWORK_TAG);
                 fail("negative testUidDetails fails: no exception thrown.");
@@ -900,7 +944,7 @@
         }
     }
 
-    private String tagToString(Integer tag) {
+    private static String tagToString(Integer tag) {
         if (tag == null) return "null";
         switch (tag) {
             case TAG_NONE:
@@ -910,7 +954,7 @@
         }
     }
 
-    private String stateToString(Integer state) {
+    private static String stateToString(Integer state) {
         if (state == null) return "null";
         switch (state) {
             case STATE_ALL:
@@ -923,8 +967,8 @@
         throw new IllegalArgumentException("Unknown state " + state);
     }
 
-    private long getTotalAndAssertNotEmpty(NetworkStats result, Integer expectedTag,
-            Integer expectedState) {
+    private long getTotal(NetworkStats result, Integer expectedTag,
+            Integer expectedState, boolean assertNotEmpty, long startTime, long endTime) {
         assertTrue(result != null);
         NetworkStats.Bucket bucket = new NetworkStats.Bucket();
         long totalTxPackets = 0;
@@ -933,7 +977,7 @@
         long totalRxBytes = 0;
         while (result.hasNextBucket()) {
             assertTrue(result.getNextBucket(bucket));
-            assertTimestamps(bucket);
+            assertTimestamps(bucket, startTime, endTime);
             if (expectedTag != null) assertEquals(bucket.getTag(), (int) expectedTag);
             if (expectedState != null) assertEquals(bucket.getState(), (int) expectedState);
             assertEquals(bucket.getMetered(), METERED_ALL);
@@ -949,23 +993,29 @@
         assertFalse(result.getNextBucket(bucket));
         String msg = String.format("uid %d tag %s state %s",
                 Process.myUid(), tagToString(expectedTag), stateToString(expectedState));
-        assertTrue("No Rx bytes usage for " + msg, totalRxBytes > 0);
-        assertTrue("No Rx packets usage for " + msg, totalRxPackets > 0);
-        assertTrue("No Tx bytes usage for " + msg, totalTxBytes > 0);
-        assertTrue("No Tx packets usage for " + msg, totalTxPackets > 0);
+        if (assertNotEmpty) {
+            assertTrue("No Rx bytes usage for " + msg, totalRxBytes > 0);
+            assertTrue("No Rx packets usage for " + msg, totalRxPackets > 0);
+            assertTrue("No Tx bytes usage for " + msg, totalTxBytes > 0);
+            assertTrue("No Tx packets usage for " + msg, totalTxPackets > 0);
+        }
 
         return totalRxBytes + totalTxBytes;
     }
 
     private long getTotalAndAssertNotEmpty(NetworkStats result) {
-        return getTotalAndAssertNotEmpty(result, null, STATE_ALL);
+        return getTotal(result, null, STATE_ALL, true /*assertEmpty*/, mStartTime, mEndTime);
     }
 
     private void assertTimestamps(final NetworkStats.Bucket bucket) {
+        assertTimestamps(bucket, mStartTime, mEndTime);
+    }
+
+    private void assertTimestamps(final NetworkStats.Bucket bucket, long startTime, long endTime) {
         assertTrue("Start timestamp " + bucket.getStartTimeStamp() + " is less than "
-                + mStartTime, bucket.getStartTimeStamp() >= mStartTime);
+                + startTime, bucket.getStartTimeStamp() >= startTime);
         assertTrue("End timestamp " + bucket.getEndTimeStamp() + " is greater than "
-                + mEndTime, bucket.getEndTimeStamp() <= mEndTime);
+                + endTime, bucket.getEndTimeStamp() <= endTime);
     }
 
     private static class TestUsageCallback extends NetworkStatsManager.UsageCallback {
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index 7fc8863..ee31f1a 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -22,14 +22,10 @@
 import android.net.ConnectivityManager.NetworkCallback
 import android.net.DnsResolver
 import android.net.InetAddresses.parseNumericAddress
-import android.net.LinkAddress
-import android.net.LinkProperties
 import android.net.LocalSocket
 import android.net.LocalSocketAddress
 import android.net.MacAddress
 import android.net.Network
-import android.net.NetworkAgentConfig
-import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED
 import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
 import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
@@ -40,8 +36,11 @@
 import android.net.TestNetworkSpecifier
 import android.net.connectivity.ConnectivityCompatChanges
 import android.net.cts.util.CtsNetUtils
+import android.net.nsd.AdvertisingRequest
+import android.net.nsd.AdvertisingRequest.FLAG_SKIP_PROBING
 import android.net.nsd.DiscoveryRequest
 import android.net.nsd.NsdManager
+import android.net.nsd.NsdManager.PROTOCOL_DNS_SD
 import android.net.nsd.NsdServiceInfo
 import android.net.nsd.OffloadEngine
 import android.net.nsd.OffloadServiceInfo
@@ -50,16 +49,10 @@
 import android.os.HandlerThread
 import android.platform.test.annotations.AppModeFull
 import android.provider.DeviceConfig.NAMESPACE_TETHERING
-import android.system.ErrnoException
-import android.system.Os
-import android.system.OsConstants.AF_INET6
-import android.system.OsConstants.EADDRNOTAVAIL
-import android.system.OsConstants.ENETUNREACH
 import android.system.OsConstants.ETH_P_IPV6
 import android.system.OsConstants.IPPROTO_IPV6
 import android.system.OsConstants.IPPROTO_UDP
 import android.system.OsConstants.RT_SCOPE_LINK
-import android.system.OsConstants.SOCK_DGRAM
 import android.util.Log
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
@@ -98,12 +91,11 @@
 import com.android.testutils.NsdServiceInfoCallbackRecord.ServiceInfoCallbackEvent.ServiceUpdated
 import com.android.testutils.NsdServiceInfoCallbackRecord.ServiceInfoCallbackEvent.ServiceUpdatedLost
 import com.android.testutils.NsdServiceInfoCallbackRecord.ServiceInfoCallbackEvent.UnregisterCallbackSucceeded
+import com.android.testutils.PollPacketReader
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
-import com.android.testutils.PollPacketReader
 import com.android.testutils.TestDnsPacket
 import com.android.testutils.TestableNetworkAgent
-import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.assertEmpty
 import com.android.testutils.filters.CtsNetTestCasesMaxTargetSdk30
@@ -244,16 +236,12 @@
         val tnm = context.getSystemService(TestNetworkManager::class.java)!!
         val iface = tnm.createTapInterface()
         val cb = TestableNetworkCallback()
-        val testNetworkSpecifier = TestNetworkSpecifier(iface.interfaceName)
         cm.requestNetwork(
-            NetworkRequest.Builder()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(testNetworkSpecifier)
-                .build(),
+            TestableNetworkAgent.makeNetworkRequestForInterface(iface.interfaceName),
             cb
         )
-        val agent = registerTestNetworkAgent(iface.interfaceName)
+        val agent = TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+            iface.interfaceName, TIMEOUT_MS)
         val network = agent.network ?: fail("Registered agent should have a network")
 
         cb.eventuallyExpect<LinkPropertiesChanged>(TIMEOUT_MS) {
@@ -268,57 +256,6 @@
         return TestTapNetwork(iface, cb, agent, network)
     }
 
-    private fun registerTestNetworkAgent(ifaceName: String): TestableNetworkAgent {
-        val lp = LinkProperties().apply {
-            interfaceName = ifaceName
-        }
-        val agent = TestableNetworkAgent(
-            context,
-            handlerThread.looper,
-                NetworkCapabilities().apply {
-                    removeCapability(NET_CAPABILITY_TRUSTED)
-                    addTransportType(TRANSPORT_TEST)
-                    setNetworkSpecifier(TestNetworkSpecifier(ifaceName))
-                },
-            lp,
-            NetworkAgentConfig.Builder().build()
-        )
-        val network = agent.register()
-        agent.markConnected()
-        agent.expectCallback<OnNetworkCreated>()
-
-        // Wait until the link-local address can be used. Address flags are not available without
-        // elevated permissions, so check that bindSocket works.
-        PollingCheck.check("No usable v6 address on interface after $TIMEOUT_MS ms", TIMEOUT_MS) {
-            // To avoid race condition between socket connection succeeding and interface returning
-            // a non-empty address list. Verify that interface returns a non-empty list, before
-            // trying the socket connection.
-            if (NetworkInterface.getByName(ifaceName).interfaceAddresses.isEmpty()) {
-                return@check false
-            }
-
-            val sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP)
-            tryTest {
-                network.bindSocket(sock)
-                Os.connect(sock, parseNumericAddress("ff02::fb%$ifaceName"), 12345)
-                true
-            }.catch<ErrnoException> {
-                if (it.errno != ENETUNREACH && it.errno != EADDRNOTAVAIL) {
-                    throw it
-                }
-                false
-            } cleanup {
-                Os.close(sock)
-            }
-        }
-
-        lp.setLinkAddresses(NetworkInterface.getByName(ifaceName).interfaceAddresses.map {
-            LinkAddress(it.address, it.networkPrefixLength.toInt())
-        })
-        agent.sendLinkProperties(lp)
-        return agent
-    }
-
     private fun makeTestServiceInfo(network: Network? = null) = NsdServiceInfo().also {
         it.serviceType = serviceType
         it.serviceName = serviceName
@@ -573,7 +510,9 @@
             assertEquals(testNetwork1.network, serviceLost.serviceInfo.network)
 
             val newAgent = runAsShell(MANAGE_TEST_NETWORKS) {
-                registerTestNetworkAgent(testNetwork1.iface.interfaceName)
+                TestableNetworkAgent.createOnInterface(context, handlerThread.looper,
+                    testNetwork1.iface.interfaceName,
+                    TIMEOUT_MS)
             }
             val newNetwork = newAgent.network ?: fail("Registered agent should have a network")
             val serviceDiscovered3 = discoveryRecord.expectCallback<ServiceFound>()
@@ -2629,6 +2568,49 @@
         verifyCachedServicesRemoval(isCachedServiceRemoved = true)
     }
 
+    @Test
+    fun testSkipProbing() {
+        val si = makeTestServiceInfo(testNetwork1.network)
+        val request = AdvertisingRequest.Builder(si)
+            .setFlags(FLAG_SKIP_PROBING)
+            .build()
+        assertEquals(FLAG_SKIP_PROBING, request.flags)
+        assertEquals(PROTOCOL_DNS_SD, request.protocolType)
+        assertEquals(si.serviceName, request.serviceInfo.serviceName)
+
+        // Register service on testNetwork1
+        val registrationRecord = NsdRegistrationRecord()
+        nsdManager.registerService(request, { it.run() }, registrationRecord)
+        registrationRecord.expectCallback<ServiceRegistered>()
+        val packetReader = makePacketReader()
+
+        tryTest {
+            val srvRecordName = "$serviceName.$serviceType.local"
+            // Look for either announcements or probes
+            val packet = packetReader.pollForMdnsPacket {
+                it.isProbeFor(srvRecordName) || it.isReplyFor(srvRecordName)
+            }
+            assertNotNull(packet, "Probe or announcement not received within timeout")
+            // The first packet should be an announcement, not a probe.
+            assertTrue("Found initial probes with NSD_ADVERTISING_SKIP_PROBING enabled",
+                packet.isReplyFor(srvRecordName))
+
+            // Force a conflict now that the service is getting announced
+            val conflictingAnnouncement = buildConflictingAnnouncement()
+            packetReader.sendResponse(conflictingAnnouncement)
+
+            // Expect to see probes now (RFC6762 9., service is reset to probing state)
+            assertNotNull(packetReader.pollForProbe(serviceName, serviceType),
+                "Probe not received within timeout after conflict")
+        } cleanupStep {
+            nsdManager.unregisterService(registrationRecord)
+            registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
+        }
+    }
+
     private fun hasServiceTypeClientsForNetwork(clients: List<String>, network: Network): Boolean {
         return clients.any { client -> client.substring(
                 client.indexOf("network=") + "network=".length,
diff --git a/tests/cts/net/src/android/net/cts/TrafficStatsTest.java b/tests/cts/net/src/android/net/cts/TrafficStatsTest.java
index bd9e03c..f5198e3 100755
--- a/tests/cts/net/src/android/net/cts/TrafficStatsTest.java
+++ b/tests/cts/net/src/android/net/cts/TrafficStatsTest.java
@@ -24,6 +24,8 @@
 import android.util.Log;
 import android.util.Range;
 
+import com.android.testutils.ConnectivityDiagnosticsCollector;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -94,11 +96,12 @@
 
     long tcpPacketToIpBytes(long packetCount, long bytes) {
         // ip header + tcp header + data.
-        // Tcp header is mostly 32. Syn has different tcp options -> 40. Don't care.
+        // Tcp header is mostly 32. Syn has different tcp options -> 40.
         return packetCount * (20 + 32 + bytes);
     }
 
     @AppModeFull(reason = "Socket cannot bind in instant app mode")
+    @ConnectivityDiagnosticsCollector.CollectTcpdumpOnFailure
     public void testTrafficStatsForLocalhost() throws IOException {
         final long mobileTxPacketsBefore = TrafficStats.getMobileTxPackets();
         final long mobileRxPacketsBefore = TrafficStats.getMobileRxPackets();
@@ -224,9 +227,15 @@
                 - uidTxDeltaPackets;
         final long deltaRxOtherPackets = (totalRxPacketsAfter - totalRxPacketsBefore)
                 - uidRxDeltaPackets;
-        if (deltaTxOtherPackets > 0 || deltaRxOtherPackets > 0) {
+        final long deltaTxOtherPktBytes = (totalTxBytesAfter - totalTxBytesBefore)
+                - uidTxDeltaBytes;
+        final long deltaRxOtherPktBytes  = (totalRxBytesAfter - totalRxBytesBefore)
+                - uidRxDeltaBytes;
+        if (deltaTxOtherPackets != 0 || deltaRxOtherPackets != 0
+                || deltaTxOtherPktBytes != 0 || deltaRxOtherPktBytes != 0) {
             Log.i(LOG_TAG, "lingering traffic data: " + deltaTxOtherPackets + "/"
-                    + deltaRxOtherPackets);
+                    + deltaRxOtherPackets + "/" + deltaTxOtherPktBytes
+                    + "/" + deltaRxOtherPktBytes);
         }
 
         // Check that the per-uid stats obtained from data profiling contain the expected values.
@@ -237,9 +246,9 @@
         final long pktBytes = tcpPacketToIpBytes(packetCount, byteCount);
         final long pktWithNoDataBytes = tcpPacketToIpBytes(packetCount, 0);
         final long minExpExtraPktBytes = tcpPacketToIpBytes(minExpectedExtraPackets, 0);
-        final long maxExpExtraPktBytes = tcpPacketToIpBytes(maxExpectedExtraPackets, 0);
-        final long deltaTxOtherPktBytes = tcpPacketToIpBytes(deltaTxOtherPackets, 0);
-        final long deltaRxOtherPktBytes  = tcpPacketToIpBytes(deltaRxOtherPackets, 0);
+        // Syn/syn-ack has different tcp options, make tcp header 40 for upper bound estimation.
+        final long maxExpExtraPktBytes = tcpPacketToIpBytes(maxExpectedExtraPackets, 8);
+
         assertInRange("txPackets detail", entry.txPackets, packetCount + minExpectedExtraPackets,
                 uidTxDeltaPackets);
         assertInRange("rxPackets detail", entry.rxPackets, packetCount + minExpectedExtraPackets,
@@ -257,32 +266,24 @@
         assertInRange("uidrxb", uidRxDeltaBytes, pktBytes + minExpExtraPktBytes,
                 pktBytes + pktWithNoDataBytes + maxExpExtraPktBytes + deltaRxOtherPktBytes);
         assertInRange("iftxp", ifaceTxDeltaPackets, packetCount + minExpectedExtraPackets,
-                packetCount + packetCount + maxExpectedExtraPackets);
+                packetCount + packetCount + maxExpectedExtraPackets + deltaTxOtherPackets);
         assertInRange("ifrxp", ifaceRxDeltaPackets, packetCount + minExpectedExtraPackets,
-                packetCount + packetCount + maxExpectedExtraPackets);
+                packetCount + packetCount + maxExpectedExtraPackets + deltaRxOtherPackets);
         assertInRange("iftxb", ifaceTxDeltaBytes, pktBytes + minExpExtraPktBytes,
-                pktBytes + pktWithNoDataBytes + maxExpExtraPktBytes);
+                pktBytes + pktWithNoDataBytes + maxExpExtraPktBytes + deltaTxOtherPktBytes);
         assertInRange("ifrxb", ifaceRxDeltaBytes, pktBytes + minExpExtraPktBytes,
-                pktBytes + pktWithNoDataBytes + maxExpExtraPktBytes);
+                pktBytes + pktWithNoDataBytes + maxExpExtraPktBytes + deltaRxOtherPktBytes);
 
         // Localhost traffic *does* count against total stats.
         // Check the total stats increased after test data transfer over localhost has been made.
-        assertTrue("ttxp: " + totalTxPacketsBefore + " -> " + totalTxPacketsAfter,
-                totalTxPacketsAfter >= totalTxPacketsBefore + uidTxDeltaPackets);
-        assertTrue("trxp: " + totalRxPacketsBefore + " -> " + totalRxPacketsAfter,
-                totalRxPacketsAfter >= totalRxPacketsBefore + uidRxDeltaPackets);
-        assertTrue("ttxb: " + totalTxBytesBefore + " -> " + totalTxBytesAfter,
-                totalTxBytesAfter >= totalTxBytesBefore + uidTxDeltaBytes);
-        assertTrue("trxb: " + totalRxBytesBefore + " -> " + totalRxBytesAfter,
-                totalRxBytesAfter >= totalRxBytesBefore + uidRxDeltaBytes);
-        assertTrue("iftxp: " + ifaceTxPacketsBefore + " -> " + ifaceTxPacketsAfter,
-                totalTxPacketsAfter >= totalTxPacketsBefore + ifaceTxDeltaPackets);
-        assertTrue("ifrxp: " + ifaceRxPacketsBefore + " -> " + ifaceRxPacketsAfter,
-                totalRxPacketsAfter >= totalRxPacketsBefore + ifaceRxDeltaPackets);
-        assertTrue("iftxb: " + ifaceTxBytesBefore + " -> " + ifaceTxBytesAfter,
-            totalTxBytesAfter >= totalTxBytesBefore + ifaceTxDeltaBytes);
-        assertTrue("ifrxb: " + ifaceRxBytesBefore + " -> " + ifaceRxBytesAfter,
-            totalRxBytesAfter >= totalRxBytesBefore + ifaceRxDeltaBytes);
+        assertInRange("ttxp", totalTxPacketsAfter,
+                totalTxPacketsBefore + packetCount + minExpectedExtraPackets, Long.MAX_VALUE);
+        assertInRange("trxp", totalRxPacketsAfter,
+                totalRxPacketsBefore + packetCount + minExpectedExtraPackets, Long.MAX_VALUE);
+        assertInRange("ttxb", totalTxBytesAfter,
+                totalTxBytesBefore + pktBytes + minExpExtraPktBytes, Long.MAX_VALUE);
+        assertInRange("trxb", totalRxBytesAfter,
+                totalRxBytesBefore + pktBytes + minExpExtraPktBytes, Long.MAX_VALUE);
 
         // Localhost traffic should *not* count against mobile stats,
         // There might be some other traffic, but nowhere near 1MB.
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
index 243cd27..3b8f5bc 100644
--- a/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
@@ -22,6 +22,7 @@
 import static android.Manifest.permission.TETHER_PRIVILEGED;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
+import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_FAILED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STARTED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STOPPED;
@@ -118,7 +119,10 @@
                     cv instanceof CallbackValue.OnTetheringStarted);
         }
 
-        public void expectTetheringFailed(final int expected) throws InterruptedException {
+        /**
+         * Verify that starting tethering failed with the specified error code.
+         */
+        public void expectTetheringFailed(final int expected) {
             final CallbackValue cv = mHistory.poll(TIMEOUT_MS, c -> true);
             assertNotNull("No onTetheringFailed after " + TIMEOUT_MS + " ms", cv);
             assertTrue("Expect fail with error code " + expected + ", but received: " + cv,
@@ -126,6 +130,71 @@
         }
     }
 
+    public static class StopTetheringCallback implements TetheringManager.StopTetheringCallback {
+        private static final int TIMEOUT_MS = 30_000;
+        public static class CallbackValue {
+            public final int error;
+
+            private CallbackValue(final int e) {
+                error = e;
+            }
+
+            public static class OnStopTetheringSucceeded extends CallbackValue {
+                OnStopTetheringSucceeded() {
+                    super(TETHER_ERROR_NO_ERROR);
+                }
+            }
+
+            public static class OnStopTetheringFailed extends CallbackValue {
+                OnStopTetheringFailed(final int error) {
+                    super(error);
+                }
+            }
+
+            @Override
+            public String toString() {
+                return String.format("%s(%d)", getClass().getSimpleName(), error);
+            }
+        }
+
+        private final ArrayTrackRecord<CallbackValue>.ReadHead mHistory =
+                new ArrayTrackRecord<CallbackValue>().newReadHead();
+
+        @Override
+        public void onStopTetheringSucceeded() {
+            mHistory.add(new CallbackValue.OnStopTetheringSucceeded());
+            // Call the parent method so that the coverage linter sees it: http://b/385014495
+            TetheringManager.StopTetheringCallback.super.onStopTetheringSucceeded();
+        }
+
+        @Override
+        public void onStopTetheringFailed(final int error) {
+            mHistory.add(new CallbackValue.OnStopTetheringFailed(error));
+            // Call the parent method so that the coverage linter sees it: http://b/385014495
+            TetheringManager.StopTetheringCallback.super.onStopTetheringFailed(error);
+        }
+
+        /**
+         *  Verifies that {@link #onStopTetheringSucceeded()} was called
+         */
+        public void verifyStopTetheringSucceeded() {
+            final CallbackValue cv = mHistory.poll(TIMEOUT_MS, c -> true);
+            assertNotNull("No onStopTetheringSucceeded after " + TIMEOUT_MS + " ms", cv);
+            assertTrue("Fail stop tethering:" + cv,
+                    cv instanceof CallbackValue.OnStopTetheringSucceeded);
+        }
+
+        /**
+         *  Verifies that {@link #onStopTetheringFailed(int)} was called
+         */
+        public void expectStopTetheringFailed(final int expected) {
+            final CallbackValue cv = mHistory.poll(TIMEOUT_MS, c -> true);
+            assertNotNull("No onStopTetheringFailed after " + TIMEOUT_MS + " ms", cv);
+            assertTrue("Expect fail with error code " + expected + ", but received: " + cv,
+                    (cv instanceof CallbackValue.OnStopTetheringFailed) && (cv.error == expected));
+        }
+    }
+
     private static boolean isRegexMatch(final String[] ifaceRegexs, String iface) {
         if (ifaceRegexs == null) fail("ifaceRegexs should not be null");
 
@@ -493,19 +562,28 @@
     }
 
     /**
-     * Starts Wi-Fi tethering.
+     * Starts Wi-Fi tethering with TETHER_PRIVILEGED permission.
      */
-    public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback)
-            throws InterruptedException {
+    public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback) {
         return startWifiTethering(callback, null);
     }
 
     /**
-     * Starts Wi-Fi tethering with the specified SoftApConfiguration.
+     * Starts Wi-Fi tethering with TETHER_PRIVILEGED permission and the specified
+     * SoftApConfiguration.
      */
     public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback,
-            final SoftApConfiguration softApConfiguration)
-            throws InterruptedException {
+            final SoftApConfiguration softApConfiguration) {
+        return runAsShell(TETHER_PRIVILEGED, () -> startWifiTetheringNoPermissions(
+                callback, softApConfiguration));
+    }
+
+    /**
+     * Starts Wi-Fi tethering without any permission with the specified SoftApConfiguration.
+     */
+    public TetheringInterface startWifiTetheringNoPermissions(
+            final TestTetheringEventCallback callback,
+            final SoftApConfiguration softApConfiguration) {
         final List<String> wifiRegexs = getWifiTetherableInterfaceRegexps(callback);
 
         final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
@@ -516,19 +594,17 @@
         }
         final TetheringRequest request = builder.build();
 
-        return runAsShell(TETHER_PRIVILEGED, () -> {
-            mTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
-            startTetheringCallback.verifyTetheringStarted();
+        mTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
+        startTetheringCallback.verifyTetheringStarted();
 
-            final TetheringInterface iface =
-                    callback.expectTetheredInterfacesChanged(wifiRegexs, TETHERING_WIFI);
+        final TetheringInterface iface =
+                callback.expectTetheredInterfacesChanged(wifiRegexs, TETHERING_WIFI);
 
-            callback.expectOneOfOffloadStatusChanged(
-                    TETHER_HARDWARE_OFFLOAD_STARTED,
-                    TETHER_HARDWARE_OFFLOAD_FAILED);
+        callback.expectOneOfOffloadStatusChanged(
+                TETHER_HARDWARE_OFFLOAD_STARTED,
+                TETHER_HARDWARE_OFFLOAD_FAILED);
 
-            return iface;
-        });
+        return iface;
     }
 
     private static class StopSoftApCallback implements SoftApCallback {
@@ -574,6 +650,22 @@
         expectSoftApDisabled();
     }
 
+    /**
+     * Calls {@link TetheringManager#stopTethering(TetheringRequest, Executor,
+     * TetheringManager.StopTetheringCallback)} and verifies if it succeeded or failed.
+     */
+    public void stopTethering(final TetheringRequest request, boolean expectSuccess) {
+        final StopTetheringCallback stopTetheringCallback = new StopTetheringCallback();
+        runAsShell(TETHER_PRIVILEGED, () -> {
+            mTm.stopTethering(request, c -> c.run() /* executor */, stopTetheringCallback);
+            if (expectSuccess) {
+                stopTetheringCallback.verifyStopTetheringSucceeded();
+            } else {
+                stopTetheringCallback.expectStopTetheringFailed(TETHER_ERROR_UNKNOWN_REQUEST);
+            }
+        });
+    }
+
     public void stopAllTethering() {
         final TestTetheringEventCallback callback = registerTetheringEventCallback();
         try {
diff --git a/tests/cts/netpermission/updatestatspermission/Android.bp b/tests/cts/netpermission/updatestatspermission/Android.bp
index b324dc8..0ff98e7 100644
--- a/tests/cts/netpermission/updatestatspermission/Android.bp
+++ b/tests/cts/netpermission/updatestatspermission/Android.bp
@@ -33,6 +33,7 @@
 
     // Tag this module as a cts test artifact
     test_suites: [
+        "automotive-general-tests",
         "cts",
         "general-tests",
     ],
diff --git a/tests/cts/netpermission/updatestatspermission/AndroidTest.xml b/tests/cts/netpermission/updatestatspermission/AndroidTest.xml
index fb6c814..82994c4 100644
--- a/tests/cts/netpermission/updatestatspermission/AndroidTest.xml
+++ b/tests/cts/netpermission/updatestatspermission/AndroidTest.xml
@@ -20,6 +20,7 @@
     <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="parameter" value="secondary_user_on_secondary_display" />
     <option name="not-shardable" value="true" />
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="cleanup-apks" value="true" />
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index d9bc7f7..a1e0797 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -34,6 +34,7 @@
 
     static_libs: [
         "TetheringCommonTests",
+        "com.android.net.flags-aconfig-java",
         "compatibility-device-util-axt",
         "cts-net-utils",
         "net-tests-utils",
@@ -54,34 +55,6 @@
     host_required: ["net-tests-utils-host-common"],
 }
 
-// Tethering CTS tests that target the latest released SDK. These tests can be installed on release
-// devices which has equal or lowner sdk version than target sdk and are useful for qualifying
-// mainline modules on release devices.
-android_test {
-    name: "CtsTetheringTestLatestSdk",
-    defaults: [
-        "ConnectivityTestsLatestSdkDefaults",
-        "CtsTetheringTestDefaults",
-    ],
-
-    min_sdk_version: "30",
-
-    static_libs: [
-        "TetheringIntegrationTestsLatestSdkLib",
-    ],
-
-    test_suites: [
-        "general-tests",
-        "mts-tethering",
-    ],
-
-    test_config_template: "AndroidTestTemplate.xml",
-
-    // Include both the 32 and 64 bit versions
-    compile_multilib: "both",
-    jarjar_rules: ":NetworkStackJarJarRules",
-}
-
 // Tethering CTS tests for development and release. These tests always target the platform SDK
 // version, and are subject to all the restrictions appropriate to that version. Before SDK
 // finalization, these tests have a min_sdk_version of 10000, but they can still be installed on
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index e0e22e5..92d58e6 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -17,6 +17,7 @@
 
 import static android.Manifest.permission.MODIFY_PHONE_STATE;
 import static android.Manifest.permission.TETHER_PRIVILEGED;
+import static android.Manifest.permission.WRITE_SETTINGS;
 import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
@@ -31,9 +32,11 @@
 import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_ENTITLEMENT_UNKNOWN;
 import static android.net.TetheringManager.TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
+import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
 import static android.net.cts.util.CtsTetheringUtils.isAnyIfaceMatch;
 import static android.os.Process.INVALID_UID;
 
@@ -44,6 +47,7 @@
 import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeFalse;
@@ -84,9 +88,11 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.testutils.ParcelUtils;
+import com.android.testutils.com.android.testutils.CarrierConfigRule;
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -95,13 +101,14 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.Executor;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 
 @RunWith(AndroidJUnit4.class)
 public class TetheringManagerTest {
+    @Rule
+    public final CarrierConfigRule mCarrierConfigRule = new CarrierConfigRule();
 
     private Context mContext;
 
@@ -228,6 +235,30 @@
 
     }
 
+    @Test
+    public void testStartTetheringDuplicateRequestRejected() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            final String[] wifiRegexs = mTM.getTetherableWifiRegexs();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback);
+            mTetherChangeReceiver.expectTethering(true /* active */, wifiRegexs);
+
+            final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+            runAsShell(TETHER_PRIVILEGED, () -> {
+                mTM.startTethering(new TetheringRequest.Builder(TETHERING_WIFI).build(),
+                        c -> c.run() /* executor */, startTetheringCallback);
+                startTetheringCallback.expectTetheringFailed(TETHER_ERROR_DUPLICATE_REQUEST);
+            });
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
     private SoftApConfiguration createSoftApConfiguration(@NonNull String ssid) {
         SoftApConfiguration config;
         if (SdkLevel.isAtLeastT()) {
@@ -374,28 +405,34 @@
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
             tetherEventCallback.expectNoTetheringActive();
 
-            SoftApConfiguration softApConfig = createSoftApConfiguration("SSID");
+            SoftApConfiguration softApConfig = SdkLevel.isAtLeastB()
+                    ? createSoftApConfiguration("SSID") : null;
             final TetheringInterface tetheredIface =
                     mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
             assertNotNull(tetheredIface);
-            assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
-            final String wifiTetheringIface = tetheredIface.getInterface();
+            if  (SdkLevel.isAtLeastB()) {
+                assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
+            }
 
             mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
 
-            try {
-                final int ret = runAsShell(TETHER_PRIVILEGED, () -> mTM.tether(wifiTetheringIface));
-                // There is no guarantee that the wifi interface will be available after disabling
-                // the hotspot, so don't fail the test if the call to tether() fails.
-                if (ret == TETHER_ERROR_NO_ERROR) {
-                    // If calling #tether successful, there is a callback to tell the result of
-                    // tethering setup.
-                    tetherEventCallback.expectErrorOrTethered(
-                            new TetheringInterface(TETHERING_WIFI, wifiTetheringIface));
+            if (!SdkLevel.isAtLeastB()) {
+                final String wifiTetheringIface = tetheredIface.getInterface();
+                try {
+                    final int ret = runAsShell(TETHER_PRIVILEGED,
+                            () -> mTM.tether(wifiTetheringIface));
+                    // There is no guarantee that the wifi interface will be available after
+                    // disabling the hotspot, so don't fail the test if the call to tether() fails.
+                    if (ret == TETHER_ERROR_NO_ERROR) {
+                        // If calling #tether successful, there is a callback to tell the result of
+                        // tethering setup.
+                        tetherEventCallback.expectErrorOrTethered(
+                                new TetheringInterface(TETHERING_WIFI, wifiTetheringIface));
+                    }
+                } finally {
+                    runAsShell(TETHER_PRIVILEGED, () -> mTM.untether(wifiTetheringIface));
                 }
-            } finally {
-                runAsShell(TETHER_PRIVILEGED, () -> mTM.untether(wifiTetheringIface));
             }
         } finally {
             mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
@@ -446,23 +483,100 @@
     }
 
     @Test
-    public void testStopTetheringRequest() throws Exception {
-        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
-        Executor executor = Runnable::run;
-        TetheringManager.StopTetheringCallback callback =
-                new TetheringManager.StopTetheringCallback() {};
+    public void testStopTetheringRequestNoMatchFailure() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
         try {
-            mTM.stopTethering(request, executor, callback);
-            fail("stopTethering should throw UnsupportedOperationException");
-        } catch (UnsupportedOperationException expect) { }
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+            mTM.startTethering(new TetheringRequest.Builder(TETHERING_VIRTUAL).build(),
+                    c -> c.run(), startTetheringCallback);
+
+            // Stopping a non-matching request should have no effect
+            TetheringRequest nonMatchingRequest = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                    .setInterfaceName("iface")
+                    .build();
+            mCtsTetheringUtils.stopTethering(nonMatchingRequest, false /* success */);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
     }
 
     @Test
-    public void testEnableTetheringPermission() throws Exception {
+    public void testStopTetheringRequestMatchSuccess() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes("This is one config"
+                            .getBytes(StandardCharsets.UTF_8))).build();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+
+            // Stopping the active request should stop tethering.
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .build();
+            mCtsTetheringUtils.stopTethering(request, true /* success */);
+            tetherEventCallback.expectNoTetheringActive();
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testStopTetheringRequestFuzzyMatchSuccess() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes("This is one config"
+                            .getBytes(StandardCharsets.UTF_8))).build();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+
+            // Stopping with a fuzzy matching request should stop tethering.
+            final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
+            final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
+            TetheringRequest fuzzyMatchingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .setShouldShowEntitlementUi(true)
+                    .setStaticIpv4Addresses(localAddr, clientAddr)
+                    .build();
+            mCtsTetheringUtils.stopTethering(fuzzyMatchingRequest, true /* success */);
+            tetherEventCallback.expectNoTetheringActive();
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testStartTetheringNoPermission() throws Exception {
         final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+
+        // No permission
         mTM.startTethering(new TetheringRequest.Builder(TETHERING_WIFI).build(),
                 c -> c.run() /* executor */, startTetheringCallback);
         startTetheringCallback.expectTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+
+        // WRITE_SETTINGS not sufficient
+        if (SdkLevel.isAtLeastB()) {
+            runAsShell(WRITE_SETTINGS, () -> {
+                mTM.startTethering(new TetheringRequest.Builder(TETHERING_WIFI).build(),
+                        c -> c.run() /* executor */, startTetheringCallback);
+                startTetheringCallback.expectTetheringFailed(
+                        TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            });
+        }
     }
 
     private class EntitlementResultListener implements OnTetheringEntitlementResultListener {
@@ -527,22 +641,13 @@
         // Override carrier config to ignore entitlement check.
         final PersistableBundle bundle = new PersistableBundle();
         bundle.putBoolean(CarrierConfigManager.KEY_REQUIRE_ENTITLEMENT_CHECKS_BOOL, false);
-        overrideCarrierConfig(bundle);
+        mCarrierConfigRule.addConfigOverrides(
+                SubscriptionManager.getDefaultSubscriptionId(), bundle);
 
         // Verify that requestLatestTetheringEntitlementResult() can get entitlement
         // result TETHER_ERROR_NO_ERROR due to provisioning bypassed.
         assertEntitlementResult(listener -> mTM.requestLatestTetheringEntitlementResult(
                 TETHERING_WIFI, false, c -> c.run(), listener), TETHER_ERROR_NO_ERROR);
-
-        // Reset carrier config.
-        overrideCarrierConfig(null);
-    }
-
-    private void overrideCarrierConfig(PersistableBundle bundle) {
-        final CarrierConfigManager configManager = (CarrierConfigManager) mContext
-                .getSystemService(Context.CARRIER_CONFIG_SERVICE);
-        final int subId = SubscriptionManager.getDefaultSubscriptionId();
-        runAsShell(MODIFY_PHONE_STATE, () -> configManager.overrideConfig(subId, bundle));
     }
 
     private boolean isTetheringApnRequired() {
@@ -603,4 +708,192 @@
             }
         }
     }
+
+    @Test
+    public void testLegacyTetherApisThrowUnsupportedOperationExceptionAfterV() {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assertThrows(UnsupportedOperationException.class, () -> mTM.tether("iface"));
+        assertThrows(UnsupportedOperationException.class, () -> mTM.untether("iface"));
+    }
+
+    @Test
+    public void testCarrierPrivilegedIsTetheringSupported() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            assertTrue(mTM.isTetheringSupported());
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStartTetheringNonWifiFails() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            StartTetheringCallback callback = new StartTetheringCallback();
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_USB).build();
+
+            mTM.startTethering(request, Runnable::run, callback);
+
+            callback.expectTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStartTetheringWifiWithoutConfigFails() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            StartTetheringCallback callback = new StartTetheringCallback();
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+
+            mTM.startTethering(request, Runnable::run, callback);
+
+            callback.expectTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStartTetheringWifiWithConfigSucceeds() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            SoftApConfiguration softApConfig = createSoftApConfiguration("Carrier-privileged");
+
+            mCtsTetheringUtils.startWifiTetheringNoPermissions(tetherEventCallback, softApConfig);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStopTetheringNonWifiFails() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_USB).build();
+            CtsTetheringUtils.StopTetheringCallback
+                    callback = new CtsTetheringUtils.StopTetheringCallback();
+
+            mTM.stopTethering(request, Runnable::run, callback);
+
+            callback.expectStopTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStopTetheringWifiWithoutConfigFails() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+            CtsTetheringUtils.StopTetheringCallback
+                    callback = new CtsTetheringUtils.StopTetheringCallback();
+
+            mTM.stopTethering(request, Runnable::run, callback);
+
+            callback.expectStopTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStopTetheringWifiWithConfigButNoActiveRequestFails()
+            throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            SoftApConfiguration softApConfig = createSoftApConfiguration("Carrier-privileged");
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .build();
+            CtsTetheringUtils.StopTetheringCallback
+                    callback = new CtsTetheringUtils.StopTetheringCallback();
+
+            mTM.stopTethering(request, Runnable::run, callback);
+
+            callback.expectStopTetheringFailed(TETHER_ERROR_UNKNOWN_REQUEST);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testCarrierPrivilegedStopTetheringWifiWithConfigSucceeds() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        assumeTrue(mPm.hasSystemFeature(FEATURE_TELEPHONY));
+        int defaultSubId = SubscriptionManager.getDefaultSubscriptionId();
+        mCarrierConfigRule.acquireCarrierPrivilege(defaultSubId);
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+            SoftApConfiguration softApConfig = createSoftApConfiguration("Carrier-privileged");
+            mCtsTetheringUtils.startWifiTetheringNoPermissions(tetherEventCallback, softApConfig);
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .build();
+            CtsTetheringUtils.StopTetheringCallback
+                    callback = new CtsTetheringUtils.StopTetheringCallback();
+
+            mTM.stopTethering(request, Runnable::run, callback);
+
+            callback.verifyStopTetheringSucceeded();
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
 }
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
index 06bdca6..de39215 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -16,6 +16,7 @@
 
 package com.android.server.net.integrationtests
 
+import android.Manifest.permission
 import android.app.usage.NetworkStatsManager
 import android.content.ComponentName
 import android.content.Context
@@ -58,14 +59,17 @@
 import com.android.server.TestNetIdManager
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator
 import com.android.server.connectivity.ConnectivityResources
+import com.android.server.connectivity.InterfaceTracker
 import com.android.server.connectivity.MockableSystemProperties
 import com.android.server.connectivity.MultinetworkPolicyTracker
+import com.android.server.connectivity.PermissionMonitor
 import com.android.server.connectivity.ProxyTracker
 import com.android.server.connectivity.SatelliteAccessController
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.DeviceInfoUtils
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.runAsShell
 import com.android.testutils.tryTest
 import java.util.function.BiConsumer
 import java.util.function.Consumer
@@ -110,6 +114,8 @@
     @Mock
     private lateinit var netd: INetd
     @Mock
+    private lateinit var interfaceTracker: InterfaceTracker
+    @Mock
     private lateinit var dnsResolver: IDnsResolver
     @Mock
     private lateinit var systemConfigManager: SystemConfigManager
@@ -136,11 +142,15 @@
 
         private val realContext get() = InstrumentationRegistry.getInstrumentation().context
         private val httpProbeUrl get() =
-            realContext.getResources().getString(com.android.server.net.integrationtests.R.string
-                    .config_captive_portal_http_url)
+            realContext.getResources().getString(
+                com.android.server.net.integrationtests.R.string
+                    .config_captive_portal_http_url
+            )
         private val httpsProbeUrl get() =
-            realContext.getResources().getString(com.android.server.net.integrationtests.R.string
-                    .config_captive_portal_https_url)
+            realContext.getResources().getString(
+                com.android.server.net.integrationtests.R.string
+                    .config_captive_portal_https_url
+            )
 
         private class InstrumentationServiceConnection : ServiceConnection {
             override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -162,12 +172,19 @@
         fun setUpClass() {
             val intent = Intent(realContext, NetworkStackInstrumentationService::class.java)
             intent.action = INetworkStackInstrumentation::class.qualifiedName
-            assertTrue(realContext.bindService(intent, InstrumentationServiceConnection(),
-                    BIND_AUTO_CREATE or BIND_IMPORTANT),
-                    "Error binding to instrumentation service")
-            assertTrue(bindingCondition.block(SERVICE_BIND_TIMEOUT_MS),
+            assertTrue(
+                realContext.bindService(
+                    intent,
+                    InstrumentationServiceConnection(),
+                    BIND_AUTO_CREATE or BIND_IMPORTANT
+                ),
+                    "Error binding to instrumentation service"
+            )
+            assertTrue(
+                bindingCondition.block(SERVICE_BIND_TIMEOUT_MS),
                     "Timed out binding to instrumentation service " +
-                            "after $SERVICE_BIND_TIMEOUT_MS ms")
+                            "after $SERVICE_BIND_TIMEOUT_MS ms"
+            )
         }
     }
 
@@ -197,7 +214,8 @@
         // We don't test the actual notification value strings, so just return an empty array.
         // It doesn't matter what the values are as long as it's not null.
         doReturn(emptyArray<String>()).`when`(resources).getStringArray(
-                R.array.network_switch_type_name)
+                R.array.network_switch_type_name
+        )
         doReturn(1).`when`(resources).getInteger(R.integer.config_networkAvoidBadWifi)
         doReturn(R.array.config_networkSupportedKeepaliveCount).`when`(resources)
                 .getIdentifier(eq("config_networkSupportedKeepaliveCount"), eq("array"), any())
@@ -208,7 +226,9 @@
         networkStackClient = TestNetworkStackClient(realContext)
         networkStackClient.start()
 
-        service = TestConnectivityService(TestDependencies())
+        service = runAsShell(permission.OBSERVE_GRANT_REVOKE_PERMISSIONS) {
+            TestConnectivityService(TestDependencies())
+        }
         cm = ConnectivityManager(context, service)
         context.addMockSystemService(Context.CONNECTIVITY_SERVICE, cm)
         context.addMockSystemService(Context.NETWORK_STATS_SERVICE, statsManager)
@@ -217,7 +237,13 @@
     }
 
     private inner class TestConnectivityService(deps: Dependencies) : ConnectivityService(
-            context, dnsResolver, log, netd, deps)
+            context,
+        dnsResolver,
+        log,
+        netd,
+        deps,
+        PermissionMonitorDependencies()
+    )
 
     private inner class TestDependencies : ConnectivityService.Dependencies() {
         override fun getNetworkStack() = networkStackClient
@@ -225,7 +251,11 @@
             mock(ProxyTracker::class.java)
         override fun getSystemProperties() = mock(MockableSystemProperties::class.java)
         override fun makeNetIdManager() = TestNetIdManager()
-        override fun getBpfNetMaps(context: Context?, netd: INetd?) = mock(BpfNetMaps::class.java)
+        override fun getBpfNetMaps(
+            context: Context?,
+            netd: INetd?,
+                                   interfaceTracker: InterfaceTracker?
+        ) = mock(BpfNetMaps::class.java)
                 .also {
                     doReturn(PERMISSION_INTERNET).`when`(it).getNetPermForUid(anyInt())
                 }
@@ -235,13 +265,17 @@
             c: Context,
             h: Handler,
             r: Runnable
-        ) = MultinetworkPolicyTracker(c, h, r,
+        ) = MultinetworkPolicyTracker(
+            c,
+            h,
+            r,
             object : MultinetworkPolicyTracker.Dependencies() {
                 override fun getResourcesForActiveSubId(
                     connResources: ConnectivityResources,
                     activeSubId: Int
                 ) = resources
-            })
+            }
+        )
 
         override fun makeHandlerThread(tag: String): HandlerThread =
             super.makeHandlerThread(tag).also { handlerThreads.add(it) }
@@ -253,13 +287,18 @@
                 listener: BiConsumer<Int, Int>,
                 handler: Handler
         ): CarrierPrivilegeAuthenticator {
-            return CarrierPrivilegeAuthenticator(context,
+            return CarrierPrivilegeAuthenticator(
+                context,
                     object : CarrierPrivilegeAuthenticator.Dependencies() {
                         override fun makeHandlerThread(): HandlerThread =
                                 super.makeHandlerThread().also { handlerThreads.add(it) }
                     },
-                    tm, TelephonyManagerShimImpl.newInstance(tm),
-                    requestRestrictedWifiEnabled, listener, handler)
+                    tm,
+                TelephonyManagerShimImpl.newInstance(tm),
+                    requestRestrictedWifiEnabled,
+                listener,
+                handler
+            )
         }
 
         override fun makeSatelliteAccessController(
@@ -267,7 +306,14 @@
             updateSatellitePreferredUid: Consumer<MutableSet<Int>>?,
             connectivityServiceInternalHandler: Handler
         ): SatelliteAccessController? = mock(
-            SatelliteAccessController::class.java)
+            SatelliteAccessController::class.java
+        )
+
+        override fun makeL2capNetworkProvider(context: Context) = null
+    }
+
+    private inner class PermissionMonitorDependencies : PermissionMonitor.Dependencies() {
+        override fun shouldEnforceLocalNetRestrictions(uid: Int) = false
     }
 
     @After
@@ -293,8 +339,12 @@
         nsInstrumentation.addHttpResponse(HttpResponse(httpProbeUrl, responseCode = 204))
         nsInstrumentation.addHttpResponse(HttpResponse(httpsProbeUrl, responseCode = 204))
 
-        val na = NetworkAgentWrapper(TRANSPORT_CELLULAR, LinkProperties(), null /* ncTemplate */,
-                context)
+        val na = NetworkAgentWrapper(
+            TRANSPORT_CELLULAR,
+            LinkProperties(),
+            null /* ncTemplate */,
+                context
+        )
         networkStackClient.verifyNetworkMonitorCreated(na.network, TEST_TIMEOUT_MS)
 
         na.addCapability(NET_CAPABILITY_INTERNET)
@@ -332,7 +382,8 @@
                     |  "user-portal-url": "https://login.capport.android.com",
                     |  "venue-info-url": "https://venueinfo.capport.android.com"
                     |}
-                """.trimMargin()))
+                """.trimMargin()
+        ))
 
         // Tests will fail if a non-mocked query is received: mock the HTTPS probe, but not the
         // HTTP probe as it should not be sent.
@@ -386,8 +437,10 @@
                 BpfUtils.BPF_CGROUP_INET_EGRESS,
                 BpfUtils.BPF_CGROUP_INET_SOCK_CREATE
         ).forEach {
-            val ret = SystemUtil.runShellCommand(InstrumentationRegistry.getInstrumentation(),
-                    "cmd connectivity bpf-get-cgroup-program-id $it").trim()
+            val ret = SystemUtil.runShellCommand(
+                InstrumentationRegistry.getInstrumentation(),
+                    "cmd connectivity bpf-get-cgroup-program-id $it"
+            ).trim()
 
             assertTrue(Integer.parseInt(ret) > 0, "Unexpected output $ret for type $it")
         }
diff --git a/tests/native/connectivity_native_test/Android.bp b/tests/native/connectivity_native_test/Android.bp
index 39a08fa..02ac3c5 100644
--- a/tests/native/connectivity_native_test/Android.bp
+++ b/tests/native/connectivity_native_test/Android.bp
@@ -24,7 +24,6 @@
         "libcom.android.tethering.connectivity_native",
         "liblog",
         "libnetutils",
-        "libprocessgroup",
     ],
     static_libs: [
         "connectivity_native_aidl_interface-lateststable-ndk",
diff --git a/tests/unit/java/android/net/IpSecManagerTest.java b/tests/unit/java/android/net/IpSecManagerTest.java
index 335f539..d07dea0 100644
--- a/tests/unit/java/android/net/IpSecManagerTest.java
+++ b/tests/unit/java/android/net/IpSecManagerTest.java
@@ -23,10 +23,10 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.fail;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyObject;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -100,7 +100,7 @@
         when(mMockIpSecService.allocateSecurityParameterIndex(
                         eq(GOOGLE_DNS_4.getHostAddress()),
                         eq(DROID_SPI),
-                        anyObject()))
+                        any()))
                 .thenReturn(spiResp);
 
         IpSecManager.SecurityParameterIndex droidSpi =
@@ -119,7 +119,7 @@
         when(mMockIpSecService.allocateSecurityParameterIndex(
                         eq(GOOGLE_DNS_4.getHostAddress()),
                         eq(IpSecManager.INVALID_SECURITY_PARAMETER_INDEX),
-                        anyObject()))
+                        any()))
                 .thenReturn(spiResp);
 
         IpSecManager.SecurityParameterIndex randomSpi =
@@ -140,7 +140,7 @@
         IpSecSpiResponse spiResp =
                 new IpSecSpiResponse(IpSecManager.Status.RESOURCE_UNAVAILABLE, 0, 0);
         when(mMockIpSecService.allocateSecurityParameterIndex(
-                        anyString(), anyInt(), anyObject()))
+                        anyString(), anyInt(), any()))
                 .thenReturn(spiResp);
 
         try {
@@ -157,7 +157,7 @@
     public void testAllocSpiSpiUnavailableException() throws Exception {
         IpSecSpiResponse spiResp = new IpSecSpiResponse(IpSecManager.Status.SPI_UNAVAILABLE, 0, 0);
         when(mMockIpSecService.allocateSecurityParameterIndex(
-                        anyString(), anyInt(), anyObject()))
+                        anyString(), anyInt(), any()))
                 .thenReturn(spiResp);
 
         try {
@@ -187,7 +187,7 @@
                         DUMMY_RESOURCE_ID,
                         TEST_UDP_ENCAP_PORT,
                         Os.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP));
-        when(mMockIpSecService.openUdpEncapsulationSocket(eq(TEST_UDP_ENCAP_PORT), anyObject()))
+        when(mMockIpSecService.openUdpEncapsulationSocket(eq(TEST_UDP_ENCAP_PORT), any()))
                 .thenReturn(udpEncapResp);
 
         IpSecManager.UdpEncapsulationSocket encapSocket =
@@ -236,7 +236,7 @@
                         TEST_UDP_ENCAP_PORT,
                         Os.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP));
 
-        when(mMockIpSecService.openUdpEncapsulationSocket(eq(0), anyObject()))
+        when(mMockIpSecService.openUdpEncapsulationSocket(eq(0), any()))
                 .thenReturn(udpEncapResp);
 
         IpSecManager.UdpEncapsulationSocket encapSocket =
@@ -267,7 +267,7 @@
                 new IpSecTunnelInterfaceResponse(IpSecManager.Status.OK, resourceId, intfName);
         when(mMockIpSecService.createTunnelInterface(
                 eq(VTI_LOCAL_ADDRESS.getHostAddress()), eq(GOOGLE_DNS_4.getHostAddress()),
-                anyObject(), anyObject(), anyString()))
+                any(), any(), anyString()))
                         .thenReturn(dummyResponse);
 
         IpSecManager.IpSecTunnelInterface tunnelIntf = mIpSecManager.createIpSecTunnelInterface(
diff --git a/tests/unit/java/android/net/TrafficStatsTest.kt b/tests/unit/java/android/net/TrafficStatsTest.kt
new file mode 100644
index 0000000..0f85daf
--- /dev/null
+++ b/tests/unit/java/android/net/TrafficStatsTest.kt
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net
+
+import android.net.TrafficStats.UNSUPPORTED
+import android.net.netstats.StatsResult
+import android.net.netstats.TrafficStatsRateLimitCacheConfig
+import android.os.Build
+import com.android.server.net.NetworkStatsService.TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule.FeatureFlag
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import java.util.HashMap
+import java.util.function.LongSupplier
+
+const val TEST_EXPIRY_DURATION_MS = 1000
+const val TEST_IFACE = "wlan0"
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class TrafficStatsTest {
+    private val binder = mock(INetworkStatsService::class.java)
+    private val myUid = android.os.Process.myUid()
+    private val mockMyUidStatsResult = StatsResult(5L, 6L, 7L, 8L)
+    private val mockIfaceStatsResult = StatsResult(7L, 3L, 10L, 21L)
+    private val mockTotalStatsResult = StatsResult(8L, 1L, 5L, 2L)
+    private val secondUidStatsResult = StatsResult(3L, 7L, 10L, 5L)
+    private val secondIfaceStatsResult = StatsResult(9L, 8L, 7L, 6L)
+    private val secondTotalStatsResult = StatsResult(4L, 3L, 2L, 1L)
+    private val emptyStatsResult = StatsResult(0L, 0L, 0L, 0L)
+    private val unsupportedStatsResult =
+            StatsResult(UNSUPPORTED.toLong(), UNSUPPORTED.toLong(),
+                    UNSUPPORTED.toLong(), UNSUPPORTED.toLong())
+
+    private val cacheDisabledConfig = TrafficStatsRateLimitCacheConfig.Builder()
+            .setIsCacheEnabled(false)
+            .setExpiryDurationMs(0)
+            .setMaxEntries(0)
+            .build()
+    private val cacheEnabledConfig = TrafficStatsRateLimitCacheConfig.Builder()
+            .setIsCacheEnabled(true)
+            .setExpiryDurationMs(TEST_EXPIRY_DURATION_MS)
+            .setMaxEntries(100)
+            .build()
+    private val mTestTimeSupplier = TestTimeSupplier()
+
+    private val featureFlags = HashMap<String, Boolean>()
+
+    // This will set feature flags from @FeatureFlag annotations
+    // into the map before setUp() runs.
+    @get:Rule
+    val setFeatureFlagsRule = SetFeatureFlagsRule(
+            { name, enabled -> featureFlags.put(name, enabled == true) },
+            { name -> featureFlags.getOrDefault(name, false) }
+    )
+
+    class TestTimeSupplier : LongSupplier {
+        private var currentTimeMillis = 0L
+
+        override fun getAsLong() = currentTimeMillis
+
+        fun advanceTime(millis: Int) {
+            currentTimeMillis += millis
+        }
+    }
+
+    @Before
+    fun setUp() {
+        TrafficStats.setServiceForTest(binder)
+        TrafficStats.setTimeSupplierForTest(mTestTimeSupplier)
+        mockStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        if (featureFlags.getOrDefault(TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, false)) {
+            doReturn(cacheEnabledConfig).`when`(binder).getRateLimitCacheConfig()
+        } else {
+            doReturn(cacheDisabledConfig).`when`(binder).getRateLimitCacheConfig()
+        }
+        TrafficStats.reinitRateLimitCacheForTest()
+    }
+
+    @After
+    fun tearDown() {
+        TrafficStats.setServiceForTest(null)
+        TrafficStats.setTimeSupplierForTest(null)
+        TrafficStats.reinitRateLimitCacheForTest()
+    }
+
+    private fun assertUidStats(uid: Int, stats: StatsResult) {
+        assertEquals(stats.rxBytes, TrafficStats.getUidRxBytes(uid))
+        assertEquals(stats.rxPackets, TrafficStats.getUidRxPackets(uid))
+        assertEquals(stats.txBytes, TrafficStats.getUidTxBytes(uid))
+        assertEquals(stats.txPackets, TrafficStats.getUidTxPackets(uid))
+    }
+
+    private fun assertIfaceStats(iface: String, stats: StatsResult) {
+        assertEquals(stats.rxBytes, TrafficStats.getRxBytes(iface))
+        assertEquals(stats.rxPackets, TrafficStats.getRxPackets(iface))
+        assertEquals(stats.txBytes, TrafficStats.getTxBytes(iface))
+        assertEquals(stats.txPackets, TrafficStats.getTxPackets(iface))
+    }
+
+    private fun assertTotalStats(stats: StatsResult) {
+        assertEquals(stats.rxBytes, TrafficStats.getTotalRxBytes())
+        assertEquals(stats.rxPackets, TrafficStats.getTotalRxPackets())
+        assertEquals(stats.txBytes, TrafficStats.getTotalTxBytes())
+        assertEquals(stats.txPackets, TrafficStats.getTotalTxPackets())
+    }
+
+    private fun mockStats(uidStats: StatsResult?, ifaceStats: StatsResult?,
+                          totalStats: StatsResult?) {
+        doReturn(uidStats).`when`(binder).getUidStats(myUid)
+        doReturn(ifaceStats).`when`(binder).getIfaceStats(TEST_IFACE)
+        doReturn(totalStats).`when`(binder).getTotalStats()
+    }
+
+    private fun assertStats(uidStats: StatsResult, ifaceStats: StatsResult,
+                            totalStats: StatsResult) {
+        assertUidStats(myUid, uidStats)
+        assertIfaceStats(TEST_IFACE, ifaceStats)
+        assertTotalStats(totalStats)
+    }
+
+    private fun assertStatsFetchInvocations(wantedInvocations: Int) {
+        verify(binder, times(wantedInvocations)).getUidStats(myUid)
+        verify(binder, times(wantedInvocations)).getIfaceStats(TEST_IFACE)
+        verify(binder, times(wantedInvocations)).getTotalStats()
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @Test
+    fun testRateLimitCacheExpiry_cacheEnabled() {
+        // Initial fetch, verify binder calls.
+        assertStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        assertStatsFetchInvocations(1)
+
+        // Advance time within expiry, verify cached values used.
+        clearInvocations(binder)
+        mockStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        mTestTimeSupplier.advanceTime(1)
+        assertStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        assertStatsFetchInvocations(0)
+
+        // Advance time to expire cache, verify new values fetched.
+        clearInvocations(binder)
+        mTestTimeSupplier.advanceTime(TEST_EXPIRY_DURATION_MS)
+        assertStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        assertStatsFetchInvocations(1)
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @Test
+    fun testRateLimitCacheExpiry_cacheDisabled() {
+        // Initial fetch, verify binder calls.
+        assertStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        assertStatsFetchInvocations(4)
+
+        // Advance time within expiry, verify new values fetched.
+        clearInvocations(binder)
+        mockStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        mTestTimeSupplier.advanceTime(1)
+        assertStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        assertStatsFetchInvocations(4)
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @Test
+    fun testInvalidStatsNotCached_cacheEnabled() {
+        doTestInvalidStatsNotCached()
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @Test
+    fun testInvalidStatsNotCached_cacheDisabled() {
+        doTestInvalidStatsNotCached()
+    }
+
+    private fun doTestInvalidStatsNotCached() {
+        // Mock null stats, this usually happens when the query is not valid,
+        // e.g. query uid stats of other application.
+        mockStats(null, null, null)
+        assertStats(unsupportedStatsResult, unsupportedStatsResult, unsupportedStatsResult)
+        assertStatsFetchInvocations(4)
+
+        // Verify null stats is not cached, and mock empty stats. This usually
+        // happens when queries with non-existent interface names.
+        clearInvocations(binder)
+        mockStats(emptyStatsResult, emptyStatsResult, emptyStatsResult)
+        assertStats(emptyStatsResult, emptyStatsResult, emptyStatsResult)
+        assertStatsFetchInvocations(4)
+
+        // Verify empty result is also not cached.
+        clearInvocations(binder)
+        assertStats(emptyStatsResult, emptyStatsResult, emptyStatsResult)
+        assertStatsFetchInvocations(4)
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @Test
+    fun testClearRateLimitCaches_cacheEnabled() {
+        doTestClearRateLimitCaches(true)
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @Test
+    fun testClearRateLimitCaches_cacheDisabled() {
+        doTestClearRateLimitCaches(false)
+    }
+
+    private fun doTestClearRateLimitCaches(cacheEnabled: Boolean) {
+        // Initial fetch, verify binder calls.
+        assertStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        assertStatsFetchInvocations(if (cacheEnabled) 1 else 4)
+
+        // Verify cached values are used.
+        clearInvocations(binder)
+        assertStats(mockMyUidStatsResult, mockIfaceStatsResult, mockTotalStatsResult)
+        assertStatsFetchInvocations(if (cacheEnabled) 0 else 4)
+
+        // Clear caches, verify fetching from the service.
+        clearInvocations(binder)
+        TrafficStats.clearRateLimitCaches()
+        mockStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        assertStats(secondUidStatsResult, secondIfaceStatsResult, secondTotalStatsResult)
+        assertStatsFetchInvocations(if (cacheEnabled) 1 else 4)
+    }
+}
diff --git a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
index c491f37..8117431 100644
--- a/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
+++ b/tests/unit/java/android/net/nsd/AdvertisingRequestTest.kt
@@ -44,14 +44,14 @@
             serviceType = "_ipp._tcp"
         }
         val beforeParcel = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
-                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setFlags(NSD_ADVERTISING_UPDATE_ONLY)
                 .setTtl(Duration.ofSeconds(30L))
                 .build()
 
         val afterParcel = parcelingRoundTrip(beforeParcel)
 
         assertEquals(beforeParcel.serviceInfo.serviceType, afterParcel.serviceInfo.serviceType)
-        assertEquals(beforeParcel.advertisingConfig, afterParcel.advertisingConfig)
+        assertEquals(beforeParcel.flags, afterParcel.flags)
     }
 
     @Test
@@ -72,13 +72,13 @@
             serviceType = "_ipp._tcp"
         }
         val request = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
-                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setFlags(NSD_ADVERTISING_UPDATE_ONLY)
                 .setTtl(Duration.ofSeconds(100L))
                 .build()
 
         assertEquals("_ipp._tcp", request.serviceInfo.serviceType)
         assertEquals(PROTOCOL_DNS_SD, request.protocolType)
-        assertEquals(NSD_ADVERTISING_UPDATE_ONLY, request.advertisingConfig)
+        assertEquals(NSD_ADVERTISING_UPDATE_ONLY, request.flags)
         assertEquals(Duration.ofSeconds(100L), request.ttl)
     }
 
@@ -90,11 +90,11 @@
         val request1 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
         val request2 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD).build()
         val request3 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
-                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setFlags(NSD_ADVERTISING_UPDATE_ONLY)
                 .setTtl(Duration.ofSeconds(120L))
                 .build()
         val request4 = AdvertisingRequest.Builder(info, PROTOCOL_DNS_SD)
-                .setAdvertisingConfig(NSD_ADVERTISING_UPDATE_ONLY)
+                .setFlags(NSD_ADVERTISING_UPDATE_ONLY)
                 .setTtl(Duration.ofSeconds(120L))
                 .build()
 
diff --git a/tests/unit/java/com/android/server/BpfNetMapsTest.java b/tests/unit/java/com/android/server/BpfNetMapsTest.java
index c1c15ca..1d2e8b0 100644
--- a/tests/unit/java/com/android/server/BpfNetMapsTest.java
+++ b/tests/unit/java/com/android/server/BpfNetMapsTest.java
@@ -71,6 +71,7 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
@@ -99,6 +100,7 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.Struct.Bool;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.Struct.U32;
 import com.android.net.module.util.Struct.U8;
@@ -106,6 +108,8 @@
 import com.android.net.module.util.bpf.CookieTagMapValue;
 import com.android.net.module.util.bpf.IngressDiscardKey;
 import com.android.net.module.util.bpf.IngressDiscardValue;
+import com.android.net.module.util.bpf.LocalNetAccessKey;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
@@ -167,10 +171,16 @@
     @Mock INetd mNetd;
     @Mock BpfNetMaps.Dependencies mDeps;
     @Mock Context mContext;
+
+    @Mock InterfaceTracker mInterfaceTracker;
     private final IBpfMap<S32, U32> mConfigurationMap = new TestBpfMap<>(S32.class, U32.class);
     private final IBpfMap<S32, UidOwnerValue> mUidOwnerMap =
             new TestBpfMap<>(S32.class, UidOwnerValue.class);
     private final IBpfMap<S32, U8> mUidPermissionMap = new TestBpfMap<>(S32.class, U8.class);
+    private final IBpfMap<U32, Bool> mLocalNetBlockedUidMap =
+            new TestBpfMap<>(U32.class, Bool.class);
+    private final IBpfMap<LocalNetAccessKey, Bool> mLocalNetAccessMap =
+            new TestBpfMap<>(LocalNetAccessKey.class, Bool.class);
     private final IBpfMap<CookieTagMapKey, CookieTagMapValue> mCookieTagMap =
             spy(new TestBpfMap<>(CookieTagMapKey.class, CookieTagMapValue.class));
     private final IBpfMap<S32, U8> mDataSaverEnabledMap = new TestBpfMap<>(S32.class, U8.class);
@@ -181,6 +191,7 @@
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         doReturn(TEST_IF_INDEX).when(mDeps).getIfIndex(TEST_IF_NAME);
+        doReturn(TEST_IF_INDEX).when(mInterfaceTracker).getInterfaceIndex(TEST_IF_NAME);
         doReturn(TEST_IF_NAME).when(mDeps).getIfName(TEST_IF_INDEX);
         doReturn(0).when(mDeps).synchronizeKernelRCU();
         BpfNetMaps.setConfigurationMapForTest(mConfigurationMap);
@@ -189,11 +200,13 @@
                 CURRENT_STATS_MAP_CONFIGURATION_KEY, new U32(STATS_SELECT_MAP_A));
         BpfNetMaps.setUidOwnerMapForTest(mUidOwnerMap);
         BpfNetMaps.setUidPermissionMapForTest(mUidPermissionMap);
+        BpfNetMaps.setLocalNetAccessMapForTest(mLocalNetAccessMap);
+        BpfNetMaps.setLocalNetBlockedUidMapForTest(mLocalNetBlockedUidMap);
         BpfNetMaps.setCookieTagMapForTest(mCookieTagMap);
         BpfNetMaps.setDataSaverEnabledMapForTest(mDataSaverEnabledMap);
         mDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(DATA_SAVER_DISABLED));
         BpfNetMaps.setIngressDiscardMapForTest(mIngressDiscardMap);
-        mBpfNetMaps = new BpfNetMaps(mContext, mNetd, mDeps);
+        mBpfNetMaps = new BpfNetMaps(mContext, mNetd, mDeps, mInterfaceTracker);
     }
 
     @Test
@@ -235,6 +248,225 @@
     }
 
     @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddLocalNetAccessBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.addLocalNetAccess(0, TEST_IF_NAME, Inet6Address.ANY, 0, 0, true));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddLocalNetAccessAfterV() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddLocalNetAccessWithNullInterfaceAfterV() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, null,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        // As we tried to add null interface, it would be skipped and map should be empty.
+        assertTrue(mLocalNetAccessMap.isEmpty());
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddLocalNetAccessAfterVWithIncorrectInterface() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        // wlan2 is an incorrect interface
+        mBpfNetMaps.addLocalNetAccess(160, "wlan2",
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        // As we tried to add incorrect interface, it would be skipped and map should be empty.
+        assertTrue(mLocalNetAccessMap.isEmpty());
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testGetLocalNetAccessBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.getLocalNetAccess(0, TEST_IF_NAME, Inet6Address.ANY, 0, 0));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testGetLocalNetAccessAfterV() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mLocalNetAccessMap.updateEntry(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0),
+                new Bool(false));
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+
+        assertFalse(mBpfNetMaps.getLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0));
+        assertTrue(mBpfNetMaps.getLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("100.68.0.0"), 0, 0));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testGetLocalNetAccessWithNullInterfaceAfterV() throws Exception {
+        assertTrue(mBpfNetMaps.getLocalNetAccess(160, null,
+                Inet4Address.getByName("100.68.0.0"), 0, 0));
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveLocalNetAccessBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.removeLocalNetAccess(0, TEST_IF_NAME, Inet6Address.ANY, 0, 0));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveLocalNetAccessAfterV() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+
+        mBpfNetMaps.removeLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0);
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveLocalNetAccessAfterVWithIncorrectInterface() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+
+        mBpfNetMaps.removeLocalNetAccess(160, "wlan2",
+                Inet4Address.getByName("196.68.0.0"), 0, 0);
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveLocalNetAccessAfterVWithNullInterface() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+
+        mBpfNetMaps.removeLocalNetAccess(160, null,
+                Inet4Address.getByName("196.68.0.0"), 0, 0);
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddUidToLocalNetBlockMapBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.addUidToLocalNetBlockMap(0));
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testIsUidBlockedFromUsingLocalNetworkBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.isUidBlockedFromUsingLocalNetwork(0));
+    }
+
+    @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveUidFromLocalNetBlockMapBeforeV() {
+        assertThrows(UnsupportedOperationException.class, () ->
+                mBpfNetMaps.removeUidFromLocalNetBlockMap(0));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddUidFromLocalNetBlockMapAfterV() throws Exception {
+        final int uid0 = TEST_UIDS[0];
+        final int uid1 = TEST_UIDS[1];
+
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addUidToLocalNetBlockMap(uid0);
+        assertTrue(mLocalNetBlockedUidMap.getValue(new U32(uid0)).val);
+        assertNull(mLocalNetBlockedUidMap.getValue(new U32(uid1)));
+
+        mBpfNetMaps.addUidToLocalNetBlockMap(uid1);
+        assertTrue(mLocalNetBlockedUidMap.getValue(new U32(uid1)).val);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testIsUidBlockedFromUsingLocalNetworkAfterV() throws Exception {
+        final int uid0 = TEST_UIDS[0];
+        final int uid1 = TEST_UIDS[1];
+
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mLocalNetBlockedUidMap.updateEntry(new U32(uid0), new Bool(true));
+        assertTrue(mBpfNetMaps.isUidBlockedFromUsingLocalNetwork(uid0));
+        assertFalse(mBpfNetMaps.isUidBlockedFromUsingLocalNetwork(uid1));
+
+        mLocalNetBlockedUidMap.updateEntry(new U32(uid1), new Bool(true));
+        assertTrue(mBpfNetMaps.isUidBlockedFromUsingLocalNetwork(uid1));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveUidFromLocalNetBlockMapAfterV() throws Exception {
+        final int uid0 = TEST_UIDS[0];
+        final int uid1 = TEST_UIDS[1];
+
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mLocalNetBlockedUidMap.updateEntry(new U32(uid0), new Bool(true));
+        mLocalNetBlockedUidMap.updateEntry(new U32(uid1), new Bool(true));
+
+        assertTrue(mLocalNetBlockedUidMap.getValue(new U32(uid0)).val);
+        assertTrue(mLocalNetBlockedUidMap.getValue(new U32(uid1)).val);
+
+        mBpfNetMaps.removeUidFromLocalNetBlockMap(uid0);
+        assertNull(mLocalNetBlockedUidMap.getValue(new U32(uid0)));
+        assertTrue(mLocalNetBlockedUidMap.getValue(new U32(uid1)).val);
+
+        mBpfNetMaps.removeUidFromLocalNetBlockMap(uid1);
+        assertNull(mLocalNetBlockedUidMap.getValue(new U32(uid1)));
+    }
+
+    @Test
     @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testIsChainEnabled() throws Exception {
         doTestIsChainEnabled(FIREWALL_CHAIN_DOZABLE);
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index f7d7c87..fefc8bf 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -163,10 +163,7 @@
 import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_HIGH;
 import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_LOW;
 
-import static com.android.server.ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK;
 import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
-import static com.android.server.ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS;
-import static com.android.server.ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION;
 import static com.android.server.ConnectivityService.MAX_NETWORK_REQUESTS_PER_SYSTEM_UID;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_MOBILE_DATA_PREFERERRED;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_OEM;
@@ -177,9 +174,6 @@
 import static com.android.server.ConnectivityServiceTestUtils.transportToLegacyType;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackRegister;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackUnregister;
-import static com.android.server.connectivity.ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN;
-import static com.android.server.connectivity.ConnectivityFlags.DELAY_DESTROY_SOCKETS;
-import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.ConcurrentUtils.await;
 import static com.android.testutils.ConcurrentUtils.durationOf;
@@ -226,7 +220,7 @@
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.Matchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.clearInvocations;
@@ -412,12 +406,14 @@
 import com.android.server.ConnectivityService.NetworkRequestInfo;
 import com.android.server.ConnectivityServiceTest.ConnectivityServiceDependencies.DestroySocketsWrapper;
 import com.android.server.ConnectivityServiceTest.ConnectivityServiceDependencies.ReportedInterfaces;
+import com.android.server.L2capNetworkProvider;
 import com.android.server.connectivity.ApplicationSelfCertifiedNetworkCapabilities;
 import com.android.server.connectivity.AutomaticOnOffKeepaliveTracker;
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator;
 import com.android.server.connectivity.ClatCoordinator;
 import com.android.server.connectivity.ConnectivityFlags;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.server.connectivity.KeepaliveTracker;
 import com.android.server.connectivity.MultinetworkPolicyTracker;
 import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies;
@@ -425,6 +421,7 @@
 import com.android.server.connectivity.NetworkAgentInfo;
 import com.android.server.connectivity.NetworkNotificationManager;
 import com.android.server.connectivity.NetworkNotificationManager.NotificationType;
+import com.android.server.connectivity.PermissionMonitor;
 import com.android.server.connectivity.ProxyTracker;
 import com.android.server.connectivity.QosCallbackTracker;
 import com.android.server.connectivity.SatelliteAccessController;
@@ -589,10 +586,15 @@
             "https://android.com/user/api/capport/";
     private static final String TEST_FRIENDLY_NAME = "Network friendly name";
     private static final String TEST_REDIRECT_URL = "http://example.com/firstPath";
+    private static final String QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER =
+            "queue_network_agent_events_in_system_server";
+
+    private boolean mShouldCreateNetworksImmediately;
 
     private MockContext mServiceContext;
     private HandlerThread mCsHandlerThread;
     private ConnectivityServiceDependencies mDeps;
+    private PermissionMonitorDependencies mPermDeps;
     private AutomaticOnOffKeepaliveTrackerDependencies mAutoOnOffKeepaliveDependencies;
     private ConnectivityService mService;
     private WrappedConnectivityManager mCm;
@@ -1920,6 +1922,7 @@
         doReturn(mResources).when(mockResContext).getResources();
         ConnectivityResources.setResourcesContextForTest(mockResContext);
         mDeps = new ConnectivityServiceDependencies(mockResContext);
+        mPermDeps = new PermissionMonitorDependencies();
         doReturn(true).when(mMockKeepaliveTrackerDependencies)
                 .isAddressTranslationEnabled(mServiceContext);
         doReturn(new ConnectivityResources(mockResContext)).when(mMockKeepaliveTrackerDependencies)
@@ -1932,10 +1935,13 @@
                 mMockDnsResolver,
                 mock(IpConnectivityLog.class),
                 mMockNetd,
-                mDeps);
+                mDeps, mPermDeps);
         mService.mLingerDelayMs = TEST_LINGER_DELAY_MS;
         mService.mNascentDelayMs = TEST_NASCENT_DELAY_MS;
 
+        mShouldCreateNetworksImmediately = mService.isConnectivityServiceFeatureEnabledForTesting(
+                QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER);
+
         if (mDeps.isAtLeastV()) {
             verify(mNetworkPolicyManager, never()).registerNetworkPolicyCallback(any(), any());
             mPolicyCallback = null;
@@ -2181,28 +2187,31 @@
                 case ConnectivityFlags.REQUEST_RESTRICTED_WIFI:
                 case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS:
                 case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS:
-                case KEY_DESTROY_FROZEN_SOCKETS_VERSION:
+                case ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN:
+                case ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION:
                     return true;
                 default:
-                    return super.isFeatureEnabled(context, name);
+                    // This is a unit test and must never depend on actual device flag values.
+                    throw new UnsupportedOperationException("Unknown flag " + name
+                            + ", update this test");
             }
         }
 
         @Override
         public boolean isFeatureNotChickenedOut(Context context, String name) {
             switch (name) {
-                case ALLOW_SYSUI_CONNECTIVITY_REPORTS:
-                    return true;
-                case ALLOW_SATALLITE_NETWORK_FALLBACK:
-                    return true;
-                case INGRESS_TO_VPN_ADDRESS_FILTERING:
-                    return true;
-                case BACKGROUND_FIREWALL_CHAIN:
-                    return true;
-                case DELAY_DESTROY_SOCKETS:
+                case ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS:
+                case ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK:
+                case ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING:
+                case ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN:
+                case ConnectivityFlags.DELAY_DESTROY_SOCKETS:
+                case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS:
+                case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS:
+                case ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER:
                     return true;
                 default:
-                    return super.isFeatureNotChickenedOut(context, name);
+                    throw new UnsupportedOperationException("Unknown flag " + name
+                            + ", update this test");
             }
         }
 
@@ -2232,7 +2241,8 @@
         private static final int VERSION_T = 3;
         private static final int VERSION_U = 4;
         private static final int VERSION_V = 5;
-        private static final int VERSION_MAX = VERSION_V;
+        private static final int VERSION_B = 6;
+        private static final int VERSION_MAX = VERSION_B;
         private int mSdkLevel = VERSION_UNMOCKED;
 
         private void setBuildSdk(final int sdkLevel) {
@@ -2262,7 +2272,14 @@
         }
 
         @Override
-        public BpfNetMaps getBpfNetMaps(Context context, INetd netd) {
+        public boolean isAtLeastB() {
+            return mSdkLevel == VERSION_UNMOCKED ? super.isAtLeastB()
+                    : mSdkLevel >= VERSION_B;
+        }
+
+        @Override
+        public BpfNetMaps getBpfNetMaps(Context context, INetd netd,
+                InterfaceTracker interfaceTracker) {
             return mBpfNetMaps;
         }
 
@@ -2381,6 +2398,18 @@
             // Needed to mock out the dependency on DeviceConfig
             return 15;
         }
+
+        @Override
+        public L2capNetworkProvider makeL2capNetworkProvider(Context context) {
+            return null;
+        }
+    }
+
+    static class PermissionMonitorDependencies extends PermissionMonitor.Dependencies {
+        @Override
+        public boolean shouldEnforceLocalNetRestrictions(int uid) {
+            return false;
+        }
     }
 
     private class AutomaticOnOffKeepaliveTrackerDependencies
@@ -2431,6 +2460,10 @@
 
     @After
     public void tearDown() throws Exception {
+        // Don't attempt to tear down if setUp didn't even get as far as creating the service.
+        // Otherwise, exceptions here will mask the actual exception in setUp, making failures
+        // harder to diagnose.
+        if (mService == null) return;
         unregisterDefaultNetworkCallbacks();
         maybeTearDownEnterpriseNetwork();
         setAlwaysOnNetworks(false);
@@ -3074,22 +3107,43 @@
         if (expectLingering) {
             generalCb.expectLosing(net1);
         }
-        generalCb.expectCaps(net2, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
-        defaultCb.expectAvailableDoubleValidatedCallbacks(net2);
-
-        // Make sure cell 1 is unwanted immediately if the radio can't time share, but only
-        // after some delay if it can.
-        if (expectLingering) {
-            net1.assertNotDisconnected(TEST_CALLBACK_TIMEOUT_MS); // always incurs the timeout
-            generalCb.assertNoCallback();
-            // assertNotDisconnected waited for TEST_CALLBACK_TIMEOUT_MS, so waiting for the
-            // linger period gives TEST_CALLBACK_TIMEOUT_MS time for the event to process.
-            net1.expectDisconnected(UNREASONABLY_LONG_ALARM_WAIT_MS);
+        if (mShouldCreateNetworksImmediately) {
+            if (expectLingering) {
+                // Make sure cell 1 is unwanted immediately if the radio can't time share, but only
+                // after some delay if it can.
+                generalCb.expectCaps(net2, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
+                defaultCb.expectAvailableDoubleValidatedCallbacks(net2);
+                net1.assertNotDisconnected(TEST_CALLBACK_TIMEOUT_MS); // always incurs the timeout
+                generalCb.assertNoCallback();
+                // assertNotDisconnected waited for TEST_CALLBACK_TIMEOUT_MS, so waiting for the
+                // linger period gives TEST_CALLBACK_TIMEOUT_MS time for the event to process.
+                net1.expectDisconnected(UNREASONABLY_LONG_ALARM_WAIT_MS);
+                generalCb.expect(LOST, net1);
+            } else {
+                net1.expectDisconnected(TEST_CALLBACK_TIMEOUT_MS);
+                net1.disconnect();
+                generalCb.expect(LOST, net1);
+                generalCb.expectCaps(net2, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
+                defaultCb.expectAvailableDoubleValidatedCallbacks(net2);
+            }
         } else {
-            net1.expectDisconnected(TEST_CALLBACK_TIMEOUT_MS);
+            generalCb.expectCaps(net2, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
+            defaultCb.expectAvailableDoubleValidatedCallbacks(net2);
+
+            // Make sure cell 1 is unwanted immediately if the radio can't time share, but only
+            // after some delay if it can.
+            if (expectLingering) {
+                net1.assertNotDisconnected(TEST_CALLBACK_TIMEOUT_MS); // always incurs the timeout
+                generalCb.assertNoCallback();
+                // assertNotDisconnected waited for TEST_CALLBACK_TIMEOUT_MS, so waiting for the
+                // linger period gives TEST_CALLBACK_TIMEOUT_MS time for the event to process.
+                net1.expectDisconnected(UNREASONABLY_LONG_ALARM_WAIT_MS);
+            } else {
+                net1.expectDisconnected(TEST_CALLBACK_TIMEOUT_MS);
+            }
+            net1.disconnect();
+            generalCb.expect(LOST, net1);
         }
-        net1.disconnect();
-        generalCb.expect(LOST, net1);
 
         // Remove primary from net 2
         net2.setScore(new NetworkScore.Builder().build());
@@ -4024,7 +4078,7 @@
 
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI, callbacks);
 
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             assertEquals("onNetworkCreated", eventOrder.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         } else {
             assertNull(eventOrder.poll());
@@ -4037,7 +4091,7 @@
         // connected.
         // TODO: fix this bug, file the request before connecting, and remove the waitForIdle.
         mWiFiAgent.connectWithoutInternet();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             assertEquals("onNetworkCreated", eventOrder.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         } else {
             waitForIdle();
@@ -7925,8 +7979,8 @@
         // Simple connection with initial LP should have updated ifaces.
         mCellAgent.connect(false);
         waitForIdle();
-        List<Network> allNetworks = mService.shouldCreateNetworksImmediately()
-                ? cellAndWifi() : onlyCell();
+        List<Network> allNetworks = mService.shouldCreateNetworksImmediately(
+                mCellAgent.getNetworkCapabilities()) ? cellAndWifi() : onlyCell();
         expectNotifyNetworkStatus(allNetworks, onlyCell(), MOBILE_IFNAME);
         reset(mStatsManager);
 
@@ -8238,7 +8292,7 @@
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
         final int netId = mCellAgent.getNetwork().netId;
         waitForIdle();
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         } else {
             verify(mMockDnsResolver, never()).setResolverConfiguration(any());
@@ -8258,7 +8312,7 @@
         mCellAgent.sendLinkProperties(cellLp);
         mCellAgent.connect(false);
         waitForIdle();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             // CS tells dnsresolver about the empty DNS config for this network.
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         }
@@ -8381,7 +8435,7 @@
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
         final int netId = mCellAgent.getNetwork().netId;
         waitForIdle();
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         } else {
             verify(mMockDnsResolver, never()).setResolverConfiguration(any());
@@ -8404,7 +8458,7 @@
         mCellAgent.sendLinkProperties(cellLp);
         mCellAgent.connect(false);
         waitForIdle();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         }
         verify(mMockDnsResolver, atLeastOnce()).setResolverConfiguration(
@@ -12294,7 +12348,7 @@
         final NetworkAgent naNoExtraInfo = new TestNetworkAgent(
                 mServiceContext, mCsHandlerThread.getLooper(), new NetworkAgentConfig());
         naNoExtraInfo.register();
-        verify(mNetworkStack).makeNetworkMonitor(any(), isNull(String.class), any());
+        verify(mNetworkStack).makeNetworkMonitor(any(), isNull(), any());
         naNoExtraInfo.unregister();
 
         reset(mNetworkStack);
@@ -16049,13 +16103,13 @@
 
         final TestNetworkAgentWrapper workAgent =
                 makeEnterpriseNetworkAgent(profileNetworkPreference.getPreferenceEnterpriseId());
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
             expectNativeNetworkCreated(workAgent.getNetwork().netId, INetd.PERMISSION_SYSTEM,
                     null /* iface */, inOrder);
         }
         if (connectWorkProfileAgentAhead) {
             workAgent.connect(false);
-            if (!mService.shouldCreateNetworksImmediately()) {
+            if (!mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
                 expectNativeNetworkCreated(workAgent.getNetwork().netId, INetd.PERMISSION_SYSTEM,
                         null /* iface */, inOrder);
             }
@@ -16098,7 +16152,7 @@
 
         if (!connectWorkProfileAgentAhead) {
             workAgent.connect(false);
-            if (!mService.shouldCreateNetworksImmediately()) {
+            if (!mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
                 inOrder.verify(mMockNetd).networkCreate(
                         nativeNetworkConfigPhysical(workAgent.getNetwork().netId,
                                 INetd.PERMISSION_SYSTEM));
@@ -18809,7 +18863,7 @@
     }
 
     private void verifyMtuSetOnWifiInterfaceOnlyUpToT(int mtu) throws Exception {
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             verify(mMockNetd, times(1)).interfaceSetMtu(WIFI_IFNAME, mtu);
         } else {
             verify(mMockNetd, never()).interfaceSetMtu(eq(WIFI_IFNAME), anyInt());
@@ -18817,7 +18871,7 @@
     }
 
     private void verifyMtuSetOnWifiInterfaceOnlyStartingFromU(int mtu) throws Exception {
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             verify(mMockNetd, times(1)).interfaceSetMtu(WIFI_IFNAME, mtu);
         } else {
             verify(mMockNetd, never()).interfaceSetMtu(eq(WIFI_IFNAME), anyInt());
diff --git a/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java b/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java
index 8037542..65beda1 100644
--- a/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java
+++ b/tests/unit/java/com/android/server/IpSecServiceParameterizedTest.java
@@ -37,9 +37,6 @@
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
diff --git a/tests/unit/java/com/android/server/IpSecServiceRefcountedResourceTest.java b/tests/unit/java/com/android/server/IpSecServiceRefcountedResourceTest.java
index 8595ab9..0e216d7 100644
--- a/tests/unit/java/com/android/server/IpSecServiceRefcountedResourceTest.java
+++ b/tests/unit/java/com/android/server/IpSecServiceRefcountedResourceTest.java
@@ -19,9 +19,9 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.fail;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyObject;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
@@ -111,7 +111,7 @@
         assertResourceState(resource, 1, 0, 0, 0, 0);
 
         // Verify linking to binder death
-        verify(binderMock).linkToDeath(anyObject(), anyInt());
+        verify(binderMock).linkToDeath(any(), anyInt());
     }
 
     @Test
@@ -129,14 +129,14 @@
         assertResourceState(childResource, 2, 0, 0, 0, 0);
 
         // Verify linking to binder death
-        verify(binderMockChild).linkToDeath(anyObject(), anyInt());
-        verify(binderMockParent).linkToDeath(anyObject(), anyInt());
+        verify(binderMockChild).linkToDeath(any(), anyInt());
+        verify(binderMockParent).linkToDeath(any(), anyInt());
     }
 
     @Test
     public void testFailLinkToDeath() throws RemoteException {
         IBinder binderMock = mock(IBinder.class);
-        doThrow(new RemoteException()).when(binderMock).linkToDeath(anyObject(), anyInt());
+        doThrow(new RemoteException()).when(binderMock).linkToDeath(any(), anyInt());
 
         try {
             getTestRefcountedResource(binderMock);
diff --git a/tests/unit/java/com/android/server/IpSecServiceTest.java b/tests/unit/java/com/android/server/IpSecServiceTest.java
index 4b6857c..32c61fb 100644
--- a/tests/unit/java/com/android/server/IpSecServiceTest.java
+++ b/tests/unit/java/com/android/server/IpSecServiceTest.java
@@ -26,10 +26,10 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Matchers.anyString;
-import static org.mockito.Matchers.argThat;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
diff --git a/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java b/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java
new file mode 100644
index 0000000..8a9ada0
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2025 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.connectivity;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.doReturn;
+
+import android.content.Context;
+import android.os.Build;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.testutils.ConnectivityModuleTest;
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(DevSdkIgnoreRunner.class)
+@SmallTest
+@ConnectivityModuleTest
+public class InterfaceTrackerTest {
+    private static final String TAG = "InterfaceTrackerTest";
+    private static final String TEST_IF_NAME = "wlan10";
+    private static final String TEST_INCORRECT_IF_NAME = "wlan20";
+    private static final int TEST_IF_INDEX = 7;
+
+    @Rule
+    public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
+
+    private InterfaceTracker mInterfaceTracker;
+
+    @Mock Context mContext;
+    @Mock InterfaceTracker.Dependencies mDeps;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        doReturn(TEST_IF_INDEX).when(mDeps).getIfIndex(TEST_IF_NAME);
+        mInterfaceTracker = new InterfaceTracker(mContext, mDeps);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingInterface_InterfaceNameIndexMappingAdded() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingNullInterface_InterfaceNameIndexMappingNotAdded() {
+        mInterfaceTracker.addInterface(null);
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingIncorrectInterface_InterfaceNameIndexMappingNotAdded() {
+        mInterfaceTracker.addInterface(TEST_INCORRECT_IF_NAME);
+
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_INCORRECT_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingInterface_InterfaceNameIndexMappingRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+        mInterfaceTracker.removeInterface(TEST_IF_NAME);
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingNullInterface_InterfaceNameIndexMappingNotRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        mInterfaceTracker.removeInterface(null);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingIncorrectInterface_InterfaceNameIndexMappingNotRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        mInterfaceTracker.removeInterface(TEST_INCORRECT_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+}
diff --git a/tests/unit/java/com/android/server/connectivity/NetworkNotificationManagerTest.java b/tests/unit/java/com/android/server/connectivity/NetworkNotificationManagerTest.java
index 727db58..bcdc4c5 100644
--- a/tests/unit/java/com/android/server/connectivity/NetworkNotificationManagerTest.java
+++ b/tests/unit/java/com/android/server/connectivity/NetworkNotificationManagerTest.java
@@ -27,6 +27,7 @@
 import static com.android.server.connectivity.NetworkNotificationManager.NotificationType.SIGN_IN;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyInt;
@@ -61,6 +62,7 @@
 import android.os.UserHandle;
 import android.telephony.TelephonyManager;
 import android.testing.PollingCheck;
+import android.text.BidiFormatter;
 import android.util.DisplayMetrics;
 import android.util.Log;
 import android.widget.TextView;
@@ -95,13 +97,14 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 
 @RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
 public class NetworkNotificationManagerTest {
 
-    private static final String TEST_SSID = "Test SSID";
+    private static final String TEST_SSID = "?Test SSID+";
     private static final String TEST_EXTRA_INFO = "extra";
     private static final int TEST_NOTIF_ID = 101;
     private static final String TEST_NOTIF_TAG = NetworkNotificationManager.tagFor(TEST_NOTIF_ID);
@@ -166,6 +169,7 @@
     @Mock NetworkAgentInfo mBluetoothNai;
     @Mock NetworkInfo mNetworkInfo;
     @Mock NetworkInfo mEmptyNetworkInfo;
+    @Mock NetworkNotificationManager.Dependencies mDependencies;
     ArgumentCaptor<Notification> mCaptor;
 
     NetworkNotificationManager mManager;
@@ -192,6 +196,7 @@
         doReturn(asUserCtx).when(mCtx).createContextAsUser(eq(UserHandle.ALL), anyInt());
         doReturn(mNotificationManager).when(mCtx)
                 .getSystemService(eq(Context.NOTIFICATION_SERVICE));
+        doReturn(BidiFormatter.getInstance(Locale.US)).when(mDependencies).getBidiFormatter();
         doReturn(TEST_EXTRA_INFO).when(mNetworkInfo).getExtraInfo();
         ConnectivityResources.setResourcesContextForTest(mCtx);
         doReturn(0xFF607D8B).when(mResources).getColor(anyInt(), any());
@@ -209,7 +214,7 @@
             .thenReturn(transportNames);
         when(mResources.getBoolean(R.bool.config_autoCancelNetworkNotifications)).thenReturn(true);
 
-        mManager = new NetworkNotificationManager(mCtx, mTelephonyManager);
+        mManager = new NetworkNotificationManager(mCtx, mTelephonyManager, mDependencies);
     }
 
     @After
@@ -535,6 +540,26 @@
     }
 
     @Test
+    public void testNotificationText_NoInternet_WithSsid() {
+        doReturn(null).when(mNetworkInfo).getExtraInfo();
+        doNotificationTextTest(NO_INTERNET,
+                R.string.wifi_no_internet, TEST_SSID,
+                R.string.wifi_no_internet_detailed);
+    }
+
+    @Test
+    public void testNotificationText_NoInternet_WithRtlSsid() {
+        final BidiFormatter formatter = BidiFormatter.getInstance(Locale.forLanguageTag("ar"));
+        final String wrappedString = formatter.unicodeWrap(TEST_SSID);
+        doReturn(formatter).when(mDependencies).getBidiFormatter();
+        doReturn(null).when(mNetworkInfo).getExtraInfo();
+        assertNotEquals(TEST_SSID, wrappedString);
+        doNotificationTextTest(NO_INTERNET,
+                R.string.wifi_no_internet, wrappedString,
+                R.string.wifi_no_internet_detailed);
+    }
+
+    @Test
     public void testNotificationText_Partial() {
         doNotificationTextTest(PARTIAL_CONNECTIVITY,
                 R.string.network_partial_connectivity, TEST_EXTRA_INFO,
diff --git a/tests/unit/java/com/android/server/connectivity/NetworkPermissionsTest.kt b/tests/unit/java/com/android/server/connectivity/NetworkPermissionsTest.kt
new file mode 100644
index 0000000..8a9d288
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/NetworkPermissionsTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2025 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.connectivity
+
+import android.net.INetd
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class NetworkPermissionsTest {
+    @Test
+    fun test_networkTrafficPerms_correctValues() {
+        assertEquals(NetworkPermissions.PERMISSION_NONE, INetd.PERMISSION_NONE) /* 0 */
+        assertEquals(NetworkPermissions.PERMISSION_NETWORK, INetd.PERMISSION_NETWORK) /* 1 */
+        assertEquals(NetworkPermissions.PERMISSION_SYSTEM, INetd.PERMISSION_SYSTEM) /* 2 */
+        assertEquals(NetworkPermissions.TRAFFIC_PERMISSION_INTERNET, 4)
+        assertEquals(NetworkPermissions.TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS, 8)
+        assertEquals(NetworkPermissions.TRAFFIC_PERMISSION_UNINSTALLED, -1)
+        assertEquals(NetworkPermissions.TRAFFIC_PERMISSION_SDKSANDBOX_LOCALHOST, 16)
+    }
+
+    @Test
+    fun test_noOverridesInFlags() {
+        val permsList = listOf(
+            NetworkPermissions.PERMISSION_NONE,
+            NetworkPermissions.PERMISSION_NETWORK,
+            NetworkPermissions.PERMISSION_SYSTEM,
+            NetworkPermissions.TRAFFIC_PERMISSION_INTERNET,
+            NetworkPermissions.TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS,
+            NetworkPermissions.TRAFFIC_PERMISSION_SDKSANDBOX_LOCALHOST,
+            NetworkPermissions.TRAFFIC_PERMISSION_UNINSTALLED
+        )
+        assertFalse(hasDuplicates(permsList))
+    }
+
+    fun hasDuplicates(list: List<Int>): Boolean {
+        return list.distinct().size != list.size
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/PermissionMonitorTest.java b/tests/unit/java/com/android/server/connectivity/PermissionMonitorTest.java
index 5bde31a..ec9c6b0 100644
--- a/tests/unit/java/com/android/server/connectivity/PermissionMonitorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/PermissionMonitorTest.java
@@ -21,7 +21,9 @@
 import static android.Manifest.permission.CONNECTIVITY_INTERNAL;
 import static android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS;
 import static android.Manifest.permission.INTERNET;
+import static android.Manifest.permission.NEARBY_WIFI_DEVICES;
 import static android.Manifest.permission.NETWORK_STACK;
+import static android.Manifest.permission.OBSERVE_GRANT_REVOKE_PERMISSIONS;
 import static android.Manifest.permission.UPDATE_DEVICE_STATS;
 import static android.content.pm.ApplicationInfo.PRIVATE_FLAG_OEM;
 import static android.content.pm.ApplicationInfo.PRIVATE_FLAG_PRODUCT;
@@ -30,6 +32,7 @@
 import static android.content.pm.PackageInfo.REQUESTED_PERMISSION_REQUIRED;
 import static android.content.pm.PackageManager.GET_PERMISSIONS;
 import static android.content.pm.PackageManager.MATCH_ANY_USER;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
 import static android.net.ConnectivitySettingsManager.UIDS_ALLOWED_ON_RESTRICTED_NETWORKS;
 import static android.net.INetd.PERMISSION_INTERNET;
 import static android.net.INetd.PERMISSION_NETWORK;
@@ -38,15 +41,20 @@
 import static android.net.INetd.PERMISSION_UNINSTALLED;
 import static android.net.INetd.PERMISSION_UPDATE_DEVICE_STATS;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
+import static android.net.connectivity.ConnectivityCompatChanges.RESTRICT_LOCAL_NETWORK;
 import static android.os.Process.SYSTEM_UID;
+import static android.permission.PermissionManager.PERMISSION_GRANTED;
 
 import static com.android.server.connectivity.PermissionMonitor.isHigherNetworkPermission;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 
 import static junit.framework.Assert.fail;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.AdditionalMatchers.aryEq;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -66,6 +74,8 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import android.compat.testing.PlatformCompatChangeRule;
+import android.content.AttributionSource;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -83,7 +93,9 @@
 import android.os.SystemConfigManager;
 import android.os.UserHandle;
 import android.os.UserManager;
+import android.permission.PermissionManager;
 import android.provider.Settings;
+import android.util.ArraySet;
 import android.util.SparseIntArray;
 
 import androidx.annotation.NonNull;
@@ -100,9 +112,13 @@
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
+import libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges;
+
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TestRule;
 import org.junit.runner.RunWith;
 import org.mockito.AdditionalAnswers;
 import org.mockito.ArgumentCaptor;
@@ -119,6 +135,8 @@
 @SmallTest
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
 public class PermissionMonitorTest {
+    @Rule
+    public TestRule compatChangeRule = new PlatformCompatChangeRule();
     private static final int MOCK_USER_ID1 = 0;
     private static final int MOCK_USER_ID2 = 1;
     private static final int MOCK_USER_ID3 = 2;
@@ -160,9 +178,14 @@
     private static final int PERMISSION_TRAFFIC_ALL =
             PERMISSION_INTERNET | PERMISSION_UPDATE_DEVICE_STATS;
     private static final int TIMEOUT_MS = 2_000;
+    // The ACCESS_LOCAL_NETWORK permission is not available yet. For the time being, use
+    // NEARBY_WIFI_DEVICES as a means to develop, for expediency.
+    // TODO(b/375236298): remove this constant when the ACCESS_LOCAL_NETWORK permission is defined.
+    private static final String ACCESS_LOCAL_NETWORK = NEARBY_WIFI_DEVICES;
 
     @Mock private Context mContext;
     @Mock private PackageManager mPackageManager;
+    @Mock private PermissionManager mPermissionManager;
     @Mock private INetd mNetdService;
     @Mock private UserManager mUserManager;
     @Mock private PermissionMonitor.Dependencies mDeps;
@@ -181,6 +204,7 @@
         when(mContext.getPackageManager()).thenReturn(mPackageManager);
         when(mContext.getSystemService(eq(Context.USER_SERVICE))).thenReturn(mUserManager);
         doReturn(List.of(MOCK_USER1)).when(mUserManager).getUserHandles(eq(true));
+        when(mContext.getSystemService(PermissionManager.class)).thenReturn(mPermissionManager);
         when(mContext.getSystemServiceName(SystemConfigManager.class))
                 .thenReturn(Context.SYSTEM_CONFIG_SERVICE);
         when(mContext.getSystemService(Context.SYSTEM_CONFIG_SERVICE))
@@ -293,19 +317,28 @@
         return result;
     }
 
-    private void buildAndMockPackageInfoWithPermissions(String packageName, int uid,
+    private PackageInfo buildAndMockPackageInfoWithPermissions(String packageName, int uid,
             String... permissions) throws Exception {
         final PackageInfo packageInfo = buildPackageInfo(packageName, uid, permissions);
         // This will return the wrong UID for the package when queried with other users.
         doReturn(packageInfo).when(mPackageManager)
                 .getPackageInfo(eq(packageName), anyInt() /* flag */);
+        if (BpfNetMaps.isAtLeast25Q2()) {
+            // Runtime permission checks for local net restrictions were introduced in 25Q2
+            for (String permission : permissions) {
+                doReturn(PERMISSION_GRANTED).when(mPermissionManager).checkPermissionForPreflight(
+                        eq(permission),
+                        argThat(attributionSource -> attributionSource.getUid() == uid));
+            }
+        }
         final String[] oldPackages = mPackageManager.getPackagesForUid(uid);
         // If it's duplicated package, no need to set it again.
-        if (CollectionUtils.contains(oldPackages, packageName)) return;
+        if (CollectionUtils.contains(oldPackages, packageName)) return packageInfo;
 
         // Combine the package if this uid is shared with other packages.
         final String[] newPackages = appendElement(String.class, oldPackages, packageName);
         doReturn(newPackages).when(mPackageManager).getPackagesForUid(eq(uid));
+        return packageInfo;
     }
 
     private void startMonitoring() {
@@ -340,7 +373,7 @@
 
     private void addPackage(String packageName, int uid, String... permissions) throws Exception {
         buildAndMockPackageInfoWithPermissions(packageName, uid, permissions);
-        processOnHandlerThread(() -> mPermissionMonitor.onPackageAdded(packageName, uid));
+        onPackageAdded(packageName, uid);
     }
 
     private void removePackage(String packageName, int uid) {
@@ -352,7 +385,12 @@
         final String[] newPackages = Arrays.stream(oldPackages).filter(e -> !e.equals(packageName))
                 .toArray(String[]::new);
         doReturn(newPackages).when(mPackageManager).getPackagesForUid(eq(uid));
-        processOnHandlerThread(() -> mPermissionMonitor.onPackageRemoved(packageName, uid));
+        if (BpfNetMaps.isAtLeast25Q2()){
+            // Runtime permission checks for local net restrictions were introduced in 25Q2
+            doReturn(PERMISSION_DENIED).when(mPermissionManager).checkPermissionForPreflight(
+                    anyString(), argThat(as -> as.getUid() == uid));
+        }
+        onPackageRemoved(packageName, uid);
     }
 
     @Test
@@ -583,6 +621,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testHasUseBackgroundNetworksPermission() throws Exception {
         assertFalse(mPermissionMonitor.hasUseBackgroundNetworksPermission(SYSTEM_UID));
         assertBackgroundPermission(false, SYSTEM_PACKAGE1, SYSTEM_UID);
@@ -604,6 +643,7 @@
 
     private class BpfMapMonitor {
         private final SparseIntArray mAppIdsTrafficPermission = new SparseIntArray();
+        private final ArraySet<Integer> mLocalNetBlockedUids = new ArraySet<>();
         private static final int DOES_NOT_EXIST = -2;
 
         BpfMapMonitor(BpfNetMaps mockBpfmap) throws Exception {
@@ -616,6 +656,18 @@
                 }
                 return null;
             }).when(mockBpfmap).setNetPermForUids(anyInt(), any(int[].class));
+            doAnswer((InvocationOnMock invocation) -> {
+                final Object[] args = invocation.getArguments();
+                final int uid = (int) args[0];
+                mLocalNetBlockedUids.add(uid);
+                return null;
+            }).when(mockBpfmap).addUidToLocalNetBlockMap(anyInt());
+            doAnswer((InvocationOnMock invocation) -> {
+                final Object[] args = invocation.getArguments();
+                final int uid = (int) args[0];
+                mLocalNetBlockedUids.remove(uid);
+                return null;
+            }).when(mockBpfmap).removeUidFromLocalNetBlockMap(anyInt());
         }
 
         public void expectTrafficPerm(int permission, Integer... appIds) {
@@ -640,6 +692,18 @@
                 }
             }
         }
+
+        public boolean hasLocalNetPermissions(int uid) {
+            return !mLocalNetBlockedUids.contains(uid);
+        }
+
+        public boolean isUidPresentInLocalNetBlockMap(int uid) {
+            return mLocalNetBlockedUids.contains(uid);
+        }
+
+        public boolean hasBlockedLocalNetForSandboxUid(int sandboxUid) {
+            return mLocalNetBlockedUids.contains(sandboxUid);
+        }
     }
 
     private class NetdMonitor {
@@ -723,6 +787,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUserAndPackageAddRemove() throws Exception {
         // MOCK_UID11: MOCK_PACKAGE1 only has network permission.
         // SYSTEM_APP_UID11: SYSTEM_PACKAGE1 has system permission.
@@ -812,6 +877,48 @@
                 MOCK_APPID1);
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_onUserAdded() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        final PackageInfo packageInfo = buildAndMockPackageInfoWithPermissions(
+                MOCK_PACKAGE1, MOCK_UID11, CHANGE_NETWORK_STATE);
+        // Set package for all users on devices
+        doReturn(List.of(packageInfo)).when(mPackageManager)
+                .getInstalledPackagesAsUser(anyInt(), eq(MOCK_USER1.getIdentifier()));
+        onUserAdded(MOCK_USER1);
+
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+        if (hasSdkSandbox(MOCK_UID11)) {
+            assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                    mProcessShim.toSdkSandboxUid(MOCK_UID11)));
+        }
+    }
+
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_onUserRemoved() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        final PackageInfo packageInfo = buildAndMockPackageInfoWithPermissions(
+                MOCK_PACKAGE1, MOCK_UID11, CHANGE_NETWORK_STATE);
+        // Set package for all users on devices
+        doReturn(List.of(packageInfo)).when(mPackageManager)
+                .getInstalledPackagesAsUser(anyInt(), eq(MOCK_USER1.getIdentifier()));
+        onUserAdded(MOCK_USER1);
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+
+        onUserRemoved(MOCK_USER1);
+        assertFalse(mBpfMapMonitor.isUidPresentInLocalNetBlockMap(MOCK_UID11));
+    }
+
     private void doTestUidFilteringDuringVpnConnectDisconnectAndUidUpdates(@Nullable String ifName)
             throws Exception {
         doReturn(List.of(
@@ -858,11 +965,13 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidFilteringDuringVpnConnectDisconnectAndUidUpdates() throws Exception {
         doTestUidFilteringDuringVpnConnectDisconnectAndUidUpdates("tun0");
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidFilteringDuringVpnConnectDisconnectAndUidUpdatesWithWildcard()
             throws Exception {
         doTestUidFilteringDuringVpnConnectDisconnectAndUidUpdates(null /* ifName */);
@@ -895,16 +1004,19 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidFilteringDuringPackageInstallAndUninstall() throws Exception {
         doTestUidFilteringDuringPackageInstallAndUninstall("tun0");
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidFilteringDuringPackageInstallAndUninstallWithWildcard() throws Exception {
         doTestUidFilteringDuringPackageInstallAndUninstall(null /* ifName */);
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testLockdownUidFilteringWithLockdownEnableDisable() {
         doReturn(List.of(
                 buildPackageInfo(SYSTEM_PACKAGE1, SYSTEM_APP_UID11, CHANGE_NETWORK_STATE,
@@ -936,6 +1048,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testLockdownUidFilteringWithLockdownEnableDisableWithMultiAdd() {
         doReturn(List.of(
                 buildPackageInfo(SYSTEM_PACKAGE1, SYSTEM_APP_UID11, CHANGE_NETWORK_STATE,
@@ -977,6 +1090,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testLockdownUidFilteringWithLockdownEnableDisableWithMultiAddAndOverlap() {
         doReturn(List.of(buildPackageInfo(SYSTEM_PACKAGE1, SYSTEM_APP_UID11, CHANGE_NETWORK_STATE,
                         CONNECTIVITY_USE_RESTRICTED_NETWORKS),
@@ -1037,6 +1151,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testLockdownUidFilteringWithLockdownEnableDisableWithDuplicates() {
         doReturn(List.of(
                 buildPackageInfo(SYSTEM_PACKAGE1, SYSTEM_APP_UID11, CHANGE_NETWORK_STATE,
@@ -1071,6 +1186,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testLockdownUidFilteringWithInstallAndUnInstall() {
         doReturn(List.of(
                 buildPackageInfo(SYSTEM_PACKAGE1, SYSTEM_APP_UID11, CHANGE_NETWORK_STATE,
@@ -1107,15 +1223,13 @@
     // called multiple times with the uid corresponding to each user.
     private void addPackageForUsers(UserHandle[] users, String packageName, int appId) {
         for (final UserHandle user : users) {
-            processOnHandlerThread(() ->
-                    mPermissionMonitor.onPackageAdded(packageName, user.getUid(appId)));
+            onPackageAdded(packageName, user.getUid(appId));
         }
     }
 
     private void removePackageForUsers(UserHandle[] users, String packageName, int appId) {
         for (final UserHandle user : users) {
-            processOnHandlerThread(() ->
-                    mPermissionMonitor.onPackageRemoved(packageName, user.getUid(appId)));
+            onPackageRemoved(packageName, user.getUid(appId));
         }
     }
 
@@ -1163,6 +1277,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageInstall() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET, UPDATE_DEVICE_STATS);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_TRAFFIC_ALL, MOCK_APPID1);
@@ -1171,7 +1286,25 @@
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_INTERNET, MOCK_APPID2);
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_onPackageInstall() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET);
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+
+        addPackage(MOCK_PACKAGE2, MOCK_UID12, ACCESS_LOCAL_NETWORK);
+        assertTrue(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID12));
+        if (hasSdkSandbox(MOCK_UID12)) assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                mProcessShim.toSdkSandboxUid(MOCK_UID12)));
+    }
+
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageInstallSharedUid() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET, UPDATE_DEVICE_STATS);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_TRAFFIC_ALL, MOCK_APPID1);
@@ -1183,6 +1316,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageUninstallBasic() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET, UPDATE_DEVICE_STATS);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_TRAFFIC_ALL, MOCK_APPID1);
@@ -1192,7 +1326,24 @@
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_UNINSTALLED, MOCK_APPID1);
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_onPackageUninstall() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        addPackage(MOCK_PACKAGE1, MOCK_UID11, ACCESS_LOCAL_NETWORK);
+        assertTrue(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+
+        when(mPackageManager.getPackagesForUid(MOCK_UID11)).thenReturn(new String[]{});
+        onPackageRemoved(MOCK_PACKAGE1, MOCK_UID11);
+        assertFalse(mBpfMapMonitor.isUidPresentInLocalNetBlockMap(MOCK_UID11));
+    }
+
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageRemoveThenAdd() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET, UPDATE_DEVICE_STATS);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_TRAFFIC_ALL, MOCK_APPID1);
@@ -1205,7 +1356,30 @@
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_INTERNET, MOCK_APPID1);
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_onPackageRemoveThenAdd() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        addPackage(MOCK_PACKAGE1, MOCK_UID11, ACCESS_LOCAL_NETWORK);
+        assertTrue(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+        if (hasSdkSandbox(MOCK_UID12)) assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                mProcessShim.toSdkSandboxUid(MOCK_UID11)));
+
+        removePackage(MOCK_PACKAGE1, MOCK_UID11);
+        assertFalse(mBpfMapMonitor.isUidPresentInLocalNetBlockMap(MOCK_UID11));
+
+        addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET);
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+        if (hasSdkSandbox(MOCK_UID12)) assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                mProcessShim.toSdkSandboxUid(MOCK_UID11)));
+    }
+
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageUpdate() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_NONE, MOCK_APPID1);
@@ -1215,6 +1389,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testPackageUninstallWithMultiplePackages() throws Exception {
         addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET, UPDATE_DEVICE_STATS);
         mBpfMapMonitor.expectTrafficPerm(PERMISSION_TRAFFIC_ALL, MOCK_APPID1);
@@ -1235,8 +1410,10 @@
         // Use the real context as this test must ensure the *real* system package holds the
         // necessary permission.
         final Context realContext = InstrumentationRegistry.getContext();
-        final PermissionMonitor monitor = new PermissionMonitor(
-                realContext, mNetdService, mBpfNetMaps, mHandlerThread);
+        final PermissionMonitor monitor = runAsShell(
+                OBSERVE_GRANT_REVOKE_PERMISSIONS,
+                () -> new PermissionMonitor(realContext, mNetdService, mBpfNetMaps, mHandlerThread)
+        );
         final PackageManager manager = realContext.getPackageManager();
         final PackageInfo systemInfo = manager.getPackageInfo(REAL_SYSTEM_PACKAGE_NAME,
                 GET_PERMISSIONS | MATCH_ANY_USER);
@@ -1244,6 +1421,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUpdateUidPermissionsFromSystemConfig() throws Exception {
         when(mSystemConfigManager.getSystemPermissionUids(eq(INTERNET)))
                 .thenReturn(new int[]{ MOCK_UID11, MOCK_UID12 });
@@ -1283,6 +1461,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testIntentReceiver() throws Exception {
         startMonitoring();
         final BroadcastReceiver receiver = expectBroadcastReceiver(
@@ -1321,6 +1500,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidsAllowedOnRestrictedNetworksChanged() throws Exception {
         startMonitoring();
         final ContentObserver contentObserver = expectRegisterContentObserver(
@@ -1353,6 +1533,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidsAllowedOnRestrictedNetworksChangedWithSharedUid() throws Exception {
         startMonitoring();
         final ContentObserver contentObserver = expectRegisterContentObserver(
@@ -1386,6 +1567,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testUidsAllowedOnRestrictedNetworksChangedWithMultipleUsers() throws Exception {
         startMonitoring();
         final ContentObserver contentObserver = expectRegisterContentObserver(
@@ -1440,6 +1622,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testOnExternalApplicationsAvailable() throws Exception {
         // Initial the permission state. MOCK_PACKAGE1 and MOCK_PACKAGE2 are installed on external
         // and have different uids. There has no permission for both uids.
@@ -1471,6 +1654,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testOnExternalApplicationsAvailable_AppsNotRegisteredOnStartMonitoring()
             throws Exception {
         startMonitoring();
@@ -1498,6 +1682,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testOnExternalApplicationsAvailableWithSharedUid()
             throws Exception {
         // Initial the permission state. MOCK_PACKAGE1 and MOCK_PACKAGE2 are installed on external
@@ -1524,6 +1709,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testOnExternalApplicationsAvailableWithSharedUid_DifferentStorage()
             throws Exception {
         // Initial the permission state. MOCK_PACKAGE1 is installed on external storage and
@@ -1566,6 +1752,38 @@
         assertFalse(isHigherNetworkPermission(PERMISSION_SYSTEM, PERMISSION_SYSTEM));
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
+    public void testLocalNetRestrictions_setPermChanges() throws Exception {
+        assumeTrue(BpfNetMaps.isAtLeast25Q2());
+        doReturn(true).when(mDeps).shouldEnforceLocalNetRestrictions(anyInt());
+        when(mPermissionManager.checkPermissionForPreflight(
+                anyString(), any(AttributionSource.class))).thenReturn(PERMISSION_DENIED);
+        addPackage(MOCK_PACKAGE1, MOCK_UID11, INTERNET);
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+
+        // Mock permission grant
+        when(mPermissionManager.checkPermissionForPreflight(
+                eq(ACCESS_LOCAL_NETWORK),
+                argThat(attributionSource -> attributionSource.getUid() == MOCK_UID11)))
+                .thenReturn(PERMISSION_GRANTED);
+        mPermissionMonitor.setLocalNetworkPermissions(MOCK_UID11, null);
+        assertTrue(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+        if (hasSdkSandbox(MOCK_UID12)) assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                mProcessShim.toSdkSandboxUid(MOCK_UID11)));
+
+        // Mock permission denied
+        when(mPermissionManager.checkPermissionForPreflight(
+                eq(ACCESS_LOCAL_NETWORK),
+                argThat(attributionSource -> attributionSource.getUid() == MOCK_UID11)))
+                .thenReturn(PERMISSION_DENIED);
+        mPermissionMonitor.setLocalNetworkPermissions(MOCK_UID11, null);
+        assertFalse(mBpfMapMonitor.hasLocalNetPermissions(MOCK_UID11));
+        if (hasSdkSandbox(MOCK_UID12)) assertTrue(mBpfMapMonitor.hasBlockedLocalNetForSandboxUid(
+                mProcessShim.toSdkSandboxUid(MOCK_UID11)));
+    }
+
     private void prepareMultiUserPackages() {
         // MOCK_USER1 has installed 3 packages
         // mockApp1 has no permission and share MOCK_APPID1.
@@ -1598,7 +1816,7 @@
 
     private void addUserAndVerifyAppIdsPermissions(UserHandle user, int appId1Perm,
             int appId2Perm, int appId3Perm) {
-        processOnHandlerThread(() -> mPermissionMonitor.onUserAdded(user));
+        onUserAdded(user);
         mBpfMapMonitor.expectTrafficPerm(appId1Perm, MOCK_APPID1);
         mBpfMapMonitor.expectTrafficPerm(appId2Perm, MOCK_APPID2);
         mBpfMapMonitor.expectTrafficPerm(appId3Perm, MOCK_APPID3);
@@ -1606,13 +1824,14 @@
 
     private void removeUserAndVerifyAppIdsPermissions(UserHandle user, int appId1Perm,
             int appId2Perm, int appId3Perm) {
-        processOnHandlerThread(() -> mPermissionMonitor.onUserRemoved(user));
+        onUserRemoved(user);
         mBpfMapMonitor.expectTrafficPerm(appId1Perm, MOCK_APPID1);
         mBpfMapMonitor.expectTrafficPerm(appId2Perm, MOCK_APPID2);
         mBpfMapMonitor.expectTrafficPerm(appId3Perm, MOCK_APPID3);
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testAppIdsTrafficPermission_UserAddedRemoved() {
         prepareMultiUserPackages();
 
@@ -1646,6 +1865,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testAppIdsTrafficPermission_Multiuser_PackageAdded() throws Exception {
         // Add two users with empty package list.
         onUserAdded(MOCK_USER1);
@@ -1716,6 +1936,7 @@
     }
 
     @Test
+    @EnableCompatChanges(RESTRICT_LOCAL_NETWORK)
     public void testAppIdsTrafficPermission_Multiuser_PackageRemoved() throws Exception {
         // Add two users with empty package list.
         onUserAdded(MOCK_USER1);
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt
new file mode 100644
index 0000000..67fb428
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt
@@ -0,0 +1,150 @@
+/*
+ * 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.connectivity.mdns
+
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.testing.TestableLooper
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val DEFAULT_TIMEOUT = 2000L
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class DiscoveryExecutorTest {
+    private val thread = HandlerThread(DiscoveryExecutorTest::class.simpleName).apply { start() }
+    private val handler by lazy { Handler(thread.looper) }
+    private val testableLooper by lazy { TestableLooper(thread.looper) }
+
+    @After
+    fun tearDown() {
+        thread.quitSafely()
+        thread.join()
+    }
+
+    @Test
+    fun testCheckAndRunOnHandlerThread() {
+        val executor = DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        try {
+            val future = CompletableFuture<Boolean>()
+            executor.checkAndRunOnHandlerThread { future.complete(true) }
+            testableLooper.processAllMessages()
+            assertTrue(future.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+
+        // Create a DiscoveryExecutor with the null defaultLooper and verify the task can execute
+        // normally.
+        val executor2 = DiscoveryExecutor(
+                null /* defaultLooper */,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        val future2 = CompletableFuture<Boolean>()
+        executor2.checkAndRunOnHandlerThread { future2.complete(true) }
+        assertTrue(future2.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        executor2.shutDown()
+    }
+
+    private fun verifyExecute(executor: DiscoveryExecutor) {
+        try {
+            val future = CompletableFuture<Boolean>()
+            executor.execute { future.complete(true) }
+            assertFalse(future.isDone)
+            testableLooper.processAllMessages()
+            assertTrue(future.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+
+    @Test
+    fun testExecute() {
+        verifyExecute(DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        ))
+    }
+
+    @Test
+    fun testExecute_RealtimeScheduler() {
+        verifyExecute(DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build()
+        ))
+    }
+
+    @Test
+    fun testExecuteDelayed() {
+        val executor = DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        try {
+            // Verify the executeDelayed method
+            val future = CompletableFuture<Boolean>()
+            // Schedule a task with 999 ms delay
+            executor.executeDelayed({ future.complete(true) }, 999L)
+            testableLooper.processAllMessages()
+            assertFalse(future.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(future.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(future.get(500L, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+
+    @Test
+    fun testExecuteDelayed_RealtimeScheduler() {
+        val executor = DiscoveryExecutor(
+                thread.looper,
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build()
+        )
+        try {
+            // Verify the executeDelayed method
+            val future = CompletableFuture<Boolean>()
+            // Schedule a task with 50ms delay
+            executor.executeDelayed({ future.complete(true) }, 50L)
+            assertTrue(future.get(500L, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
index df48f6c..087617a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -42,17 +42,20 @@
 import java.util.Objects
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.TimeUnit
+import kotlin.test.assertTrue
 import org.junit.After
 import org.junit.Assert.assertEquals
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.anyBoolean
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.any
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.argThat
 import org.mockito.Mockito.atLeastOnce
+import org.mockito.Mockito.doCallRealMethod
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
 import org.mockito.Mockito.never
@@ -89,6 +92,20 @@
     network = TEST_NETWORK_1
 }
 
+private val GOOGLECAST_SERVICE = NsdServiceInfo("TestServiceName", "_googlecast._tcp").apply {
+    subtypes = setOf(TEST_SUBTYPE)
+    port = 12345
+    hostAddresses = listOf(TEST_ADDR)
+    network = TEST_NETWORK_1
+}
+
+private val GOOGLEZONE_SERVICE = NsdServiceInfo("TestServiceName", "_GOOglezone._tcp").apply {
+    subtypes = setOf(TEST_SUBTYPE)
+    port = 12345
+    hostAddresses = listOf(TEST_ADDR)
+    network = TEST_NETWORK_1
+}
+
 private val SERVICE_1_SUBTYPE = NsdServiceInfo("TestServiceName", "_advertisertest._tcp").apply {
     subtypes = setOf(TEST_SUBTYPE)
     port = 12345
@@ -140,6 +157,15 @@
     OffloadEngine.OFFLOAD_TYPE_REPLY.toLong()
 )
 
+private val OFFLOAD_SERVICE_INFO_GOOGLECAST = OffloadServiceInfo(
+    OffloadServiceInfo.Key("TestServiceName", "_googlecast._tcp"),
+    listOf(),
+    "Android_test.local",
+    TEST_OFFLOAD_PACKET1,
+    Int.MAX_VALUE,
+    OffloadEngine.OFFLOAD_TYPE_REPLY.toLong()
+)
+
 private val OFFLOAD_SERVICEINFO_NO_SUBTYPE = OffloadServiceInfo(
     OffloadServiceInfo.Key("TestServiceName", "_advertisertest._tcp"),
     listOf(),
@@ -164,6 +190,10 @@
     "5:_otherprioritytest._tcp"
 )
 
+private val SERVICES_DENY_LIST = arrayOf(
+    "_googlezone._tcp",
+)
+
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsAdvertiserTest {
@@ -185,12 +215,26 @@
     @Before
     fun setUp() {
         thread.start()
-        doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname()
-        doReturn(mockInterfaceAdvertiser1).`when`(mockDeps).makeAdvertiser(eq(mockSocket1),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
+        doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname(anyBoolean())
+        doReturn(mockInterfaceAdvertiser1).`when`(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any()
         )
-        doReturn(mockInterfaceAdvertiser2).`when`(mockDeps).makeAdvertiser(eq(mockSocket2),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
+        doReturn(mockInterfaceAdvertiser2).`when`(mockDeps).makeAdvertiser(
+            eq(mockSocket2),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any()
         )
         doReturn(true).`when`(mockInterfaceAdvertiser1).isProbing(anyInt())
         doReturn(true).`when`(mockInterfaceAdvertiser2).isProbing(anyInt())
@@ -199,16 +243,24 @@
         doReturn(TEST_INTERFACE1).`when`(mockInterfaceAdvertiser1).socketInterfaceName
         doReturn(TEST_INTERFACE2).`when`(mockInterfaceAdvertiser2).socketInterfaceName
         doReturn(TEST_OFFLOAD_PACKET1).`when`(mockInterfaceAdvertiser1).getRawOffloadPayload(
-            SERVICE_ID_1)
+            SERVICE_ID_1
+        )
         doReturn(TEST_OFFLOAD_PACKET1).`when`(mockInterfaceAdvertiser1).getRawOffloadPayload(
-            SERVICE_ID_2)
+            SERVICE_ID_2
+        )
         doReturn(TEST_OFFLOAD_PACKET1).`when`(mockInterfaceAdvertiser1).getRawOffloadPayload(
-            SERVICE_ID_3)
+            SERVICE_ID_3
+        )
         doReturn(TEST_OFFLOAD_PACKET1).`when`(mockInterfaceAdvertiser2).getRawOffloadPayload(
-            SERVICE_ID_1)
+            SERVICE_ID_1
+        )
         doReturn(resources).`when`(context).getResources()
         doReturn(SERVICES_PRIORITY_LIST).`when`(resources).getStringArray(
-            R.array.config_nsdOffloadServicesPriority)
+            R.array.config_nsdOffloadServicesPriority
+        )
+        doReturn(SERVICES_DENY_LIST).`when`(resources).getStringArray(
+            R.array.config_nsdOffloadServicesDenyList
+        )
         ConnectivityResources.setResourcesContextForTest(context)
     }
 
@@ -229,8 +281,12 @@
     fun testAddService_OneNetwork() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, SERVICE_1,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            SERVICE_1,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
         val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
         verify(socketProvider).requestSocket(eq(TEST_NETWORK_1), socketCbCaptor.capture())
@@ -252,7 +308,9 @@
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor.value.onServiceProbingSucceeded(
-                mockInterfaceAdvertiser1, SERVICE_ID_1) }
+            mockInterfaceAdvertiser1,
+            SERVICE_ID_1
+        ) }
         verify(cb).onRegisterServiceSucceeded(eq(SERVICE_ID_1), argThat { it.matches(SERVICE_1) })
         verify(cb).onOffloadStartOrUpdate(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO_NO_SUBTYPE))
 
@@ -293,12 +351,18 @@
     fun testAddService_AllNetworksWithSubType() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, ALL_NETWORKS_SERVICE_SUBTYPE,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            ALL_NETWORKS_SERVICE_SUBTYPE,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
         val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
-        verify(socketProvider).requestSocket(eq(ALL_NETWORKS_SERVICE_SUBTYPE.network),
-                socketCbCaptor.capture())
+        verify(socketProvider).requestSocket(
+            eq(ALL_NETWORKS_SERVICE_SUBTYPE.network),
+            socketCbCaptor.capture()
+        )
 
         val socketCb = socketCbCaptor.value
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
@@ -306,30 +370,56 @@
 
         val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         val intAdvCbCaptor2 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
-        verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any(), any()
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor1.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
         )
-        verify(mockDeps).makeAdvertiser(eq(mockSocket2), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any(), any()
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket2),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor2.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
         )
         verify(mockInterfaceAdvertiser1).addService(
-                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE), any())
+            anyInt(),
+            eq(ALL_NETWORKS_SERVICE_SUBTYPE),
+            any()
+        )
         verify(mockInterfaceAdvertiser2).addService(
-                anyInt(), eq(ALL_NETWORKS_SERVICE_SUBTYPE), any())
+            anyInt(),
+            eq(ALL_NETWORKS_SERVICE_SUBTYPE),
+            any()
+        )
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor1.value.onServiceProbingSucceeded(
-                mockInterfaceAdvertiser1, SERVICE_ID_1) }
+            mockInterfaceAdvertiser1,
+            SERVICE_ID_1
+        ) }
         verify(cb).onOffloadStartOrUpdate(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICEINFO))
 
         // Need both advertisers to finish probing and call onRegisterServiceSucceeded
         verify(cb, never()).onRegisterServiceSucceeded(anyInt(), any())
         doReturn(false).`when`(mockInterfaceAdvertiser2).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor2.value.onServiceProbingSucceeded(
-                mockInterfaceAdvertiser2, SERVICE_ID_1) }
+            mockInterfaceAdvertiser2,
+            SERVICE_ID_1
+        ) }
         verify(cb).onOffloadStartOrUpdate(eq(TEST_INTERFACE2), eq(OFFLOAD_SERVICEINFO))
-        verify(cb).onRegisterServiceSucceeded(eq(SERVICE_ID_1),
-                argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) })
+        verify(cb).onRegisterServiceSucceeded(
+            eq(SERVICE_ID_1),
+            argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) }
+        )
 
         // Services are conflicted.
         postSync {
@@ -375,19 +465,30 @@
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
         postSync {
-            advertiser.addOrUpdateService(SERVICE_ID_1, SERVICE_1, DEFAULT_ADVERTISING_OPTION,
-                    TEST_CLIENT_UID_1)
-            advertiser.addOrUpdateService(SERVICE_ID_2,
+            advertiser.addOrUpdateService(
+                SERVICE_ID_1,
+                SERVICE_1,
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
+            advertiser.addOrUpdateService(
+                SERVICE_ID_2,
                 NsdServiceInfo("TestService2", "_PRIORITYTEST._udp").apply {
                     port = 12345
                     hostAddresses = listOf(TEST_ADDR)
-                }, DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1)
+                },
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
             advertiser.addOrUpdateService(
                 SERVICE_ID_3,
                 NsdServiceInfo("TestService3", "_notprioritized._tcp").apply {
                     port = 12345
                     hostAddresses = listOf(TEST_ADDR)
-                }, DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1)
+                },
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
         }
 
         val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
@@ -397,8 +498,15 @@
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
 
         val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
-        verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-            eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any(), any()
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor1.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
         )
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
@@ -430,30 +538,126 @@
     }
 
     @Test
+    fun testAddService_NoOffloadForServiceTypeInDenyList() {
+        val advertiser =
+            MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+        postSync {
+            advertiser.addOrUpdateService(
+                SERVICE_ID_1,
+                GOOGLEZONE_SERVICE,
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
+        }
+        val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
+        verify(socketProvider).requestSocket(eq(SERVICE_1.network), socketCbCaptor.capture())
+
+        val socketCb = socketCbCaptor.value
+        postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
+
+        val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor1.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
+        )
+
+        doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
+        postSync {
+            intAdvCbCaptor1.value.onServiceProbingSucceeded(mockInterfaceAdvertiser1, SERVICE_ID_1)
+        }
+
+        verify(cb, never()).onOffloadStartOrUpdate(eq(TEST_INTERFACE1), any())
+    }
+
+    @Test
+    fun testAddService_NoSubtypeForGoogleCastOffload() {
+        val advertiser =
+            MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+        postSync {
+            advertiser.addOrUpdateService(
+                SERVICE_ID_1,
+                GOOGLECAST_SERVICE,
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
+        }
+        val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
+        verify(socketProvider).requestSocket(eq(SERVICE_1.network), socketCbCaptor.capture())
+
+        val socketCb = socketCbCaptor.value
+        postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
+
+        val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor1.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
+        )
+
+        doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
+        postSync {
+            intAdvCbCaptor1.value.onServiceProbingSucceeded(mockInterfaceAdvertiser1, SERVICE_ID_1)
+        }
+
+        verify(cb).onOffloadStartOrUpdate(eq(TEST_INTERFACE1), eq(OFFLOAD_SERVICE_INFO_GOOGLECAST))
+    }
+
+    @Test
     fun testAddService_Conflicts() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, SERVICE_1,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            SERVICE_1,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
         val oneNetSocketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
         verify(socketProvider).requestSocket(eq(TEST_NETWORK_1), oneNetSocketCbCaptor.capture())
         val oneNetSocketCb = oneNetSocketCbCaptor.value
 
         // Register a service with the same name on all networks (name conflict)
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_2, ALL_NETWORKS_SERVICE,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_2,
+            ALL_NETWORKS_SERVICE,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
         val allNetSocketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
         verify(socketProvider).requestSocket(eq(null), allNetSocketCbCaptor.capture())
         val allNetSocketCb = allNetSocketCbCaptor.value
 
-        postSync { advertiser.addOrUpdateService(LONG_SERVICE_ID_1, LONG_SERVICE_1,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
-        postSync { advertiser.addOrUpdateService(LONG_SERVICE_ID_2, LONG_ALL_NETWORKS_SERVICE,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            LONG_SERVICE_ID_1,
+            LONG_SERVICE_1,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
+        postSync { advertiser.addOrUpdateService(
+            LONG_SERVICE_ID_2,
+            LONG_ALL_NETWORKS_SERVICE,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
-        postSync { advertiser.addOrUpdateService(CASE_INSENSITIVE_TEST_SERVICE_ID,
-                ALL_NETWORKS_SERVICE_2, DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            CASE_INSENSITIVE_TEST_SERVICE_ID,
+            ALL_NETWORKS_SERVICE_2,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
         // Callbacks for matching network and all networks both get the socket
         postSync {
@@ -462,7 +666,9 @@
         }
 
         val expectedRenamed = NsdServiceInfo(
-                "${ALL_NETWORKS_SERVICE.serviceName} (2)", ALL_NETWORKS_SERVICE.serviceType).apply {
+            "${ALL_NETWORKS_SERVICE.serviceName} (2)",
+            ALL_NETWORKS_SERVICE.serviceType
+        ).apply {
             port = ALL_NETWORKS_SERVICE.port
             hostAddresses = ALL_NETWORKS_SERVICE.hostAddresses
             network = ALL_NETWORKS_SERVICE.network
@@ -470,14 +676,16 @@
 
         val expectedLongRenamed = NsdServiceInfo(
             "${LONG_ALL_NETWORKS_SERVICE.serviceName.dropLast(4)} (2)",
-            LONG_ALL_NETWORKS_SERVICE.serviceType).apply {
+            LONG_ALL_NETWORKS_SERVICE.serviceType
+        ).apply {
             port = LONG_ALL_NETWORKS_SERVICE.port
             hostAddresses = LONG_ALL_NETWORKS_SERVICE.hostAddresses
             network = LONG_ALL_NETWORKS_SERVICE.network
         }
 
         val expectedCaseInsensitiveRenamed = NsdServiceInfo(
-            "${ALL_NETWORKS_SERVICE_2.serviceName} (3)", ALL_NETWORKS_SERVICE_2.serviceType
+            "${ALL_NETWORKS_SERVICE_2.serviceName} (3)",
+            ALL_NETWORKS_SERVICE_2.serviceType
         ).apply {
             port = ALL_NETWORKS_SERVICE_2.port
             hostAddresses = ALL_NETWORKS_SERVICE_2.hostAddresses
@@ -485,30 +693,58 @@
         }
 
         val intAdvCbCaptor = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
-        verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any(), any()
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
         )
-        verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(SERVICE_1) }, any())
-        verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_2),
-                argThat { it.matches(expectedRenamed) }, any())
-        verify(mockInterfaceAdvertiser1).addService(eq(LONG_SERVICE_ID_1),
-                argThat { it.matches(LONG_SERVICE_1) }, any())
-        verify(mockInterfaceAdvertiser1).addService(eq(LONG_SERVICE_ID_2),
-            argThat { it.matches(expectedLongRenamed) }, any())
-        verify(mockInterfaceAdvertiser1).addService(eq(CASE_INSENSITIVE_TEST_SERVICE_ID),
-            argThat { it.matches(expectedCaseInsensitiveRenamed) }, any())
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(SERVICE_ID_1),
+            argThat { it.matches(SERVICE_1) },
+            any()
+        )
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(SERVICE_ID_2),
+            argThat { it.matches(expectedRenamed) },
+            any()
+        )
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(LONG_SERVICE_ID_1),
+            argThat { it.matches(LONG_SERVICE_1) },
+            any()
+        )
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(LONG_SERVICE_ID_2),
+            argThat { it.matches(expectedLongRenamed) },
+            any()
+        )
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(CASE_INSENSITIVE_TEST_SERVICE_ID),
+            argThat { it.matches(expectedCaseInsensitiveRenamed) },
+            any()
+        )
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
         postSync { intAdvCbCaptor.value.onServiceProbingSucceeded(
-                mockInterfaceAdvertiser1, SERVICE_ID_1) }
+            mockInterfaceAdvertiser1,
+            SERVICE_ID_1
+        ) }
         verify(cb).onRegisterServiceSucceeded(eq(SERVICE_ID_1), argThat { it.matches(SERVICE_1) })
 
         doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_2)
         postSync { intAdvCbCaptor.value.onServiceProbingSucceeded(
-                mockInterfaceAdvertiser1, SERVICE_ID_2) }
-        verify(cb).onRegisterServiceSucceeded(eq(SERVICE_ID_2),
-                argThat { it.matches(expectedRenamed) })
+            mockInterfaceAdvertiser1,
+            SERVICE_ID_2
+        ) }
+        verify(cb).onRegisterServiceSucceeded(
+            eq(SERVICE_ID_2),
+            argThat { it.matches(expectedRenamed) }
+        )
 
         postSync { oneNetSocketCb.onInterfaceDestroyed(TEST_SOCKETKEY_1, mockSocket1) }
         postSync { allNetSocketCb.onInterfaceDestroyed(TEST_SOCKETKEY_1, mockSocket1) }
@@ -520,10 +756,21 @@
     @Test
     fun testAddOrUpdateService_Updates() {
         val advertiser =
-                MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags,
-                    context)
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, ALL_NETWORKS_SERVICE,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+                MdnsAdvertiser(
+                    thread.looper,
+                    socketProvider,
+                    cb,
+                    mockDeps,
+                    sharedlog,
+                    flags,
+                    context
+                )
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            ALL_NETWORKS_SERVICE,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
 
         val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
         verify(socketProvider).requestSocket(eq(null), socketCbCaptor.capture())
@@ -531,41 +778,70 @@
         val socketCb = socketCbCaptor.value
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
 
-        verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(ALL_NETWORKS_SERVICE) }, any())
+        verify(mockInterfaceAdvertiser1).addService(
+            eq(SERVICE_ID_1),
+            argThat { it.matches(ALL_NETWORKS_SERVICE) },
+            any()
+        )
 
         val updateOptions = MdnsAdvertisingOptions.newBuilder().setIsOnlyUpdate(true).build()
 
         // Update with serviceId that is not registered yet should fail
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_2, ALL_NETWORKS_SERVICE_SUBTYPE,
-                updateOptions, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_2,
+            ALL_NETWORKS_SERVICE_SUBTYPE,
+            updateOptions,
+            TEST_CLIENT_UID_1
+        ) }
         verify(cb).onRegisterServiceFailed(SERVICE_ID_2, NsdManager.FAILURE_INTERNAL_ERROR)
 
         // Update service with different NsdServiceInfo should fail
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, SERVICE_1_SUBTYPE, updateOptions,
-                TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            SERVICE_1_SUBTYPE,
+            updateOptions,
+            TEST_CLIENT_UID_1
+        ) }
         verify(cb).onRegisterServiceFailed(SERVICE_ID_1, NsdManager.FAILURE_INTERNAL_ERROR)
 
         // Update service with same NsdServiceInfo but different subType should succeed
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, ALL_NETWORKS_SERVICE_SUBTYPE,
-                updateOptions, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            ALL_NETWORKS_SERVICE_SUBTYPE,
+            updateOptions,
+            TEST_CLIENT_UID_1
+        ) }
         verify(mockInterfaceAdvertiser1).updateService(eq(SERVICE_ID_1), eq(setOf(TEST_SUBTYPE)))
 
         // Newly created MdnsInterfaceAdvertiser will get addService() call.
         postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_2, mockSocket2, listOf(TEST_LINKADDR2)) }
-        verify(mockInterfaceAdvertiser2).addService(eq(SERVICE_ID_1),
-                argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) }, any())
+        verify(mockInterfaceAdvertiser2).addService(
+            eq(SERVICE_ID_1),
+            argThat { it.matches(ALL_NETWORKS_SERVICE_SUBTYPE) },
+            any()
+        )
     }
 
     @Test
     fun testAddOrUpdateService_customTtl_registeredSuccess() {
         val advertiser = MdnsAdvertiser(
-                thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+            thread.looper,
+            socketProvider,
+            cb,
+            mockDeps,
+            sharedlog,
+            flags,
+            context
+        )
         val updateOptions =
                 MdnsAdvertisingOptions.newBuilder().setTtl(Duration.ofSeconds(30)).build()
 
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, ALL_NETWORKS_SERVICE,
-                updateOptions, TEST_CLIENT_UID_1) }
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            ALL_NETWORKS_SERVICE,
+            updateOptions,
+            TEST_CLIENT_UID_1
+        ) }
 
         val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
         verify(socketProvider).requestSocket(eq(null), socketCbCaptor.capture())
@@ -578,11 +854,71 @@
     fun testRemoveService_whenAllServiceRemoved_thenUpdateHostName() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
-        verify(mockDeps, times(1)).generateHostname()
-        postSync { advertiser.addOrUpdateService(SERVICE_ID_1, SERVICE_1,
-                DEFAULT_ADVERTISING_OPTION, TEST_CLIENT_UID_1) }
+        verify(mockDeps, times(1)).generateHostname(anyBoolean())
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            SERVICE_1,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
         postSync { advertiser.removeService(SERVICE_ID_1) }
-        verify(mockDeps, times(2)).generateHostname()
+        verify(mockDeps, times(2)).generateHostname(anyBoolean())
+    }
+
+    private fun doHostnameGenerationTest(shortHostname: Boolean): Array<String> {
+        doCallRealMethod().`when`(mockDeps).generateHostname(anyBoolean())
+        val flags = MdnsFeatureFlags.newBuilder().setIsShortHostnamesEnabled(shortHostname).build()
+        val advertiser =
+            MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+        postSync { advertiser.addOrUpdateService(
+            SERVICE_ID_1,
+            SERVICE_1,
+            DEFAULT_ADVERTISING_OPTION,
+            TEST_CLIENT_UID_1
+        ) }
+
+        val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
+        verify(socketProvider).requestSocket(eq(TEST_NETWORK_1), socketCbCaptor.capture())
+
+        val socketCb = socketCbCaptor.value
+        postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
+
+        val hostnameCaptor = ArgumentCaptor.forClass(Array<String>::class.java)
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            any(),
+            hostnameCaptor.capture(),
+            any(),
+            any()
+        )
+        return hostnameCaptor.value
+    }
+
+    @Test
+    fun testShortHostnameGeneration() {
+        val hostname = doHostnameGenerationTest(shortHostname = true)
+        // Short hostnames are [8 uppercase letters or digits].local
+        assertEquals(2, hostname.size)
+        assertTrue(
+            Regex("Android_[A-Z0-9]{8}").matches(hostname[0]),
+            "Unexpected hostname: ${hostname.contentToString()}"
+        )
+        assertEquals("local", hostname[1])
+    }
+
+    @Test
+    fun testLongHostnameGeneration() {
+        val hostname = doHostnameGenerationTest(shortHostname = false)
+        // Long hostnames are Android_[32 lowercase hex characters].local
+        assertEquals(2, hostname.size)
+        assertTrue(
+            Regex("Android_[a-f0-9]{32}").matches(hostname[0]),
+            "Unexpected hostname: ${hostname.contentToString()}"
+        )
+        assertEquals("local", hostname[1])
     }
 
     private fun postSync(r: () -> Unit) {
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 ab2fb99..758b822 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -19,8 +19,6 @@
 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;
@@ -35,12 +33,10 @@
 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;
@@ -60,9 +56,7 @@
 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
@@ -435,48 +429,6 @@
     }
 
     @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();
-        }
-    }
-
-    @Test
     public void testRemoveServicesAfterAllListenersUnregistered() throws IOException {
         final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
                 .setIsCachedServicesRemovalEnabled(true)
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
index 4c71991..e115d5a8 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
@@ -357,6 +357,8 @@
         verify(mProvider).unrequestSocket(callback);
         verify(mSocketCreationCallback).onSocketDestroyed(mSocketKey);
         verify(mSocketCreationCallback).onSocketDestroyed(otherSocketKey);
+        verify(mSocket).removePacketHandler(any());
+        verify(otherSocket).removePacketHandler(any());
     }
 
     @Test
@@ -375,8 +377,10 @@
         // Notify socket destroyed
         callback.onInterfaceDestroyed(mSocketKey, mSocket);
         verify(mSocketCreationCallback).onSocketDestroyed(mSocketKey);
+        verify(mSocket).removePacketHandler(any());
         callback.onInterfaceDestroyed(otherSocketKey, otherSocket);
         verify(mSocketCreationCallback).onSocketDestroyed(otherSocketKey);
+        verify(otherSocket).removePacketHandler(any());
     }
 
     @Test
@@ -413,6 +417,7 @@
         verify(creationCallback2).onSocketDestroyed(mSocketKey);
         verify(creationCallback3).onSocketDestroyed(mSocketKey);
         verify(creationCallback3, never()).onSocketDestroyed(socketKey2);
+        verify(mSocket).removePacketHandler(any());
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
index 0a8f108..2ebe87a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
@@ -21,6 +21,7 @@
 import android.os.HandlerThread
 import com.android.net.module.util.ArrayTrackRecord
 import com.android.server.connectivity.mdns.MdnsServiceCache.CacheKey
+import com.android.server.connectivity.mdns.MdnsServiceCache.CachedService
 import com.android.server.connectivity.mdns.MdnsServiceCacheTest.ExpiredRecord.ExpiredEvent.ServiceRecordExpired
 import com.android.server.connectivity.mdns.util.MdnsUtils
 import com.android.testutils.DevSdkIgnoreRule
@@ -208,7 +209,10 @@
     @Test
     fun testServiceExpiredAndSendCallbacks() {
         val serviceCache = MdnsServiceCache(
-                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+                thread.looper,
+            makeFlags(isExpiredServicesRemovalEnabled = true),
+            clock
+        )
         // Register service expired callbacks
         val callback1 = ExpiredRecord()
         val callback2 = ExpiredRecord()
@@ -218,12 +222,21 @@
         doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
 
         // Add multiple services with different ttl time.
-        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1,
-                DEFAULT_TTL_TIME_MS))
-        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1,
-                DEFAULT_TTL_TIME_MS + 20L))
-        addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_3, SERVICE_TYPE_2,
-                DEFAULT_TTL_TIME_MS + 10L))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(
+            SERVICE_NAME_1,
+            SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS
+        ))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(
+            SERVICE_NAME_2,
+            SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS + 20L
+        ))
+        addOrUpdateService(serviceCache, cacheKey2, createResponse(
+            SERVICE_NAME_3,
+            SERVICE_TYPE_2,
+                DEFAULT_TTL_TIME_MS + 10L
+        ))
 
         // Check the service expiration immediately. Should be no callback.
         assertEquals(2, getServices(serviceCache, cacheKey1).size)
@@ -252,48 +265,65 @@
     @Test
     fun testRemoveExpiredServiceWhenGetting() {
         val serviceCache = MdnsServiceCache(
-                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+                thread.looper,
+            makeFlags(isExpiredServicesRemovalEnabled = true),
+            clock
+        )
 
         doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
-        addOrUpdateService(serviceCache, cacheKey1,
-                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 1L /* ttlTime */))
+        addOrUpdateService(
+            serviceCache,
+            cacheKey1,
+                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 1L /* ttlTime */)
+        )
         doReturn(TEST_ELAPSED_REALTIME_MS + 2L).`when`(clock).elapsedRealtime()
         assertNull(getService(serviceCache, SERVICE_NAME_1, cacheKey1))
 
-        addOrUpdateService(serviceCache, cacheKey2,
-                createResponse(SERVICE_NAME_2, SERVICE_TYPE_2, 3L /* ttlTime */))
+        addOrUpdateService(
+            serviceCache,
+            cacheKey2,
+                createResponse(SERVICE_NAME_2, SERVICE_TYPE_2, 3L /* ttlTime */)
+        )
         doReturn(TEST_ELAPSED_REALTIME_MS + 4L).`when`(clock).elapsedRealtime()
         assertEquals(0, getServices(serviceCache, cacheKey2).size)
     }
 
     @Test
     fun testInsertResponseAndSortList() {
-        val responses = ArrayList<MdnsResponse>()
-        val response1 = createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 100L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response1, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(1, responses.size)
-        assertEquals(response1, responses[0])
+        val services = ArrayList<CachedService>()
+        val service1 = CachedService(
+                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 100L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service1, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(1, services.size)
+        assertEquals(service1, services[0])
 
-        val response2 = createResponse(SERVICE_NAME_2, SERVICE_TYPE_1, 50L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response2, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(2, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response1, responses[1])
+        val service2 = CachedService(
+                createResponse(SERVICE_NAME_2, SERVICE_TYPE_1, 50L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service2, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(2, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service1, services[1])
 
-        val response3 = createResponse(SERVICE_NAME_3, SERVICE_TYPE_1, 75L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response3, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(3, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response3, responses[1])
-        assertEquals(response1, responses[2])
+        val service3 = CachedService(
+                createResponse(SERVICE_NAME_3, SERVICE_TYPE_1, 75L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service3, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(3, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service3, services[1])
+        assertEquals(service1, services[2])
 
-        val response4 = createResponse("service-instance-4", SERVICE_TYPE_1, 125L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response4, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(4, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response3, responses[1])
-        assertEquals(response1, responses[2])
-        assertEquals(response4, responses[3])
+        val service4 = CachedService(
+                createResponse("service-instance-4", SERVICE_TYPE_1, 125L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service4, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(4, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service3, services[1])
+        assertEquals(service1, services[2])
+        assertEquals(service4, services[3])
     }
 
     @Test
@@ -334,8 +364,11 @@
     ): MdnsResponse {
         val serviceName = "$serviceInstanceName.$serviceType".split(".").toTypedArray()
         val response = MdnsResponse(
-                0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
-                socketKey.interfaceIndex, socketKey.network)
+                0 /* now */,
+            "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
+                socketKey.interfaceIndex,
+            socketKey.network
+        )
 
         // Set PTR record
         val pointerRecord = MdnsPointerRecord(
@@ -343,7 +376,8 @@
                 TEST_ELAPSED_REALTIME_MS /* receiptTimeMillis */,
                 false /* cacheFlush */,
                 ttlTime /* ttlMillis */,
-                serviceName)
+                serviceName
+        )
         response.addPointerRecord(pointerRecord)
 
         // Set SRV record.
@@ -355,7 +389,8 @@
                 0 /* servicePriority */,
                 0 /* serviceWeight */,
                 12345 /* port */,
-                arrayOf("hostname"))
+                arrayOf("hostname")
+        )
         response.serviceRecord = serviceRecord
         return response
     }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index da0bc88..b9c0d2f 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -16,13 +16,13 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.server.connectivity.mdns.MdnsQueryScheduler.INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS;
+import static com.android.server.connectivity.mdns.MdnsQueryScheduler.MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS;
+import static com.android.server.connectivity.mdns.MdnsQueryScheduler.TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS;
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.ACTIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.PASSIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsServiceTypeClient.EVENT_START_QUERYTASK;
-import static com.android.server.connectivity.mdns.QueryTaskConfig.INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS;
-import static com.android.server.connectivity.mdns.QueryTaskConfig.MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS;
-import static com.android.server.connectivity.mdns.QueryTaskConfig.TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -127,6 +127,8 @@
     private SharedLog mockSharedLog;
     @Mock
     private MdnsServiceTypeClient.Dependencies mockDeps;
+    @Mock
+    private Scheduler mockScheduler;
     @Captor
     private ArgumentCaptor<MdnsServiceInfo> serviceInfoCaptor;
 
@@ -145,6 +147,7 @@
     private Message delayMessage = null;
     private Handler realHandler = null;
     private MdnsFeatureFlags featureFlags = MdnsFeatureFlags.newBuilder().build();
+    private Message message = null;
 
     @Before
     @SuppressWarnings("DoNotMock")
@@ -244,10 +247,21 @@
             return true;
         }).when(mockDeps).sendMessage(any(Handler.class), any(Message.class));
 
-        client = makeMdnsServiceTypeClient();
+        doAnswer(inv -> {
+            realHandler = (Handler) inv.getArguments()[0];
+            return mockScheduler;
+        }).when(mockDeps).createScheduler(any(Handler.class));
+
+        doAnswer(inv -> {
+            message = (Message) inv.getArguments()[0];
+            latestDelayMs = (long) inv.getArguments()[1];
+            return null;
+        }).when(mockScheduler).sendDelayedMessage(any(), anyLong());
+
+        client = makeMdnsServiceTypeClient(featureFlags);
     }
 
-    private MdnsServiceTypeClient makeMdnsServiceTypeClient() {
+    private MdnsServiceTypeClient makeMdnsServiceTypeClient(MdnsFeatureFlags featureFlags) {
         return new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
                 mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
                 serviceCache, featureFlags);
@@ -566,21 +580,21 @@
 
         // This is the first query. We will ask for unicast response.
         assertTrue(config.expectUnicastResponse);
-        assertEquals(config.transactionId, 1);
+        assertEquals(config.getTransactionId(), 1);
 
         // For the rest of queries in this burst, we will NOT ask for unicast response.
         for (int i = 1; i < MdnsConfigs.queriesPerBurst(); i++) {
-            int oldTransactionId = config.transactionId;
+            int oldTransactionId = config.getTransactionId();
             config = config.getConfigForNextRun(ACTIVE_QUERY_MODE);
             assertFalse(config.expectUnicastResponse);
-            assertEquals(config.transactionId, oldTransactionId + 1);
+            assertEquals(config.getTransactionId(), oldTransactionId + 1);
         }
 
         // This is the first query of a new burst. We will ask for unicast response.
-        int oldTransactionId = config.transactionId;
+        int oldTransactionId = config.getTransactionId();
         config = config.getConfigForNextRun(ACTIVE_QUERY_MODE);
         assertTrue(config.expectUnicastResponse);
-        assertEquals(config.transactionId, oldTransactionId + 1);
+        assertEquals(config.getTransactionId(), oldTransactionId + 1);
     }
 
     @Test
@@ -591,21 +605,21 @@
 
         // This is the first query. We will ask for unicast response.
         assertTrue(config.expectUnicastResponse);
-        assertEquals(config.transactionId, 1);
+        assertEquals(config.getTransactionId(), 1);
 
         // For the rest of queries in this burst, we will NOT ask for unicast response.
         for (int i = 1; i < MdnsConfigs.queriesPerBurst(); i++) {
-            int oldTransactionId = config.transactionId;
+            int oldTransactionId = config.getTransactionId();
             config = config.getConfigForNextRun(ACTIVE_QUERY_MODE);
             assertFalse(config.expectUnicastResponse);
-            assertEquals(config.transactionId, oldTransactionId + 1);
+            assertEquals(config.getTransactionId(), oldTransactionId + 1);
         }
 
         // This is the first query of a new burst. We will NOT ask for unicast response.
-        int oldTransactionId = config.transactionId;
+        int oldTransactionId = config.getTransactionId();
         config = config.getConfigForNextRun(ACTIVE_QUERY_MODE);
         assertFalse(config.expectUnicastResponse);
-        assertEquals(config.transactionId, oldTransactionId + 1);
+        assertEquals(config.getTransactionId(), oldTransactionId + 1);
     }
 
     @Test
@@ -1926,9 +1940,7 @@
 
     @Test
     public void testSendQueryWithKnownAnswers() throws Exception {
-        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
-                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
-                serviceCache,
+        client = makeMdnsServiceTypeClient(
                 MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
 
         doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
@@ -1990,9 +2002,7 @@
 
     @Test
     public void testSendQueryWithSubTypeWithKnownAnswers() throws Exception {
-        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
-                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
-                serviceCache,
+        client = makeMdnsServiceTypeClient(
                 MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
 
         doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
@@ -2114,6 +2124,73 @@
         assertEquals(9680L, latestDelayMs);
     }
 
+    @Test
+    public void sendQueries_AccurateDelayCallback() {
+        client = makeMdnsServiceTypeClient(
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build());
+
+        final int numOfQueriesBeforeBackoff = 2;
+        final MdnsSearchOptions searchOptions = MdnsSearchOptions.newBuilder()
+                .addSubtype(SUBTYPE)
+                .setQueryMode(AGGRESSIVE_QUERY_MODE)
+                .setNumOfQueriesBeforeBackoff(numOfQueriesBeforeBackoff)
+                .build();
+        startSendAndReceive(mockListenerOne, searchOptions);
+        verify(mockScheduler, times(1)).removeDelayedMessage(EVENT_START_QUERYTASK);
+
+        // Verify that the first query has been sent.
+        verifyAndSendQuery(0 /* index */, 0 /* timeInMs */, true /* expectsUnicastResponse */,
+                true /* multipleSocketDiscovery */, 1 /* scheduledCount */,
+                1 /* sendMessageCount */, true /* useAccurateDelayCallback */);
+
+        // Verify that the second query has been sent
+        verifyAndSendQuery(1 /* index */, 0 /* timeInMs */, false /* expectsUnicastResponse */,
+                true /* multipleSocketDiscovery */, 2 /* scheduledCount */,
+                2 /* sendMessageCount */, true /* useAccurateDelayCallback */);
+
+        // Verify that the third query has been sent
+        verifyAndSendQuery(2 /* index */, TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS,
+                false /* expectsUnicastResponse */, true /* multipleSocketDiscovery */,
+                3 /* scheduledCount */, 3 /* sendMessageCount */,
+                true /* useAccurateDelayCallback */);
+
+        // In backoff mode, the current scheduled task will be canceled and reschedule if the
+        // 0.8 * smallestRemainingTtl is larger than time to next run.
+        long currentTime = TEST_TTL / 2 + TEST_ELAPSED_REALTIME;
+        doReturn(currentTime).when(mockDecoderClock).elapsedRealtime();
+        doReturn(true).when(mockScheduler).hasDelayedMessage(EVENT_START_QUERYTASK);
+        processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL), socketKey);
+        // Verify that the message removal occurred.
+        verify(mockScheduler, times(6)).removeDelayedMessage(EVENT_START_QUERYTASK);
+        assertNotNull(message);
+        verifyAndSendQuery(3 /* index */, (long) (TEST_TTL / 2 * 0.8) /* timeInMs */,
+                true /* expectsUnicastResponse */, true /* multipleSocketDiscovery */,
+                5 /* scheduledCount */, 4 /* sendMessageCount */,
+                true /* useAccurateDelayCallback */);
+
+        // Stop sending packets.
+        stopSendAndReceive(mockListenerOne);
+        verify(mockScheduler, times(8)).removeDelayedMessage(EVENT_START_QUERYTASK);
+    }
+
+    @Test
+    public void testTimerFdCloseProperly() {
+        client = makeMdnsServiceTypeClient(
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build());
+
+        // Start query
+        startSendAndReceive(mockListenerOne, MdnsSearchOptions.newBuilder().build());
+        verify(mockScheduler, times(1)).removeDelayedMessage(EVENT_START_QUERYTASK);
+
+        // Stop query and verify the close() method has been called.
+        stopSendAndReceive(mockListenerOne);
+        verify(mockScheduler, times(2)).removeDelayedMessage(EVENT_START_QUERYTASK);
+        verify(mockScheduler).close();
+    }
+
     private static MdnsServiceInfo matchServiceName(String name) {
         return argThat(info -> info.getServiceInstanceName().equals(name));
     }
@@ -2127,9 +2204,22 @@
 
     private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse,
             boolean multipleSocketDiscovery, int scheduledCount) {
-        // Dispatch the message
-        if (delayMessage != null && realHandler != null) {
-            dispatchMessage();
+        verifyAndSendQuery(index, timeInMs, expectsUnicastResponse,
+                multipleSocketDiscovery, scheduledCount, index + 1 /* sendMessageCount */,
+                false /* useAccurateDelayCallback */);
+    }
+
+    private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse,
+            boolean multipleSocketDiscovery, int scheduledCount, int sendMessageCount,
+            boolean useAccurateDelayCallback) {
+        if (useAccurateDelayCallback && message != null && realHandler != null) {
+            runOnHandler(() -> realHandler.dispatchMessage(message));
+            message = null;
+        } else {
+            // Dispatch the message
+            if (delayMessage != null && realHandler != null) {
+                dispatchMessage();
+            }
         }
         assertEquals(timeInMs, latestDelayMs);
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
@@ -2152,11 +2242,15 @@
                         eq(socketKey), eq(false));
             }
         }
-        verify(mockDeps, times(index + 1))
+        verify(mockDeps, times(sendMessageCount))
                 .sendMessage(any(Handler.class), any(Message.class));
         // Verify the task has been scheduled.
-        verify(mockDeps, times(scheduledCount))
-                .sendMessageDelayed(any(Handler.class), any(Message.class), anyLong());
+        if (useAccurateDelayCallback) {
+            verify(mockScheduler, times(scheduledCount)).sendDelayedMessage(any(), anyLong());
+        } else {
+            verify(mockDeps, times(scheduledCount))
+                    .sendMessageDelayed(any(Handler.class), any(Message.class), anyLong());
+        }
     }
 
     private static String[] getTestServiceName(String instanceName) {
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketProviderTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketProviderTest.java
index 1cc9985..f763bae 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketProviderTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketProviderTest.java
@@ -610,6 +610,7 @@
 
     @Test
     public void testSocketCreatedForMulticastInterface() throws Exception {
+        doReturn(true).when(mTestNetworkIfaceWrapper).isPointToPoint();
         doReturn(true).when(mTestNetworkIfaceWrapper).supportsMulticast();
         startMonitoringSockets();
 
@@ -621,18 +622,6 @@
     }
 
     @Test
-    public void testNoSocketCreatedForPTPInterface() throws Exception {
-        doReturn(true).when(mTestNetworkIfaceWrapper).isPointToPoint();
-        startMonitoringSockets();
-
-        final TestSocketCallback testCallback = new TestSocketCallback();
-        runOnHandler(() -> mSocketProvider.requestSocket(TEST_NETWORK, testCallback));
-
-        postNetworkAvailable(TRANSPORT_BLUETOOTH);
-        testCallback.expectedNoCallback();
-    }
-
-    @Test
     public void testNoSocketCreatedForVPNInterface() throws Exception {
         // VPN interfaces generally also have IFF_POINTOPOINT, but even if they don't, they should
         // not be included even with TRANSPORT_WIFI.
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSBpfNetMapsTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSBpfNetMapsTest.kt
index 8155fd0..06cb7ee 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSBpfNetMapsTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSBpfNetMapsTest.kt
@@ -21,7 +21,10 @@
 import android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_DISABLED
 import android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
 import android.net.ConnectivityManager.RESTRICT_BACKGROUND_STATUS_WHITELISTED
+import android.net.InetAddresses
+import android.net.LinkProperties
 import android.os.Build
+import android.os.Build.VERSION_CODES
 import androidx.test.filters.SmallTest
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter
@@ -33,11 +36,32 @@
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.anyBoolean
 import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.atLeastOnce
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.never
 import org.mockito.Mockito.verify
 
+internal val LOCAL_DNS = InetAddresses.parseNumericAddress("224.0.1.2")
+internal val NON_LOCAL_DNS = InetAddresses.parseNumericAddress("76.76.75.75")
+
+private const val IFNAME_1 = "wlan1"
+private const val IFNAME_2 = "wlan2"
+private const val PORT_53 = 53
+private const val PROTOCOL_TCP = 6
+private const val PROTOCOL_UDP = 17
+
+private val lpWithNoLocalDns = LinkProperties().apply {
+    addDnsServer(NON_LOCAL_DNS)
+    interfaceName = IFNAME_1
+}
+
+private val lpWithLocalDns = LinkProperties().apply {
+    addDnsServer(LOCAL_DNS)
+    interfaceName = IFNAME_2
+}
+
 @DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
@@ -69,6 +93,81 @@
         }
     }
 
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    fun testLocalPrefixesUpdatedInBpfMap() {
+        // Connect Wi-Fi network with non-local dns.
+        val wifiAgent = Agent(nc = defaultNc(), lp = lpWithNoLocalDns)
+        wifiAgent.connect()
+
+        // Verify that block rule is added to BpfMap for local prefixes.
+        verify(bpfNetMaps, atLeastOnce()).addLocalNetAccess(any(), eq(IFNAME_1),
+            any(), eq(0), eq(0), eq(false))
+
+        wifiAgent.disconnect()
+        val cellAgent = Agent(nc = defaultNc(), lp = lpWithLocalDns)
+        cellAgent.connect()
+
+        // Verify that block rule is removed from BpfMap for local prefixes.
+        verify(bpfNetMaps, atLeastOnce()).removeLocalNetAccess(any(), eq(IFNAME_1),
+            any(), eq(0), eq(0))
+
+        cellAgent.disconnect()
+    }
+
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    fun testLocalDnsNotUpdatedInBpfMap() {
+        // Connect Wi-Fi network with non-local dns.
+        val wifiAgent = Agent(nc = defaultNc(), lp = lpWithNoLocalDns)
+        wifiAgent.connect()
+
+        // Verify that No allow rule is added to BpfMap since there is no local dns.
+        verify(bpfNetMaps, never()).addLocalNetAccess(any(), any(), any(), any(), any(),
+            eq(true))
+
+        wifiAgent.disconnect()
+        val cellAgent = Agent(nc = defaultNc(), lp = lpWithLocalDns)
+        cellAgent.connect()
+
+        // Verify that No allow rule from port 53 is removed on network change
+        // because no dns was added
+        verify(bpfNetMaps, never()).removeLocalNetAccess(eq(192), eq(IFNAME_1),
+            eq(NON_LOCAL_DNS), any(), eq(PORT_53))
+
+        cellAgent.disconnect()
+    }
+
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    fun testLocalDnsUpdatedInBpfMap() {
+        // Connect Wi-Fi network with one local Dns.
+        val wifiAgent = Agent(nc = defaultNc(), lp = lpWithLocalDns)
+        wifiAgent.connect()
+
+        // Verify that allow rule is added to BpfMap for local dns at port 53,
+        // for TCP(=6) protocol
+        verify(bpfNetMaps, atLeastOnce()).addLocalNetAccess(eq(192), eq(IFNAME_2),
+            eq(LOCAL_DNS), eq(PROTOCOL_TCP), eq(PORT_53), eq(true))
+        // And for UDP(=17) protocol
+        verify(bpfNetMaps, atLeastOnce()).addLocalNetAccess(eq(192), eq(IFNAME_2),
+            eq(LOCAL_DNS), eq(PROTOCOL_UDP), eq(PORT_53), eq(true))
+
+        wifiAgent.disconnect()
+        val cellAgent = Agent(nc = defaultNc(), lp = lpWithNoLocalDns)
+        cellAgent.connect()
+
+        // Verify that allow rule is removed for local dns on network change,
+        // for TCP(=6) protocol
+        verify(bpfNetMaps, atLeastOnce()).removeLocalNetAccess(eq(192), eq(IFNAME_2),
+            eq(LOCAL_DNS), eq(PROTOCOL_TCP), eq(PORT_53))
+        // And for UDP(=17) protocol
+        verify(bpfNetMaps, atLeastOnce()).removeLocalNetAccess(eq(192), eq(IFNAME_2),
+            eq(LOCAL_DNS), eq(PROTOCOL_UDP), eq(PORT_53))
+
+        cellAgent.disconnect()
+    }
+
     private fun mockDataSaverStatus(status: Int) {
         doReturn(status).`when`(context.networkPolicyManager).getRestrictBackgroundStatus(anyInt())
         // While the production code dispatches the intent on the handler thread,
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSDeclaredMethodsForCallbacksTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSDeclaredMethodsForCallbacksTest.kt
index a7083dc..b179aac 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSDeclaredMethodsForCallbacksTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSDeclaredMethodsForCallbacksTest.kt
@@ -150,7 +150,7 @@
         // EXPIRE_LEGACY_REQUEST (=8) is only used in ConnectivityManager and not included.
         // CALLBACK_TRANSITIVE_CALLS_ONLY (=0) is not a callback so not included either.
         assertEquals(
-            "PRECHK|AVAIL|LOSING|LOST|UNAVAIL|NC|LP|SUSP|RESUME|BLK|LOCALINF|0x7fffe101",
+            "PRECHK|AVAIL|LOSING|LOST|UNAVAIL|NC|LP|SUSP|RESUME|BLK|LOCALINF|RES|0x7fffc101",
             ConnectivityService.declaredMethodsFlagsToString(0x7fff_ffff)
         )
         // The toString method and the assertion above need to be updated if constants are added
@@ -158,7 +158,7 @@
             Modifier.isStatic(it.modifiers) && Modifier.isFinal(it.modifiers) &&
                     it.name.startsWith("CALLBACK_")
         }
-        assertEquals(12, constants.size)
+        assertEquals(13, constants.size)
     }
 }
 
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
index 94c68c0..c0965b4 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
@@ -19,7 +19,7 @@
 import android.net.LocalNetworkConfig
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
-import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkCapabilities.TRANSPORT_THREAD
 import android.net.NetworkRequest
 import android.net.NetworkScore
 import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
@@ -42,23 +42,32 @@
     fun testKeepConnectedLocalAgent() {
         deps.setBuildSdk(VERSION_V)
         val nc = NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_WIFI)
+                .addTransportType(TRANSPORT_THREAD)
                 .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
                 .build()
-        val keepConnectedAgent = Agent(nc = nc, score = FromS(NetworkScore.Builder()
-                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                .build()),
-                lnc = FromS(LocalNetworkConfig.Builder().build()))
-        val dontKeepConnectedAgent = Agent(nc = nc,
-                lnc = FromS(LocalNetworkConfig.Builder().build()))
+        val keepConnectedAgent = Agent(
+            nc = nc,
+            score = FromS(
+                    NetworkScore.Builder()
+                            .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                            .build()
+            ),
+            lnc = FromS(LocalNetworkConfig.Builder().build())
+        )
+        val dontKeepConnectedAgent = Agent(
+            nc = nc,
+            lnc = FromS(LocalNetworkConfig.Builder().build())
+        )
         doTestKeepConnected(keepConnectedAgent, dontKeepConnectedAgent)
     }
 
     @Test
     fun testKeepConnectedForTest() {
-        val keepAgent = Agent(score = FromS(NetworkScore.Builder()
-                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
-                .build()))
+        val keepAgent = Agent(score = FromS(
+                NetworkScore.Builder()
+                        .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                        .build()
+        ))
         val dontKeepAgent = Agent()
         doTestKeepConnected(keepAgent, dontKeepAgent)
     }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSL2capProviderTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSL2capProviderTest.kt
new file mode 100644
index 0000000..ee5b4ee
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSL2capProviderTest.kt
@@ -0,0 +1,430 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothServerSocket
+import android.bluetooth.BluetoothSocket
+import android.net.INetworkMonitor
+import android.net.INetworkMonitorCallbacks
+import android.net.IpPrefix
+import android.net.L2capNetworkSpecifier
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_NONE
+import android.net.L2capNetworkSpecifier.ROLE_CLIENT
+import android.net.L2capNetworkSpecifier.ROLE_SERVER
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.MacAddress
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
+import android.net.NetworkRequest
+import android.net.NetworkSpecifier
+import android.net.RouteInfo
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.ParcelFileDescriptor
+import com.android.server.net.L2capNetwork.L2capIpClient
+import com.android.server.net.L2capPacketForwarder
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.RecorderCallback.CallbackEntry.Reserved
+import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.anyNetwork
+import com.android.testutils.waitForIdle
+import java.io.IOException
+import java.util.Optional
+import java.util.concurrent.LinkedBlockingQueue
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.ArgumentMatchers.isNull
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.doThrow
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+
+private const val PSM = 0x85
+private val REMOTE_MAC = byteArrayOf(1, 2, 3, 4, 5, 6)
+private val REQUEST = NetworkRequest.Builder()
+        .addTransportType(TRANSPORT_BLUETOOTH)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
+        .build()
+
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.R)
+@DevSdkIgnoreRunner.MonitorThreadLeak
+class CSL2capProviderTest : CSTest() {
+    private val networkMonitor = mock<INetworkMonitor>()
+
+    private val btAdapter = mock<BluetoothAdapter>()
+    private val btDevice = mock<BluetoothDevice>()
+    private val btServerSocket = mock<BluetoothServerSocket>()
+    private val btSocket = mock<BluetoothSocket>()
+    private val tunInterface = mock<ParcelFileDescriptor>()
+    private val l2capIpClient = mock<L2capIpClient>()
+    private val packetForwarder = mock<L2capPacketForwarder>()
+    private val providerDeps = mock<L2capNetworkProvider.Dependencies>()
+
+    // BlockingQueue does not support put(null) operations, as null is used as an internal sentinel
+    // value. Therefore, use Optional<BluetoothSocket> where an empty optional signals the
+    // BluetoothServerSocket#close() operation.
+    private val acceptQueue = LinkedBlockingQueue<Optional<BluetoothSocket>>()
+
+    private val handlerThread = HandlerThread("CSL2capProviderTest thread").apply { start() }
+    private val registeredCallbacks = ArrayList<TestableNetworkCallback>()
+
+    // Requires Dependencies mock to be setup before creation.
+    private lateinit var provider: L2capNetworkProvider
+
+    @Before
+    fun innerSetUp() {
+        doReturn(btAdapter).`when`(bluetoothManager).getAdapter()
+        doReturn(btServerSocket).`when`(btAdapter).listenUsingInsecureL2capChannel()
+        doReturn(PSM).`when`(btServerSocket).getPsm()
+        doReturn(btDevice).`when`(btAdapter).getRemoteDevice(eq(REMOTE_MAC))
+        doReturn(btSocket).`when`(btDevice).createInsecureL2capChannel(eq(PSM))
+
+        doAnswer {
+            val sock = acceptQueue.take()
+            if (sock == null || !sock.isPresent()) throw IOException()
+            sock.get()
+        }.`when`(btServerSocket).accept()
+
+        doAnswer {
+            acceptQueue.put(Optional.empty())
+        }.`when`(btServerSocket).close()
+
+        doReturn(handlerThread).`when`(providerDeps).getHandlerThread()
+        doReturn(tunInterface).`when`(providerDeps).createTunInterface(any())
+        doReturn(packetForwarder).`when`(providerDeps)
+                .createL2capPacketForwarder(any(), any(), any(), any(), any())
+        doReturn(l2capIpClient).`when`(providerDeps).createL2capIpClient(any(), any(), any())
+
+        val lp = LinkProperties()
+        val ifname = "l2cap-tun0"
+        lp.setInterfaceName(ifname)
+        lp.addLinkAddress(LinkAddress("fe80::1/64"))
+        lp.addRoute(RouteInfo(IpPrefix("fe80::/64"), null /* nextHop */, ifname))
+        doReturn(lp).`when`(l2capIpClient).start()
+
+        // Note: In order to properly register a NetworkAgent, a NetworkMonitor must be created for
+        // the agent. CSAgentWrapper already does some of this, but requires adding additional
+        // Dependencies to the production code. Create a mocked NM inside this test instead.
+        doAnswer { i ->
+            val cb = i.arguments[2] as INetworkMonitorCallbacks
+            cb.onNetworkMonitorCreated(networkMonitor)
+        }.`when`(networkStack).makeNetworkMonitor(
+                any() /* network */,
+                isNull() /* name */,
+                any() /* callbacks */
+        )
+
+        provider = L2capNetworkProvider(providerDeps, context)
+        provider.start()
+    }
+
+    @After
+    fun innerTearDown() {
+        // Unregistering a callback which has previously been unregistered by virtue of receiving
+        // onUnavailable is a noop.
+        registeredCallbacks.forEach { cm.unregisterNetworkCallback(it) }
+        // Wait for CS handler idle, meaning the unregisterNetworkCallback has been processed and
+        // L2capNetworkProvider has been notified.
+        waitForIdle()
+
+        // While quitSafely() effectively waits for idle, it is not enough, because the tear down
+        // path itself posts on the handler thread. This means that waitForIdle() needs to run
+        // twice. The first time, to ensure all active threads have been joined, and the second time
+        // to run all associated clean up actions.
+        handlerThread.waitForIdle(HANDLER_TIMEOUT_MS)
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    private fun reserveNetwork(nr: NetworkRequest) = TestableNetworkCallback().also {
+        cm.reserveNetwork(nr, csHandler, it)
+        registeredCallbacks.add(it)
+    }
+
+    private fun requestNetwork(nr: NetworkRequest) = TestableNetworkCallback().also {
+        cm.requestNetwork(nr, it, csHandler)
+        registeredCallbacks.add(it)
+    }
+
+    private fun NetworkRequest.copyWithSpecifier(specifier: NetworkSpecifier): NetworkRequest {
+        // Note: NetworkRequest.Builder(NetworkRequest) *does not* perform a defensive copy but
+        // changes the underlying request.
+        return NetworkRequest.Builder(NetworkRequest(this))
+                .setNetworkSpecifier(specifier)
+                .build()
+    }
+
+    @Test
+    fun testReservation() {
+        val l2capServerSpecifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        val l2capReservation = REQUEST.copyWithSpecifier(l2capServerSpecifier)
+        val reservationCb = reserveNetwork(l2capReservation)
+
+        val reservedCaps = reservationCb.expect<Reserved>().caps
+        val reservedSpec = reservedCaps.networkSpecifier as L2capNetworkSpecifier
+
+        assertEquals(PSM, reservedSpec.getPsm())
+        assertEquals(HEADER_COMPRESSION_6LOWPAN, reservedSpec.headerCompression)
+        assertNull(reservedSpec.remoteAddress)
+
+        reservationCb.assertNoCallback()
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithoutSpecifier() {
+        reserveNetwork(REQUEST).assertNoCallback()
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithCorrectSpecifier() {
+        var specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        var nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).expect<Reserved>()
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .build()
+        nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).expect<Reserved>()
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithIncorrectSpecifier() {
+        var specifier = L2capNetworkSpecifier.Builder().build()
+        var nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).assertNoCallback()
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .build()
+        nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).assertNoCallback()
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setPsm(0x81)
+                .build()
+        nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).assertNoCallback()
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .build()
+        nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).assertNoCallback()
+    }
+
+    @Test
+    fun testBluetoothException_listenUsingInsecureL2capChannelThrows() {
+        doThrow(IOException()).`when`(btAdapter).listenUsingInsecureL2capChannel()
+        var specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        var nr = REQUEST.copyWithSpecifier(specifier)
+        reserveNetwork(nr).expect<Unavailable>()
+
+        doReturn(btServerSocket).`when`(btAdapter).listenUsingInsecureL2capChannel()
+        reserveNetwork(nr).expect<Reserved>()
+    }
+
+    @Test
+    fun testBluetoothException_acceptThrows() {
+        doThrow(IOException()).`when`(btServerSocket).accept()
+        var specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        var nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = reserveNetwork(nr)
+        cb.expect<Reserved>()
+        cb.expect<Unavailable>()
+
+        // BluetoothServerSocket#close() puts Optional.empty() on the acceptQueue.
+        acceptQueue.clear()
+        doAnswer {
+            val sock = acceptQueue.take()
+            assertFalse(sock.isPresent())
+            throw IOException() // to indicate the socket was closed.
+        }.`when`(btServerSocket).accept()
+        val cb2 = reserveNetwork(nr)
+        cb2.expect<Reserved>()
+        cb2.assertNoCallback()
+    }
+
+    @Test
+    fun testServerNetwork() {
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = reserveNetwork(nr)
+        cb.expect<Reserved>()
+
+        // Unblock BluetoothServerSocket#accept()
+        doReturn(true).`when`(btSocket).isConnected()
+        acceptQueue.put(Optional.of(btSocket))
+
+        cb.expectAvailableCallbacks(anyNetwork(), validated = false)
+        cb.assertNoCallback()
+        // Verify that packet forwarding was started.
+        // TODO: stop mocking L2capPacketForwarder.
+        verify(providerDeps).createL2capPacketForwarder(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun testBluetoothException_createInsecureL2capChannelThrows() {
+        doThrow(IOException()).`when`(btDevice).createInsecureL2capChannel(any())
+
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+
+        cb.expect<Unavailable>()
+    }
+
+    @Test
+    fun testBluetoothException_bluetoothSocketConnectThrows() {
+        doThrow(IOException()).`when`(btSocket).connect()
+
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+
+        cb.expect<Unavailable>()
+    }
+
+    @Test
+    fun testClientNetwork() {
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+        cb.expectAvailableCallbacks(anyNetwork(), validated = false)
+    }
+
+    @Test
+    fun testClientNetwork_headerCompressionMismatch() {
+        var specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        var nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+        cb.expectAvailableCallbacks(anyNetwork(), validated = false)
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        nr = REQUEST.copyWithSpecifier(specifier)
+        val cb2 = requestNetwork(nr)
+        cb2.expect<Unavailable>()
+    }
+
+    @Test
+    fun testClientNetwork_multipleRequests() {
+        val specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_CLIENT)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+                .setPsm(PSM)
+                .build()
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+        cb.expectAvailableCallbacks(anyNetwork(), validated = false)
+
+        val cb2 = requestNetwork(nr)
+        cb2.expectAvailableCallbacks(anyNetwork(), validated = false)
+    }
+
+    /** Test to ensure onLost() is sent before onUnavailable() when the network is torn down. */
+    @Test
+    fun testClientNetwork_gracefulTearDown() {
+        val specifier = L2capNetworkSpecifier.Builder()
+            .setRole(ROLE_CLIENT)
+            .setHeaderCompression(HEADER_COMPRESSION_NONE)
+            .setRemoteAddress(MacAddress.fromBytes(REMOTE_MAC))
+            .setPsm(PSM)
+            .build()
+
+        val nr = REQUEST.copyWithSpecifier(specifier)
+        val cb = requestNetwork(nr)
+        cb.expectAvailableCallbacks(anyNetwork(), validated = false)
+
+        // Capture the L2capPacketForwarder callback object to tear down the network.
+        val handlerCaptor = ArgumentCaptor.forClass(Handler::class.java)
+        val forwarderCbCaptor = ArgumentCaptor.forClass(L2capPacketForwarder.ICallback::class.java)
+        verify(providerDeps).createL2capPacketForwarder(
+                handlerCaptor.capture(), any(), any(), any(), forwarderCbCaptor.capture())
+        val handler = handlerCaptor.value
+        val forwarderCb = forwarderCbCaptor.value
+
+        // Trigger a forwarding error
+        handler.post { forwarderCb.onError() }
+        handler.waitForIdle(HANDLER_TIMEOUT_MS)
+
+        cb.expect<Lost>()
+        cb.expect<Unavailable>()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
index cb98454..6dc9d2d 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
@@ -18,14 +18,11 @@
 
 import android.content.pm.PackageManager.FEATURE_LEANBACK
 import android.net.INetd
-import android.net.LocalNetworkConfig
 import android.net.NativeNetworkConfig
 import android.net.NativeNetworkType
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
 import android.net.NetworkRequest
-import android.net.NetworkScore
-import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
 import android.net.VpnManager
 import android.os.Build
 import androidx.test.filters.SmallTest
@@ -46,57 +43,63 @@
 private const val TIMEOUT_MS = 2_000L
 private const val NO_CALLBACK_TIMEOUT_MS = 200L
 
-private fun keepConnectedScore() =
-        FromS(NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build())
-
-private fun defaultLnc() = FromS(LocalNetworkConfig.Builder().build())
-
 @DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
 @IgnoreUpTo(Build.VERSION_CODES.R)
-class CSLocalAgentCreationTests(
-        private val sdkLevel: Int,
-        private val isTv: Boolean,
-        private val addLocalNetCapToRequest: Boolean
-) : CSTest() {
+class CSLocalAgentCreationTests : CSTest() {
+    @Parameterized.Parameter(0) lateinit var params: TestParams
+
+    data class TestParams(
+            val sdkLevel: Int,
+            val isTv: Boolean = false,
+            val addLocalNetCapToRequest: Boolean = true
+    )
+
     companion object {
         @JvmStatic
         @Parameterized.Parameters
         fun arguments() = listOf(
-                arrayOf(VERSION_V, false /* isTv */, true /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_V, false /* isTv */, false /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_V, true /* isTv */, true /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_V, true /* isTv */, false /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_U, false /* isTv */, true /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_U, false /* isTv */, false /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_U, true /* isTv */, true /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_U, true /* isTv */, false /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_T, false /* isTv */, false /* addLocalNetCapToRequest */),
-                arrayOf(VERSION_T, true /* isTv */, false /* addLocalNetCapToRequest */),
+                TestParams(VERSION_V, isTv = false, addLocalNetCapToRequest = true),
+                TestParams(VERSION_V, isTv = false, addLocalNetCapToRequest = false),
+                TestParams(VERSION_V, isTv = true, addLocalNetCapToRequest = true),
+                TestParams(VERSION_V, isTv = true, addLocalNetCapToRequest = false),
+                TestParams(VERSION_U, isTv = false, addLocalNetCapToRequest = true),
+                TestParams(VERSION_U, isTv = false, addLocalNetCapToRequest = false),
+                TestParams(VERSION_U, isTv = true, addLocalNetCapToRequest = true),
+                TestParams(VERSION_U, isTv = true, addLocalNetCapToRequest = false),
+                TestParams(VERSION_T, isTv = false, addLocalNetCapToRequest = false),
+                TestParams(VERSION_T, isTv = true, addLocalNetCapToRequest = false),
         )
     }
 
     private fun makeNativeNetworkConfigLocal(netId: Int, permission: Int) =
-            NativeNetworkConfig(netId, NativeNetworkType.PHYSICAL_LOCAL, permission,
-                    false /* secure */, VpnManager.TYPE_VPN_NONE, false /* excludeLocalRoutes */)
+            NativeNetworkConfig(
+                    netId,
+                    NativeNetworkType.PHYSICAL_LOCAL,
+                    permission,
+                    false /* secure */,
+                    VpnManager.TYPE_VPN_NONE,
+                    false /* excludeLocalRoutes */
+            )
 
     @Test
     fun testLocalAgents() {
         val netdInOrder = inOrder(netd)
-        deps.setBuildSdk(sdkLevel)
-        doReturn(isTv).`when`(packageManager).hasSystemFeature(FEATURE_LEANBACK)
+        deps.setBuildSdk(params.sdkLevel)
+        doReturn(params.isTv).`when`(packageManager).hasSystemFeature(FEATURE_LEANBACK)
         val allNetworksCb = TestableNetworkCallback()
         val request = NetworkRequest.Builder()
-        if (addLocalNetCapToRequest) {
+        if (params.addLocalNetCapToRequest) {
             request.addCapability(NET_CAPABILITY_LOCAL_NETWORK)
         }
         cm.registerNetworkCallback(request.build(), allNetworksCb)
         val ncTemplate = NetworkCapabilities.Builder().run {
-            addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+            addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
             addCapability(NET_CAPABILITY_LOCAL_NETWORK)
         }.build()
-        val localAgent = if (sdkLevel >= VERSION_V || sdkLevel == VERSION_U && isTv) {
+        val localAgent = if (params.sdkLevel >= VERSION_V ||
+                params.sdkLevel == VERSION_U && params.isTv) {
             Agent(nc = ncTemplate, score = keepConnectedScore(), lnc = defaultLnc())
         } else {
             assertFailsWith<IllegalArgumentException> { Agent(nc = ncTemplate, lnc = defaultLnc()) }
@@ -105,8 +108,9 @@
         }
         localAgent.connect()
         netdInOrder.verify(netd).networkCreate(
-                makeNativeNetworkConfigLocal(localAgent.network.netId, INetd.PERMISSION_NONE))
-        if (addLocalNetCapToRequest) {
+                makeNativeNetworkConfigLocal(localAgent.network.netId, INetd.PERMISSION_NONE)
+        )
+        if (params.addLocalNetCapToRequest) {
             assertEquals(localAgent.network, allNetworksCb.expect<Available>().network)
         } else {
             allNetworksCb.assertNoCallback(NO_CALLBACK_TIMEOUT_MS)
@@ -119,10 +123,12 @@
     @Test
     fun testBadAgents() {
         assertFailsWith<IllegalArgumentException> {
-            Agent(nc = NetworkCapabilities.Builder()
-                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                    .build(),
-                    lnc = null)
+            Agent(
+                    nc = NetworkCapabilities.Builder()
+                            .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                            .build(),
+                    lnc = null
+            )
         }
         assertFailsWith<IllegalArgumentException> {
             Agent(nc = NetworkCapabilities.Builder().build(), lnc = defaultLnc())
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
index 83fff87..9468d54 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -39,6 +39,7 @@
 import android.net.NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK
 import android.net.RouteInfo
 import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_LOCAL_NETWORK
+import android.net.connectivity.ConnectivityCompatChanges.ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS
 import android.os.Build
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
@@ -54,9 +55,7 @@
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.never
 import org.mockito.Mockito.timeout
-import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
-import org.mockito.Mockito.verifyNoMoreInteractions
 
 private const val TIMEOUT_MS = 200L
 private const val MEDIUM_TIMEOUT_MS = 1_000L
@@ -113,55 +112,67 @@
         deps.setBuildSdk(VERSION_V)
 
         assertFailsWith<IllegalArgumentException> {
-            Agent(nc = NetworkCapabilities.Builder()
-                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                    .build(),
-                    lnc = null)
+            Agent(
+                    nc = NetworkCapabilities.Builder()
+                            .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                            .build(),
+                    lnc = null
+            )
         }
         assertFailsWith<IllegalArgumentException> {
-            Agent(nc = NetworkCapabilities.Builder().build(),
-                    lnc = FromS(LocalNetworkConfig.Builder().build()))
+            Agent(
+                    nc = NetworkCapabilities.Builder().build(),
+                    lnc = FromS(LocalNetworkConfig.Builder().build())
+            )
         }
     }
 
     @Test
     fun testStructuralConstraintViolation() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
 
         val cb = TestableNetworkCallback()
-        cm.requestNetwork(NetworkRequest.Builder()
-                .clearCapabilities()
-                .build(),
-                cb)
-        val agent = Agent(nc = NetworkCapabilities.Builder()
-                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                .build(),
-                lnc = FromS(LocalNetworkConfig.Builder().build()))
+        cm.requestNetwork(
+                NetworkRequest.Builder()
+                        .clearCapabilities()
+                        .build(),
+                cb
+        )
+        val agent = Agent(
+                nc = NetworkCapabilities.Builder()
+                        .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                        .build(),
+                lnc = FromS(LocalNetworkConfig.Builder().build())
+        )
         agent.connect()
         cb.expectAvailableCallbacks(agent.network, validated = false)
         agent.sendNetworkCapabilities(NetworkCapabilities.Builder().build())
         cb.expect<Lost>(agent.network)
 
-        val agent2 = Agent(nc = NetworkCapabilities.Builder()
-                .build(),
-                lnc = null)
+        val agent2 = Agent(nc = NetworkCapabilities.Builder().build(), lnc = null)
         agent2.connect()
         cb.expectAvailableCallbacks(agent2.network, validated = false)
-        agent2.sendNetworkCapabilities(NetworkCapabilities.Builder()
-                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                .build())
+        agent2.sendNetworkCapabilities(
+                NetworkCapabilities.Builder()
+                        .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                        .build()
+        )
         cb.expect<Lost>(agent2.network)
     }
 
     @Test
     fun testUpdateLocalAgentConfig() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
 
         val cb = TestableNetworkCallback()
-        cm.requestNetwork(NetworkRequest.Builder()
-                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                .build(),
-                cb)
+        cm.requestNetwork(
+                NetworkRequest.Builder()
+                        .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                        .build(),
+                cb
+        )
 
         // Set up a local agent that should forward its traffic to the best DUN upstream.
         val localAgent = Agent(
@@ -173,15 +184,20 @@
 
         cb.expectAvailableCallbacks(localAgent.network, validated = false)
 
-        val wifiAgent = Agent(score = keepScore(), lp = lp("wifi0"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        val wifiAgent = Agent(
+                score = keepScore(),
+                lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
         wifiAgent.connect()
 
         val newLnc = LocalNetworkConfig.Builder()
-                .setUpstreamSelector(NetworkRequest.Builder()
-                        .addTransportType(TRANSPORT_WIFI)
-                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build())
+                .setUpstreamSelector(
+                        NetworkRequest.Builder()
+                                .addTransportType(TRANSPORT_WIFI)
+                                .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                                .build()
+                )
                 .build()
         localAgent.sendLocalNetworkConfig(newLnc)
 
@@ -209,21 +225,29 @@
                 nc = nc(TRANSPORT_THREAD, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp(name),
                 lnc = localNetworkConfig,
-                score = FromS(NetworkScore.Builder()
+                score = FromS(
+                    NetworkScore.Builder()
                         .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build())
+                        .build()
+                )
         )
         return localAgent
     }
 
     private fun createWifiAgent(name: String): CSAgentWrapper {
-        return Agent(score = keepScore(), lp = lp(name),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        return Agent(
+                score = keepScore(),
+                lp = lp(name),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
     }
 
     private fun createCellAgent(name: String): CSAgentWrapper {
-        return Agent(score = keepScore(), lp = lp(name),
-                nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET))
+        return Agent(
+                score = keepScore(),
+                lp = lp(name),
+                nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET)
+        )
     }
 
     private fun sendLocalNetworkConfig(
@@ -247,11 +271,12 @@
         cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
         val inOrder = inOrder(multicastRoutingCoordinatorService)
 
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(upstreamSelectorWifi)
-                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                .build()
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(upstreamSelectorWifi)
+                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                        .build()
         )
         val localAgent = createLocalAgent("local0", lnc)
         localAgent.connect()
@@ -266,9 +291,15 @@
         }
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "wifi0", multicastRoutingConfigMinScope)
+                "local0",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local0", multicastRoutingConfigSelected)
+                "wifi0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         wifiAgent.disconnect()
 
@@ -284,11 +315,12 @@
     fun testMulticastRoutingConfig_2LocalNetworks() {
         deps.setBuildSdk(VERSION_V)
         val inOrder = inOrder(multicastRoutingCoordinatorService)
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(upstreamSelectorWifi)
-                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                .build()
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(upstreamSelectorWifi)
+                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                        .build()
         )
         val localAgent0 = createLocalAgent("local0", lnc)
         localAgent0.connect()
@@ -298,18 +330,30 @@
         waitForIdle()
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "wifi0", multicastRoutingConfigMinScope)
+                "local0",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local0", multicastRoutingConfigSelected)
+                "wifi0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         val localAgent1 = createLocalAgent("local1", lnc)
         localAgent1.connect()
         waitForIdle()
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local1", "wifi0", multicastRoutingConfigMinScope)
+                "local1",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local1", multicastRoutingConfigSelected)
+                "wifi0",
+                "local1",
+                multicastRoutingConfigSelected
+        )
 
         localAgent0.disconnect()
         localAgent1.disconnect()
@@ -320,15 +364,19 @@
     fun testMulticastRoutingConfig_UpstreamNetworkCellToWifi() {
         deps.setBuildSdk(VERSION_V)
         val cb = TestableNetworkCallback()
-        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities()
+        cm.registerNetworkCallback(
+                NetworkRequest.Builder().clearCapabilities()
                         .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build(), cb)
+                        .build(),
+            cb
+        )
         val inOrder = inOrder(multicastRoutingCoordinatorService)
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(upstreamSelectorAny)
-                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                .build()
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(upstreamSelectorAny)
+                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                        .build()
         )
         val localAgent = createLocalAgent("local0", lnc)
         val wifiAgent = createWifiAgent("wifi0")
@@ -343,9 +391,15 @@
         }
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "cell0", multicastRoutingConfigMinScope)
+                "local0",
+                "cell0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "cell0", "local0", multicastRoutingConfigSelected)
+                "cell0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         wifiAgent.connect()
 
@@ -359,9 +413,15 @@
         inOrder.verify(multicastRoutingCoordinatorService)
                 .applyMulticastRoutingConfig("cell0", "local0", CONFIG_FORWARD_NONE)
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "wifi0", multicastRoutingConfigMinScope)
+                "local0",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local0", multicastRoutingConfigSelected)
+                "wifi0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         localAgent.disconnect()
         cellAgent.disconnect()
@@ -371,16 +431,21 @@
     @Test
     fun testMulticastRoutingConfig_UpstreamSelectorCellToWifi() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         val cb = TestableNetworkCallback()
-        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities()
+        cm.registerNetworkCallback(
+                NetworkRequest.Builder().clearCapabilities()
                         .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build(), cb)
+                        .build(),
+                cb
+        )
         val inOrder = inOrder(multicastRoutingCoordinatorService)
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(upstreamSelectorCell)
-                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                .build()
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(upstreamSelectorCell)
+                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                        .build()
         )
         val localAgent = createLocalAgent("local0", lnc)
         val wifiAgent = createWifiAgent("wifi0")
@@ -395,12 +460,22 @@
         }
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "cell0", multicastRoutingConfigMinScope)
+                "local0",
+                "cell0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "cell0", "local0", multicastRoutingConfigSelected)
+                "cell0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
-        sendLocalNetworkConfig(localAgent, upstreamSelectorWifi, multicastRoutingConfigMinScope,
-                multicastRoutingConfigSelected)
+        sendLocalNetworkConfig(
+                localAgent,
+                upstreamSelectorWifi,
+                multicastRoutingConfigMinScope,
+                multicastRoutingConfigSelected
+        )
         cb.expect<LocalInfoChanged>(localAgent.network) {
             it.info.upstreamNetwork == wifiAgent.network
         }
@@ -411,9 +486,15 @@
         inOrder.verify(multicastRoutingCoordinatorService)
                 .applyMulticastRoutingConfig("cell0", "local0", CONFIG_FORWARD_NONE)
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "wifi0", multicastRoutingConfigMinScope)
+                "local0",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local0", multicastRoutingConfigSelected)
+                "wifi0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         localAgent.disconnect()
         cellAgent.disconnect()
@@ -423,16 +504,21 @@
     @Test
     fun testMulticastRoutingConfig_UpstreamSelectorWifiToNull() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         val cb = TestableNetworkCallback()
-        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities()
+        cm.registerNetworkCallback(
+                NetworkRequest.Builder().clearCapabilities()
                         .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build(), cb)
+                        .build(),
+                cb
+        )
         val inOrder = inOrder(multicastRoutingCoordinatorService)
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(upstreamSelectorWifi)
-                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                .build()
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(upstreamSelectorWifi)
+                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                        .build()
         )
         val localAgent = createLocalAgent("local0", lnc)
         localAgent.connect()
@@ -444,12 +530,22 @@
         }
 
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", "wifi0", multicastRoutingConfigMinScope)
+                "local0",
+                "wifi0",
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "wifi0", "local0", multicastRoutingConfigSelected)
+                "wifi0",
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
-        sendLocalNetworkConfig(localAgent, null, multicastRoutingConfigMinScope,
-                multicastRoutingConfigSelected)
+        sendLocalNetworkConfig(
+                localAgent,
+                null,
+                multicastRoutingConfigMinScope,
+                multicastRoutingConfigSelected
+        )
         cb.expect<LocalInfoChanged>(localAgent.network) {
             it.info.upstreamNetwork == null
         }
@@ -460,9 +556,15 @@
         inOrder.verify(multicastRoutingCoordinatorService)
                 .applyMulticastRoutingConfig("wifi0", "local0", CONFIG_FORWARD_NONE)
         inOrder.verify(multicastRoutingCoordinatorService, never()).applyMulticastRoutingConfig(
-                eq("local0"), any(), eq(multicastRoutingConfigMinScope))
+                eq("local0"),
+                any(),
+                eq(multicastRoutingConfigMinScope)
+        )
         inOrder.verify(multicastRoutingCoordinatorService, never()).applyMulticastRoutingConfig(
-                any(), eq("local0"), eq(multicastRoutingConfigSelected))
+                any(),
+                eq("local0"),
+                eq(multicastRoutingConfigSelected)
+        )
 
         localAgent.disconnect()
         wifiAgent.disconnect()
@@ -480,27 +582,35 @@
 
     fun doTestUnregisterUpstreamAfterReplacement(sameIfaceName: Boolean) {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         val cb = TestableNetworkCallback()
         cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
 
         // Set up a local agent that should forward its traffic to the best wifi upstream.
-        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp("local0"),
-                lnc = FromS(LocalNetworkConfig.Builder()
-                        .setUpstreamSelector(upstreamSelectorWifi)
-                        .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
-                        .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
-                        .build()),
-                score = FromS(NetworkScore.Builder()
-                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build())
+                lnc = FromS(
+                        LocalNetworkConfig.Builder()
+                                .setUpstreamSelector(upstreamSelectorWifi)
+                                .setUpstreamMulticastRoutingConfig(multicastRoutingConfigMinScope)
+                                .setDownstreamMulticastRoutingConfig(multicastRoutingConfigSelected)
+                                .build()
+                ),
+                score = FromS(
+                        NetworkScore.Builder()
+                                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                                .build()
+                )
         )
         localAgent.connect()
 
         cb.expectAvailableCallbacks(localAgent.network, validated = false)
 
-        val wifiAgent = Agent(lp = lp("wifi0"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        val wifiAgent = Agent(
+                lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
         wifiAgent.connect()
 
         cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
@@ -521,8 +631,10 @@
         inOrder.verify(netd).networkDestroy(wifiAgent.network.netId)
 
         val wifiIface2 = if (sameIfaceName) "wifi0" else "wifi1"
-        val wifiAgent2 = Agent(lp = lp(wifiIface2),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        val wifiAgent2 = Agent(
+                lp = lp(wifiIface2),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
         wifiAgent2.connect()
 
         cb.expectAvailableCallbacks(wifiAgent2.network, validated = false)
@@ -531,9 +643,15 @@
 
         inOrder.verify(netd).ipfwdAddInterfaceForward("local0", wifiIface2)
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                "local0", wifiIface2, multicastRoutingConfigMinScope)
+                "local0",
+                wifiIface2,
+                multicastRoutingConfigMinScope
+        )
         inOrder.verify(multicastRoutingCoordinatorService).applyMulticastRoutingConfig(
-                wifiIface2, "local0", multicastRoutingConfigSelected)
+                wifiIface2,
+                "local0",
+                multicastRoutingConfigSelected
+        )
 
         inOrder.verify(netd, never()).ipfwdRemoveInterfaceForward(any(), any())
         inOrder.verify(multicastRoutingCoordinatorService, never())
@@ -545,28 +663,40 @@
     @Test
     fun testUnregisterUpstreamAfterReplacement_neverReplaced() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         val cb = TestableNetworkCallback()
         cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
 
         // Set up a local agent that should forward its traffic to the best wifi upstream.
-        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp("local0"),
-                lnc = FromS(LocalNetworkConfig.Builder()
-                        .setUpstreamSelector(NetworkRequest.Builder()
-                                .addTransportType(TRANSPORT_WIFI)
-                                .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                                .build())
-                        .build()),
-                score = FromS(NetworkScore.Builder()
-                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build())
+                lnc = FromS(
+                        LocalNetworkConfig.Builder()
+                                .setUpstreamSelector(
+                                        NetworkRequest.Builder()
+                                                .addTransportType(TRANSPORT_WIFI)
+                                                .addForbiddenCapability(
+                                                        NET_CAPABILITY_LOCAL_NETWORK
+                                                )
+                                                .build()
+                                )
+                                .build()
+                ),
+                score = FromS(
+                        NetworkScore.Builder()
+                                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                                .build()
+                )
         )
         localAgent.connect()
 
         cb.expectAvailableCallbacks(localAgent.network, validated = false)
 
-        val wifiAgent = Agent(lp = lp("wifi0"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+        val wifiAgent = Agent(
+                lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
         wifiAgent.connect()
 
         cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
@@ -587,23 +717,30 @@
     @Test
     fun testUnregisterLocalAgentAfterReplacement() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
 
         val localCb = TestableNetworkCallback()
-        cm.requestNetwork(NetworkRequest.Builder().clearCapabilities()
-                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                .build(),
-                localCb)
+        cm.requestNetwork(
+                NetworkRequest.Builder().clearCapabilities()
+                        .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                        .build(),
+                localCb
+        )
 
         val cb = TestableNetworkCallback()
         cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
 
         val localNc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK)
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(NetworkRequest.Builder()
-                        .addTransportType(TRANSPORT_WIFI)
-                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build())
-                .build())
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(
+                                NetworkRequest.Builder()
+                                        .addTransportType(TRANSPORT_WIFI)
+                                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                                        .build()
+                        )
+                        .build()
+        )
         val localScore = FromS(NetworkScore.Builder().build())
 
         // Set up a local agent that should forward its traffic to the best wifi upstream.
@@ -630,16 +767,23 @@
         val localAgent2 = Agent(nc = localNc, lp = lp("local0"), lnc = lnc, score = localScore)
         localAgent2.connect()
 
-        localCb.expectAvailableCallbacks(localAgent2.network,
-                validated = false, upstream = wifiAgent.network)
-        cb.expectAvailableCallbacks(localAgent2.network,
-                validated = false, upstream = wifiAgent.network)
+        localCb.expectAvailableCallbacks(
+                localAgent2.network,
+                validated = false,
+                upstream = wifiAgent.network
+        )
+        cb.expectAvailableCallbacks(
+                localAgent2.network,
+                validated = false,
+                upstream = wifiAgent.network
+        )
         cb.expect<Lost> { it.network == localAgent.network }
     }
 
     @Test
     fun testDestroyedNetworkAsSelectedUpstream() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         val cb = TestableNetworkCallback()
         cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
 
@@ -650,24 +794,36 @@
         // Unregister wifi pending replacement, then set up a local agent that would have
         // this network as its upstream.
         wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
-        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp("local0"),
-                lnc = FromS(LocalNetworkConfig.Builder()
-                        .setUpstreamSelector(NetworkRequest.Builder()
-                                .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                                .addTransportType(TRANSPORT_WIFI)
-                                .build())
-                        .build()),
-                score = FromS(NetworkScore.Builder()
-                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build())
+                lnc = FromS(
+                        LocalNetworkConfig.Builder()
+                                .setUpstreamSelector(
+                                        NetworkRequest.Builder()
+                                                .addForbiddenCapability(
+                                                        NET_CAPABILITY_LOCAL_NETWORK
+                                                )
+                                                .addTransportType(TRANSPORT_WIFI)
+                                                .build()
+                                )
+                                .build()
+                ),
+                score = FromS(
+                        NetworkScore.Builder()
+                                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                                .build()
+                )
         )
 
         // Connect the local agent. The zombie wifi is its upstream, but the stack doesn't
         // tell netd to add the forward since the wifi0 interface has gone.
         localAgent.connect()
-        cb.expectAvailableCallbacks(localAgent.network,
-                validated = false, upstream = wifiAgent.network)
+        cb.expectAvailableCallbacks(
+                localAgent.network,
+                validated = false,
+                upstream = wifiAgent.network
+        )
 
         verify(netd, never()).ipfwdAddInterfaceForward("local0", "wifi0")
 
@@ -680,34 +836,53 @@
     @Test
     fun testForwardingRules() {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
         // Set up a local agent that should forward its traffic to the best DUN upstream.
-        val lnc = FromS(LocalNetworkConfig.Builder()
-                .setUpstreamSelector(NetworkRequest.Builder()
-                        .addCapability(NET_CAPABILITY_DUN)
-                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .build())
-                .build())
-        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+        val lnc = FromS(
+                LocalNetworkConfig.Builder()
+                        .setUpstreamSelector(
+                                NetworkRequest.Builder()
+                                        .addCapability(NET_CAPABILITY_DUN)
+                                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                                        .build()
+                        )
+                        .build()
+        )
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp("local0"),
                 lnc = lnc,
-                score = FromS(NetworkScore.Builder()
-                        .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build())
+                score = FromS(
+                        NetworkScore.Builder()
+                                .setKeepConnectedReason(KEEP_CONNECTED_LOCAL_NETWORK)
+                                .build()
+                )
         )
         localAgent.connect()
 
-        val wifiAgent = Agent(score = keepScore(), lp = lp("wifi0"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
-        val cellAgentDun = Agent(score = keepScore(), lp = lp("cell0"),
-                nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
-        val wifiAgentDun = Agent(score = keepScore(), lp = lp("wifi1"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+        val wifiAgent = Agent(
+                score = keepScore(),
+                lp = lp("wifi0"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET)
+        )
+        val cellAgentDun = Agent(
+                score = keepScore(),
+                lp = lp("cell0"),
+                nc = nc(TRANSPORT_CELLULAR, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN)
+        )
+        val wifiAgentDun = Agent(
+                score = keepScore(),
+                lp = lp("wifi1"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN)
+        )
 
         val cb = TestableNetworkCallback()
-        cm.registerNetworkCallback(NetworkRequest.Builder()
-                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                .build(),
-                cb)
+        cm.registerNetworkCallback(
+                NetworkRequest.Builder()
+                        .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                        .build(),
+                cb
+        )
         cb.expectAvailableCallbacks(localAgent.network, validated = false)
 
         val inOrder = inOrder(netd)
@@ -752,8 +927,11 @@
         inOrder.verify(netd).ipfwdDisableForwarding(any())
         cb.expect<LocalInfoChanged>(localAgent.network) { it.info.upstreamNetwork == null }
 
-        val wifiAgentDun2 = Agent(score = keepScore(), lp = lp("wifi2"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+        val wifiAgentDun2 = Agent(
+                score = keepScore(),
+                lp = lp("wifi2"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN)
+        )
         wifiAgentDun2.connect()
         inOrder.verify(netd).ipfwdEnableForwarding(any())
         inOrder.verify(netd).ipfwdAddInterfaceForward("local0", "wifi2")
@@ -766,8 +944,11 @@
         inOrder.verify(netd).ipfwdDisableForwarding(any())
         cb.expect<LocalInfoChanged>(localAgent.network) { it.info.upstreamNetwork == null }
 
-        val wifiAgentDun3 = Agent(score = keepScore(), lp = lp("wifi3"),
-                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN))
+        val wifiAgentDun3 = Agent(
+                score = keepScore(),
+                lp = lp("wifi3"),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET, NET_CAPABILITY_DUN)
+        )
         wifiAgentDun3.connect()
         inOrder.verify(netd).ipfwdEnableForwarding(any())
         inOrder.verify(netd).ipfwdAddInterfaceForward("local0", "wifi3")
@@ -794,6 +975,7 @@
 
     fun doTestLocalNetworkUnwanted(haveUpstream: Boolean) {
         deps.setBuildSdk(VERSION_V)
+        deps.setChangeIdEnabled(true, ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS)
 
         val nr = NetworkRequest.Builder().addCapability(NET_CAPABILITY_LOCAL_NETWORK).build()
         val requestCb = TestableNetworkCallback()
@@ -802,8 +984,11 @@
         cm.registerNetworkCallback(nr, listenCb)
 
         val upstream = if (haveUpstream) {
-            Agent(score = keepScore(), lp = lp("wifi0"),
-                    nc = nc(TRANSPORT_WIFI)).also { it.connect() }
+            Agent(
+                    score = keepScore(),
+                    lp = lp("wifi0"),
+                    nc = nc(TRANSPORT_WIFI)
+            ).also { it.connect() }
         } else {
             null
         }
@@ -811,23 +996,32 @@
         // Set up a local agent.
         val lnc = FromS(LocalNetworkConfig.Builder().apply {
             if (haveUpstream) {
-                setUpstreamSelector(NetworkRequest.Builder()
-                        .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
-                        .addTransportType(TRANSPORT_WIFI)
-                        .build())
+                setUpstreamSelector(
+                        NetworkRequest.Builder()
+                                .addForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                                .addTransportType(TRANSPORT_WIFI)
+                                .build()
+                )
             }
         }.build())
-        val localAgent = Agent(nc = nc(TRANSPORT_THREAD, NET_CAPABILITY_LOCAL_NETWORK),
+        val localAgent = Agent(
+                nc = nc(TRANSPORT_THREAD, NET_CAPABILITY_LOCAL_NETWORK),
                 lp = lp("local0"),
                 lnc = lnc,
                 score = FromS(NetworkScore.Builder().build())
         )
         localAgent.connect()
 
-        requestCb.expectAvailableCallbacks(localAgent.network,
-                validated = false, upstream = upstream?.network)
-        listenCb.expectAvailableCallbacks(localAgent.network,
-                validated = false, upstream = upstream?.network)
+        requestCb.expectAvailableCallbacks(
+                localAgent.network,
+                validated = false,
+                upstream = upstream?.network
+        )
+        listenCb.expectAvailableCallbacks(
+                localAgent.network,
+                validated = false,
+                upstream = upstream?.network
+        )
 
         cm.unregisterNetworkCallback(requestCb)
 
@@ -868,12 +1062,14 @@
         doTestLocalNetworkRequest(
                 request,
                 enableMatchLocalNetwork = false,
-                expectCallback = false)
+                expectCallback = false
+        )
         // If ENABLE_MATCH_LOCAL_NETWORK is true, request is satisfied by local network
         doTestLocalNetworkRequest(
                 request,
                 enableMatchLocalNetwork = true,
-                expectCallback = true)
+                expectCallback = true
+        )
     }
 
     @Test
@@ -882,10 +1078,94 @@
         doTestLocalNetworkRequest(
                 request,
                 enableMatchLocalNetwork = false,
-                expectCallback = true)
+                expectCallback = true
+        )
         doTestLocalNetworkRequest(
                 request,
                 enableMatchLocalNetwork = true,
-                expectCallback = true)
+                expectCallback = true
+        )
+    }
+
+    @Test
+    fun testNonThreadLocalAgentMatches_disabled() {
+        doTestNonThreadLocalAgentMatches(
+                VERSION_V,
+                enableMatchNonThreadLocalNetworks = false,
+                TRANSPORT_WIFI,
+                expectAvailable = false
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_V,
+                enableMatchNonThreadLocalNetworks = false,
+                TRANSPORT_THREAD,
+                expectAvailable = true
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_B,
+                enableMatchNonThreadLocalNetworks = false,
+                TRANSPORT_WIFI,
+                expectAvailable = false
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_B,
+                enableMatchNonThreadLocalNetworks = false,
+                TRANSPORT_THREAD,
+                expectAvailable = true
+        )
+    }
+
+    @Test
+    fun testNonThreadLocalAgentMatches_enabled() {
+        doTestNonThreadLocalAgentMatches(
+                VERSION_V,
+                enableMatchNonThreadLocalNetworks = true,
+                TRANSPORT_WIFI,
+                expectAvailable = true
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_V,
+                enableMatchNonThreadLocalNetworks = true,
+                TRANSPORT_THREAD,
+                expectAvailable = true
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_B,
+                enableMatchNonThreadLocalNetworks = true,
+                TRANSPORT_WIFI,
+                expectAvailable = true
+        )
+        doTestNonThreadLocalAgentMatches(
+                VERSION_B,
+                enableMatchNonThreadLocalNetworks = true,
+                TRANSPORT_THREAD,
+                expectAvailable = true
+        )
+    }
+
+    private fun doTestNonThreadLocalAgentMatches(
+            sdkLevel: Int,
+            enableMatchNonThreadLocalNetworks: Boolean,
+            transport: Int,
+            expectAvailable: Boolean
+    ) {
+        deps.setBuildSdk(sdkLevel)
+        deps.setChangeIdEnabled(
+                enableMatchNonThreadLocalNetworks,
+                ENABLE_MATCH_NON_THREAD_LOCAL_NETWORKS
+        )
+
+        val localNcTemplate = NetworkCapabilities.Builder().run {
+            addTransportType(transport)
+            addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+        }.build()
+        val localAgent = Agent(
+                nc = localNcTemplate,
+                score = keepConnectedScore(),
+                lnc = defaultLnc()
+        )
+        // The implementation inside connect() will expect the OnAvailable.
+        localAgent.connect(expectAvailable)
+        localAgent.disconnect(expectAvailable)
     }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt
new file mode 100644
index 0000000..84c9835
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt
@@ -0,0 +1,590 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License")
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.InetAddresses
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+
+private const val LONG_TIMEOUT_MS = 5_000
+private const val PREFIX_LENGTH_IPV4 = 32 + 96
+private const val PREFIX_LENGTH_IPV6 = 32
+private const val WIFI_IFNAME = "wlan0"
+private const val WIFI_IFNAME_2 = "wlan1"
+private const val WIFI_IFNAME_3 = "wlan2"
+
+private val wifiNc = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_WIFI)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .build()
+
+private fun lp(iface: String, vararg linkAddresses: LinkAddress) = LinkProperties().apply {
+    interfaceName = iface
+    for (linkAddress in linkAddresses) {
+        addLinkAddress(linkAddress)
+    }
+}
+
+private fun nr(transport: Int) = NetworkRequest.Builder()
+        .clearCapabilities()
+        .addTransportType(transport).apply {
+            if (transport != TRANSPORT_VPN) {
+                addCapability(NET_CAPABILITY_NOT_VPN)
+            }
+        }.build()
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+class CSLocalNetworkProtectionTest : CSTest() {
+    private val LOCAL_IPV6_IP_ADDRESS_PREFIX = IpPrefix("fe80::1cf1:35ff:fe8c:db87/64")
+    private val LOCAL_IPV6_LINK_ADDRESS = LinkAddress(
+        LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress(),
+        LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()
+    )
+
+    private val LOCAL_IPV6_IP_ADDRESS_2_PREFIX =
+            IpPrefix("2601:19b:67f:e220:1cf1:35ff:fe8c:db87/64")
+    private val LOCAL_IPV6_LINK_ADDRESS_2 = LinkAddress(
+            LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getAddress(),
+            LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getPrefixLength()
+    )
+
+    private val LOCAL_IPV6_IP_ADDRESS_3_PREFIX =
+            IpPrefix("fe80::/10")
+    private val LOCAL_IPV6_LINK_ADDRESS_3 = LinkAddress(
+            LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getAddress(),
+            LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getPrefixLength()
+    )
+
+    private val LOCAL_IPV4_IP_ADDRESS_PREFIX_1 = IpPrefix("10.0.0.184/24")
+    private val LOCAL_IPV4_LINK_ADDRRESS_1 =
+        LinkAddress(
+            LOCAL_IPV4_IP_ADDRESS_PREFIX_1.getAddress(),
+            LOCAL_IPV4_IP_ADDRESS_PREFIX_1.getPrefixLength()
+        )
+
+    private val LOCAL_IPV4_IP_ADDRESS_PREFIX_2 = IpPrefix("10.0.255.184/24")
+    private val LOCAL_IPV4_LINK_ADDRRESS_2 =
+        LinkAddress(
+            LOCAL_IPV4_IP_ADDRESS_PREFIX_2.getAddress(),
+            LOCAL_IPV4_IP_ADDRESS_PREFIX_2.getPrefixLength()
+        )
+
+    @Test
+    fun testNetworkWithIPv6LocalAddress_AddressAddedToBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        // Connecting to network with IPv6 local address in LinkProperties
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+    }
+
+    @Test
+    fun testNetworkWithIPv4LocalAddress_AddressAddedToBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV4_LINK_ADDRRESS_1)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 8),
+            eq(WIFI_IFNAME),
+            eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+    }
+
+    @Test
+    fun testChangeLinkPropertiesWithDifferentLinkAddresses_AddressReplacedInBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+
+        // Updating Link Property from IPv6 in Link Address to IPv4 in Link Address
+        val wifiLp2 = lp(WIFI_IFNAME, LOCAL_IPV4_LINK_ADDRRESS_1)
+        wifiAgent.sendLinkProperties(wifiLp2)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 8),
+            eq(WIFI_IFNAME),
+            eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+        // Verifying IPv6 address should be removed from local_net_access map
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0)
+        )
+    }
+
+    @Test
+    fun testAddingThenRemovingStackedLinkProperties_AddressAddedThenRemovedInBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
+        // Adding stacked link
+        wifiLp.addStackedLink(wifiLp2)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+
+        // Multicast and Broadcast address should always be populated on stacked link
+        // in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
+        // in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // As both addresses are in stacked links, so no address should be removed from the map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
+
+        // replacing link properties without stacked links
+        val wifiLp_3 = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        wifiAgent.sendLinkProperties(wifiLp_3)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // As both stacked links is removed, 10.0.0.0/8 should be removed from local_net_access map.
+        verify(bpfNetMaps).removeLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0)
+        )
+    }
+
+    @Test
+    fun testChangeStackedLinkProperties_AddressReplacedBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
+        // populating stacked link
+        wifiLp.addStackedLink(wifiLp2)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+
+        // Multicast and Broadcast address should always be populated on stacked link
+        // in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
+        // in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // As both addresses are in stacked links, so no address should be removed from the map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
+
+        // replacing link properties multiple stacked links
+        val wifiLp_3 = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS_2)
+        val wifiLp_4 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_2)
+        val wifiLp_5 = lp(WIFI_IFNAME_3, LOCAL_IPV6_LINK_ADDRESS_3)
+        wifiLp_3.addStackedLink(wifiLp_4)
+        wifiLp_3.addStackedLink(wifiLp_5)
+        wifiAgent.sendLinkProperties(wifiLp_3)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // Multicast and Broadcast address should always be populated on stacked link
+        // in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_3)
+        // Verifying new base IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
+        // in local_net_access map
+        verify(bpfNetMaps, times(2)).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying newly stacked IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME_3),
+                eq(LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying old base IPv6 address should be removed from local_net_access map
+        verify(bpfNetMaps).removeLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+                eq(0),
+                eq(0)
+        )
+        // As both stacked links is had same prefix, 10.0.0.0/8 should not be removed from
+        // local_net_access map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0)
+        )
+    }
+
+    @Test
+    fun testChangeLinkPropertiesWithLinkAddressesInSameRange_AddressIntactInBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV4_LINK_ADDRRESS_1)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 8),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV4_IP_ADDRESS_PREFIX_1.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+
+        // Updating Link Property from one IPv4 to another IPv4 within same range(10.0.0.0/8)
+        val wifiLp2 = lp(WIFI_IFNAME, LOCAL_IPV4_LINK_ADDRRESS_2)
+        wifiAgent.sendLinkProperties(wifiLp2)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // As both addresses below to same range, so no address should be removed from the map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun testChangeLinkPropertiesWithDifferentInterface_AddressReplacedInBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+
+        // Updating Link Property by changing interface name which has IPv4 instead of IPv6
+        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
+        wifiAgent.sendLinkProperties(wifiLp2)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // Multicast and Broadcast address should be populated in local_net_access map for
+        // new interface
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 8),
+            eq(WIFI_IFNAME_2),
+            eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+        // Multicast and Broadcast address should be removed in local_net_access map for
+        // old interface
+        verifyRemovalOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be removed from local_net_access map
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0)
+        )
+    }
+
+    @Test
+    fun testAddingAnotherNetwork_AllAddressesAddedInBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+
+        // Adding another network with LinkProperty having IPv4 in LinkAddress
+        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
+        val wifiAgent2 = Agent(nc = wifiNc, lp = wifiLp2)
+        wifiAgent2.connect()
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 8),
+            eq(WIFI_IFNAME_2),
+            eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+        // Verifying nothing should be removed from local_net_access map
+        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
+    }
+
+    @Test
+    fun testDestroyingNetwork_AddressesRemovedFromBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq( PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+
+        // Unregistering the network
+        wifiAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+        cb.expect<Lost>(wifiAgent.network)
+
+        // Multicast and Broadcast address should be removed in local_net_access map for
+        // old interface
+        verifyRemovalOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be removed from local_net_access map
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+            eq(WIFI_IFNAME),
+            eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+            eq(0),
+            eq(0)
+        )
+    }
+
+    // Verify if multicast and broadcast addresses have been added using addLocalNetAccess
+    fun verifyPopulationOfMulticastAndBroadcastAddress(
+        interfaceName: String = WIFI_IFNAME
+    ) {
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 4),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("224.0.0.0")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + 8),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("ff00::")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+        verify(bpfNetMaps).addLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 32),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("255.255.255.255")),
+            eq(0),
+            eq(0),
+            eq(false)
+        )
+    }
+
+    // Verify if multicast and broadcast addresses have been removed using removeLocalNetAccess
+    fun verifyRemovalOfMulticastAndBroadcastAddress(
+        interfaceName: String = WIFI_IFNAME
+    ) {
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 4),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("224.0.0.0")),
+            eq(0),
+            eq(0)
+        )
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV6 + 8),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("ff00::")),
+            eq(0),
+            eq(0)
+        )
+        verify(bpfNetMaps).removeLocalNetAccess(
+            eq(PREFIX_LENGTH_IPV4 + 32),
+            eq(interfaceName),
+            eq(InetAddresses.parseNumericAddress("255.255.255.255")),
+            eq(0),
+            eq(0)
+        )
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkAgentTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkAgentTest.kt
new file mode 100644
index 0000000..547a4cc
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkAgentTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.NativeNetworkConfig
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.VpnManager
+import android.net.VpnTransportInfo
+import android.net.netd.aidl.NativeUidRangeConfig
+import android.os.Build
+import android.os.Process
+import android.util.Range
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S)
+class CSNetworkAgentTest : CSTest() {
+    @Test fun testVpnUidAgent() = testUidAgent(
+        TRANSPORT_VPN,
+        expectAddUidRanges = true
+    )
+    @ConnectivityModuleTest
+    @Test fun testWifiUidAgent() = testUidAgent(TRANSPORT_WIFI, expectAddUidRanges = false)
+
+    fun testUidAgent(transport: Int, expectAddUidRanges: Boolean) {
+        val netdInOrder = inOrder(netd)
+        val uid = Process.myUid()
+
+        val nc = defaultNc()
+            .addTransportType(transport)
+            .setUids(setOf(Range(uid, uid)))
+        if (TRANSPORT_VPN == transport) {
+            nc.removeCapability(NET_CAPABILITY_NOT_VPN)
+            nc.setTransportInfo(
+                VpnTransportInfo(
+                    VpnManager.TYPE_VPN_SERVICE,
+                    "MySession12345",
+                    true /* bypassable */,
+                    false /* longLivedTcpConnectionsExpensive */
+                )
+            )
+        }
+        val agent = Agent(nc)
+        agent.connect()
+
+        netdInOrder.verify(netd).networkCreate(argThat { it: NativeNetworkConfig ->
+            it.netId == agent.network.netId
+        })
+        if (deps.isAtLeastU()) {
+          // The call to setNetworkAllowlist was added in U.
+          netdInOrder.verify(netd).setNetworkAllowlist(any())
+        }
+        if (expectAddUidRanges) {
+            netdInOrder.verify(netd).networkAddUidRangesParcel(argThat { it: NativeUidRangeConfig ->
+                it.netId == agent.network.netId &&
+                        it.uidRanges.size == 1 &&
+                        it.uidRanges[0].start == uid &&
+                        it.uidRanges[0].stop == uid &&
+                        it.subPriority == 0 // VPN priority
+            })
+        } else {
+            netdInOrder.verify(netd, never()).networkAddUidRangesParcel(any())
+        }
+        // The old method should never be called in any case
+        netdInOrder.verify(netd, never()).networkAddUidRanges(anyInt(), any())
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkRequestStateStatsMetricsTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkRequestStateStatsMetricsTests.kt
index 35f8ae5..e6a69c3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSNetworkRequestStateStatsMetricsTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkRequestStateStatsMetricsTests.kt
@@ -20,6 +20,7 @@
 import android.net.NetworkRequest
 import android.os.Build
 import android.os.Process
+import android.text.TextUtils
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.TestableNetworkCallback
@@ -54,9 +55,17 @@
         cm.requestNetwork(CELL_INTERNET_NOT_METERED_NR, TestableNetworkCallback())
         waitForIdle()
 
+        // Some fields might be altered by the service, e.g. mMatchNonThreadLocalNetworks.
+        // Check immutable differences instead.
         verify(networkRequestStateStatsMetrics).onNetworkRequestReceived(
-                argThat{req -> req.networkCapabilities.equals(
-                        CELL_INTERNET_NOT_METERED_NR.networkCapabilities)})
+                argThat { req ->
+                    TextUtils.isEmpty(
+                            req.networkCapabilities.describeImmutableDifferences(
+                                    CELL_INTERNET_NOT_METERED_NR.networkCapabilities
+                            )
+                    )
+                }
+        )
     }
 
     @Test
@@ -77,7 +86,13 @@
         cm.unregisterNetworkCallback(cb)
         waitForIdle()
         verify(networkRequestStateStatsMetrics).onNetworkRequestRemoved(
-                argThat{req -> req.networkCapabilities.equals(
-                        CELL_INTERNET_NOT_METERED_NR.networkCapabilities)})
+                argThat { req ->
+                    TextUtils.isEmpty(
+                            req.networkCapabilities.describeImmutableDifferences(
+                                    CELL_INTERNET_NOT_METERED_NR.networkCapabilities
+                            )
+                    )
+                }
+        )
     }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkReservationTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkReservationTest.kt
new file mode 100644
index 0000000..e698930
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkReservationTest.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_WIFI_P2P
+import android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_TEST
+import android.net.NetworkProvider
+import android.net.NetworkProvider.NetworkOfferCallback
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Reserved
+import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
+import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.TestableNetworkOfferCallback
+import com.android.testutils.TestableNetworkOfferCallback.CallbackEntry.OnNetworkNeeded
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private val ETHERNET_SCORE = NetworkScore.Builder().build()
+private val ETHERNET_CAPS = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .addCapability(NET_CAPABILITY_INTERNET)
+        .addCapability(NET_CAPABILITY_NOT_CONGESTED)
+        .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+private val BLANKET_CAPS = NetworkCapabilities(ETHERNET_CAPS).apply {
+    reservationId = RES_ID_MATCH_ALL_RESERVATIONS
+}
+private val ETHERNET_REQUEST = NetworkRequest.Builder()
+        .addTransportType(TRANSPORT_ETHERNET)
+        .addTransportType(TRANSPORT_TEST)
+        .removeCapability(NET_CAPABILITY_TRUSTED)
+        .build()
+
+private const val TIMEOUT_MS = 5_000L
+private const val NO_CB_TIMEOUT_MS = 200L
+
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.R)
+class CSNetworkReservationTest : CSTest() {
+    private lateinit var provider: NetworkProvider
+    private val blanketOffer = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+
+    @Before
+    fun subclassSetUp() {
+        provider = NetworkProvider(context, csHandlerThread.looper, "Ethernet provider")
+        cm.registerNetworkProvider(provider)
+
+        // register a blanket offer for use in tests.
+        provider.registerNetworkOffer(ETHERNET_SCORE, BLANKET_CAPS, blanketOffer)
+    }
+
+    fun NetworkCapabilities.copyWithReservationId(resId: Int) = NetworkCapabilities(this).also {
+        it.reservationId = resId
+    }
+
+    fun NetworkProvider.registerNetworkOffer(
+            score: NetworkScore,
+            caps: NetworkCapabilities,
+            cb: NetworkOfferCallback
+    ) {
+        registerNetworkOffer(score, caps, {r -> r.run()}, cb)
+    }
+
+    @Test
+    fun testReservationRequest() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        // validate the reservation matches the blanket offer.
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        // bring up reserved reservation offer
+        val reservedOfferCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOfferCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, reservedOfferCaps, reservedOfferCb)
+
+        // validate onReserved was sent to the app
+        val onReservedCaps = cb.expect<Reserved>().caps
+        assertEquals(reservedOfferCaps, onReservedCaps)
+
+        // validate the reservation matches the reserved offer.
+        reservedOfferCb.expectOnNetworkNeeded(reservedOfferCaps)
+
+        // reserved offer goes away
+        provider.unregisterNetworkOffer(reservedOfferCb)
+        cb.expect<Unavailable>()
+    }
+
+    fun TestableNetworkOfferCallback.expectNoCallbackWhere(
+            predicate: (TestableNetworkOfferCallback.CallbackEntry) -> Boolean
+    ) {
+        val event = history.poll(NO_CB_TIMEOUT_MS) { predicate(it) }
+        assertNull(event)
+    }
+
+    @Test
+    fun testReservationRequest_notDeliveredToRegularOffer() {
+        val offerCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, ETHERNET_CAPS, {r -> r.run()}, offerCb)
+
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        // validate the offer does not receive onNetworkNeeded for reservation request
+        offerCb.expectNoCallbackWhere {
+            it is OnNetworkNeeded && it.request.type == NetworkRequest.Type.RESERVATION
+        }
+    }
+
+    @Test
+    fun testReservedOffer_preventReservationIdUpdate() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        // validate the reservation matches the blanket offer.
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        // bring up reserved offer
+        val reservedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOfferCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, reservedCaps, reservedOfferCb)
+
+        cb.expect<Reserved>()
+        reservedOfferCb.expectOnNetworkNeeded(reservedCaps)
+
+        // try to update the offer's reservationId by reusing the same callback object.
+        // first file a new request to try and match the offer later.
+        val cb2 = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb2)
+
+        val reservationReq2 = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId2 = reservationReq2.networkCapabilities.reservationId
+
+        // try to update the offer's reservationId to an existing reservationId.
+        val updatedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId2)
+        provider.registerNetworkOffer(ETHERNET_SCORE, updatedCaps, reservedOfferCb)
+
+        // validate the original offer disappeared.
+        cb.expect<Unavailable>()
+        // validate the new offer was rejected by CS.
+        reservedOfferCb.expectOnNetworkUnneeded(reservedCaps)
+        // validate cb2 never sees onReserved().
+        cb2.assertNoCallback()
+    }
+
+    @Test
+    fun testReservedOffer_capabilitiesCannotBeUpdated() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        val reservedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOfferCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, reservedCaps, reservedOfferCb)
+
+        cb.expect<Reserved>()
+        reservedOfferCb.expectOnNetworkNeeded(reservedCaps)
+
+        // update reserved offer capabilities
+        val updatedCaps = NetworkCapabilities(reservedCaps).addCapability(NET_CAPABILITY_WIFI_P2P)
+        provider.registerNetworkOffer(ETHERNET_SCORE, updatedCaps, reservedOfferCb)
+
+        cb.expect<Unavailable>()
+        reservedOfferCb.expectOnNetworkUnneeded(reservedCaps)
+        reservedOfferCb.assertNoCallback()
+    }
+
+    @Test
+    fun testBlanketOffer_updateAllowed() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+        blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS)
+
+        val updatedCaps = NetworkCapabilities(BLANKET_CAPS).addCapability(NET_CAPABILITY_WIFI_P2P)
+        provider.registerNetworkOffer(ETHERNET_SCORE, updatedCaps, blanketOffer)
+        blanketOffer.assertNoCallback()
+
+        // Note: NetworkRequest.Builder(NetworkRequest) *does not* perform a defensive copy but
+        // changes the underlying request.
+        val p2pRequest = NetworkRequest.Builder(NetworkRequest(ETHERNET_REQUEST))
+                .addCapability(NET_CAPABILITY_WIFI_P2P)
+                .build()
+        cm.reserveNetwork(p2pRequest, csHandler, cb)
+        blanketOffer.expectOnNetworkNeeded(updatedCaps)
+    }
+
+    @Test
+    fun testReservationOffer_onlyAllowSingleOffer() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        val offerCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        val caps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        provider.registerNetworkOffer(ETHERNET_SCORE, caps, offerCb)
+        offerCb.expectOnNetworkNeeded(caps)
+        cb.expect<Reserved>()
+
+        val newOfferCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, caps, newOfferCb)
+        newOfferCb.assertNoCallback()
+        cb.assertNoCallback()
+
+        // File a regular request and validate only the old offer gets onNetworkNeeded.
+        val cb2 = TestableNetworkCallback()
+        cm.requestNetwork(ETHERNET_REQUEST, cb2, csHandler)
+        offerCb.expectOnNetworkNeeded(caps)
+        newOfferCb.assertNoCallback()
+    }
+
+    @Test
+    fun testReservationOffer_updateScore() {
+        val cb = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, csHandler, cb)
+
+        val reservationReq = blanketOffer.expectOnNetworkNeeded(BLANKET_CAPS).request
+        val reservationId = reservationReq.networkCapabilities.reservationId
+
+        val reservedCaps = ETHERNET_CAPS.copyWithReservationId(reservationId)
+        val reservedOfferCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, reservedCaps, reservedOfferCb)
+        reservedOfferCb.expectOnNetworkNeeded(reservedCaps)
+        reservedOfferCb.assertNoCallback()
+        cb.expect<Reserved>()
+
+        // update reserved offer capabilities
+        val newScore = NetworkScore.Builder().setShouldYieldToBadWifi(true).build()
+        provider.registerNetworkOffer(newScore, reservedCaps, reservedOfferCb)
+        cb.assertNoCallback()
+
+        val cb2 = TestableNetworkCallback()
+        cm.requestNetwork(ETHERNET_REQUEST, cb2, csHandler)
+        reservedOfferCb.expectOnNetworkNeeded(reservedCaps)
+        reservedOfferCb.assertNoCallback()
+    }
+
+    @Test
+    fun testReservationOffer_regularOfferCanBeUpdated() {
+        val offerCb = TestableNetworkOfferCallback(TIMEOUT_MS, NO_CB_TIMEOUT_MS)
+        provider.registerNetworkOffer(ETHERNET_SCORE, ETHERNET_CAPS, offerCb)
+
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(ETHERNET_REQUEST, cb, csHandler)
+        offerCb.expectOnNetworkNeeded(ETHERNET_CAPS)
+        offerCb.assertNoCallback()
+
+        val updatedCaps = NetworkCapabilities(ETHERNET_CAPS).addCapability(NET_CAPABILITY_WIFI_P2P)
+        val newScore = NetworkScore.Builder().setShouldYieldToBadWifi(true).build()
+        provider.registerNetworkOffer(newScore, updatedCaps, offerCb)
+        offerCb.assertNoCallback()
+
+        val cb2 = TestableNetworkCallback()
+        cm.requestNetwork(ETHERNET_REQUEST, cb2, csHandler)
+        offerCb.expectOnNetworkNeeded(ETHERNET_CAPS)
+        offerCb.assertNoCallback()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSQueuedCallbacksTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSQueuedCallbacksTest.kt
index fc2a06c..6d1858e 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSQueuedCallbacksTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSQueuedCallbacksTest.kt
@@ -39,6 +39,7 @@
 import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
 import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_THREAD
 import android.net.NetworkCapabilities.TRANSPORT_WIFI
 import android.net.NetworkPolicyManager.NetworkPolicyCallback
 import android.net.NetworkRequest
@@ -356,6 +357,7 @@
         val localAgent = Agent(
             nc = defaultNc()
                 .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .addTransportType(TRANSPORT_THREAD)
                 .removeCapability(NET_CAPABILITY_INTERNET),
             lp = defaultLp().apply { interfaceName = "local42" },
             lnc = FromS(lnc)
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index 9be7d11..a77daa8 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -104,7 +104,8 @@
         doNothing().`when`(networkStack).makeNetworkMonitor(
                 nmNetworkCaptor.capture(),
                 any() /* name */,
-                nmCbCaptor.capture())
+                nmCbCaptor.capture()
+        )
 
         // Create the actual agent. NetworkAgent is abstract, so make an anonymous subclass.
         if (deps.isAtLeastS()) {
@@ -136,7 +137,7 @@
         nmCallbacks.notifyNetworkTestedWithExtras(p)
     }
 
-    fun connect() {
+    fun connect(expectAvailable: Boolean = true) {
         val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
@@ -148,24 +149,30 @@
         val cb = TestableNetworkCallback()
         mgr.registerNetworkCallback(request, cb)
         agent.markConnected()
-        if (null == cb.poll { it is Available && agent.network == it.network }) {
-            if (!nc.hasCapability(NET_CAPABILITY_NOT_SUSPENDED) &&
-                    nc.hasTransport(TRANSPORT_CELLULAR)) {
-                // ConnectivityService adds NOT_SUSPENDED by default to all non-cell agents. An
-                // agent without NOT_SUSPENDED will not connect, instead going into the SUSPENDED
-                // state, so this call will not terminate.
-                // Instead of forcefully adding NOT_SUSPENDED to all agents like older tools did,
-                // it's better to let the developer manage it as they see fit but help them
-                // debug if they forget.
-                fail("Could not connect the agent. Did you forget to add " +
-                        "NET_CAPABILITY_NOT_SUSPENDED ?")
+        if (expectAvailable) {
+            if (null == cb.poll { it is Available && agent.network == it.network }) {
+                if (!nc.hasCapability(NET_CAPABILITY_NOT_SUSPENDED) &&
+                        nc.hasTransport(TRANSPORT_CELLULAR)) {
+                    // ConnectivityService adds NOT_SUSPENDED by default to all non-cell agents. An
+                    // agent without NOT_SUSPENDED will not connect, instead going into the
+                    // SUSPENDED state, so this call will not terminate.
+                    // Instead of forcefully adding NOT_SUSPENDED to all agents like older tools did,
+                    // it's better to let the developer manage it as they see fit but help them
+                    // debug if they forget.
+                    fail(
+                        "Could not connect the agent. Did you forget to add " +
+                            "NET_CAPABILITY_NOT_SUSPENDED ?"
+                    )
+                }
+                fail("Could not connect the agent. Instrumentation failure ?")
             }
-            fail("Could not connect the agent. Instrumentation failure ?")
+        } else {
+            cb.assertNoCallback()
         }
         mgr.unregisterNetworkCallback(cb)
     }
 
-    fun disconnect() {
+    fun disconnect(expectAvailable: Boolean = true) {
         val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
         val request = NetworkRequest.Builder().apply {
             clearCapabilities()
@@ -176,9 +183,14 @@
         }.build()
         val cb = TestableNetworkCallback(timeoutMs = SHORT_TIMEOUT_MS)
         mgr.registerNetworkCallback(request, cb)
-        cb.eventuallyExpect<Available> { it.network == agent.network }
-        agent.unregister()
-        cb.eventuallyExpect<Lost> { it.network == agent.network }
+        if (expectAvailable) {
+            cb.eventuallyExpect<Available> { it.network == agent.network }
+            agent.unregister()
+            cb.eventuallyExpect<Lost> { it.network == agent.network }
+        } else {
+            agent.unregister()
+            cb.assertNoCallback()
+        }
     }
 
     fun setTeardownDelayMillis(delayMillis: Int) = agent.setTeardownDelayMillis(delayMillis)
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index ae196a6..0fe61ec 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -18,6 +18,7 @@
 
 import android.app.AlarmManager
 import android.app.AppOpsManager
+import android.bluetooth.BluetoothManager
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
@@ -67,10 +68,12 @@
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator
 import com.android.server.connectivity.ClatCoordinator
 import com.android.server.connectivity.ConnectivityFlags
+import com.android.server.connectivity.InterfaceTracker
 import com.android.server.connectivity.MulticastRoutingCoordinatorService
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies
 import com.android.server.connectivity.NetworkRequestStateStatsMetrics
+import com.android.server.connectivity.PermissionMonitor
 import com.android.server.connectivity.ProxyTracker
 import com.android.server.connectivity.SatelliteAccessController
 import com.android.testutils.visibleOnHandlerThread
@@ -109,7 +112,8 @@
 internal const val VERSION_T = 3
 internal const val VERSION_U = 4
 internal const val VERSION_V = 5
-internal const val VERSION_MAX = VERSION_V
+internal const val VERSION_B = 6
+internal const val VERSION_MAX = VERSION_B
 
 internal const val CALLING_UID_UNMOCKED = Process.INVALID_UID
 
@@ -166,6 +170,7 @@
         it[ConnectivityFlags.DELAY_DESTROY_SOCKETS] = true
         it[ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS] = true
         it[ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS] = true
+        it[ConnectivityFlags.QUEUE_NETWORK_AGENT_EVENTS_IN_SYSTEM_SERVER] = true
     }
     fun setFeatureEnabled(flag: String, enabled: Boolean) = enabledFeatures.set(flag, enabled)
 
@@ -191,6 +196,7 @@
     val connResources = makeMockConnResources(sysResources, packageManager)
 
     val netd = mock<INetd>()
+    val interfaceTracker = mock<InterfaceTracker>()
     val bpfNetMaps = mock<BpfNetMaps>().also {
         doReturn(PERMISSION_INTERNET).`when`(it).getNetPermForUid(anyInt())
     }
@@ -209,12 +215,14 @@
         doReturn(true).`when`(it).isDataCapable()
     }
     val subscriptionManager = mock<SubscriptionManager>()
+    val bluetoothManager = mock<BluetoothManager>()
 
     val multicastRoutingCoordinatorService = mock<MulticastRoutingCoordinatorService>()
     val satelliteAccessController = mock<SatelliteAccessController>()
     val destroySocketsWrapper = mock<DestroySocketsWrapper>()
 
     val deps = CSDeps()
+    val permDeps = PermDeps()
 
     // Initializations that start threads are done from setUp to avoid thread leak
     lateinit var alarmHandlerThread: HandlerThread
@@ -251,7 +259,9 @@
 
         alarmHandlerThread = HandlerThread("TestAlarmManager").also { it.start() }
         alarmManager = makeMockAlarmManager(alarmHandlerThread)
-        service = makeConnectivityService(context, netd, deps).also { it.systemReadyInternal() }
+        service = makeConnectivityService(context, netd, deps, permDeps).also {
+            it.systemReadyInternal()
+        }
         cm = ConnectivityManager(context, service)
         // csHandler initialization must be after makeConnectivityService since ConnectivityService
         // constructor starts csHandlerThread
@@ -273,7 +283,11 @@
 
     inner class CSDeps : ConnectivityService.Dependencies() {
         override fun getResources(ctx: Context) = connResources
-        override fun getBpfNetMaps(context: Context, netd: INetd) = this@CSTest.bpfNetMaps
+        override fun getBpfNetMaps(
+            context: Context,
+            netd: INetd,
+            interfaceTracker: InterfaceTracker
+        ) = this@CSTest.bpfNetMaps
         override fun getClatCoordinator(netd: INetd?) = this@CSTest.clatCoordinator
         override fun getNetworkStack() = this@CSTest.networkStack
 
@@ -375,6 +389,7 @@
         override fun isAtLeastT() = if (isSdkUnmocked) super.isAtLeastT() else sdkLevel >= VERSION_T
         override fun isAtLeastU() = if (isSdkUnmocked) super.isAtLeastU() else sdkLevel >= VERSION_U
         override fun isAtLeastV() = if (isSdkUnmocked) super.isAtLeastV() else sdkLevel >= VERSION_V
+        override fun isAtLeastB() = if (isSdkUnmocked) super.isAtLeastB() else sdkLevel >= VERSION_B
 
         private var callingUid = CALLING_UID_UNMOCKED
 
@@ -393,6 +408,12 @@
             // Call mocked destroyLiveTcpSocketsByOwnerUids so that test can verify this method call
             destroySocketsWrapper.destroyLiveTcpSocketsByOwnerUids(ownerUids)
         }
+
+        override fun makeL2capNetworkProvider(context: Context) = null
+    }
+
+    inner class PermDeps : PermissionMonitor.Dependencies() {
+        override fun shouldEnforceLocalNetRestrictions(uid: Int) = false
     }
 
     inner class CSContext(base: Context) : BroadcastInterceptingContext(base) {
@@ -503,6 +524,7 @@
             Context.BATTERY_STATS_SERVICE -> batteryManager
             Context.STATS_MANAGER -> null // Stats manager is final and can't be mocked
             Context.APP_OPS_SERVICE -> appOpsManager
+            Context.BLUETOOTH_SERVICE -> bluetoothManager
             else -> super.getSystemService(serviceName)
         }
 
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
index 8ff790c..f28f063 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
@@ -23,6 +23,7 @@
 import android.content.Context
 import android.content.pm.PackageManager
 import android.content.pm.PackageManager.FEATURE_BLUETOOTH
+import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE
 import android.content.pm.PackageManager.FEATURE_ETHERNET
 import android.content.pm.PackageManager.FEATURE_WIFI
 import android.content.pm.PackageManager.FEATURE_WIFI_DIRECT
@@ -33,6 +34,7 @@
 import android.net.IpPrefix
 import android.net.LinkAddress
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
 import android.net.NetworkScore
@@ -53,6 +55,7 @@
 import com.android.modules.utils.build.SdkLevel
 import com.android.server.ConnectivityService.Dependencies
 import com.android.server.connectivity.ConnectivityResources
+import com.android.server.connectivity.PermissionMonitor
 import kotlin.test.fail
 import org.mockito.ArgumentMatchers
 import org.mockito.ArgumentMatchers.any
@@ -82,6 +85,11 @@
 
 internal fun defaultScore() = FromS(NetworkScore.Builder().build())
 
+internal fun keepConnectedScore() = FromS(NetworkScore.Builder()
+                .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_FOR_TEST).build())
+
+internal fun defaultLnc() = FromS(LocalNetworkConfig.Builder().build())
+
 internal fun defaultLp() = LinkProperties().apply {
     addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
     addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
@@ -103,11 +111,19 @@
 }
 
 internal fun makeMockPackageManager(realContext: Context) = mock<PackageManager>().also { pm ->
-    val supported = listOf(FEATURE_WIFI, FEATURE_WIFI_DIRECT, FEATURE_BLUETOOTH, FEATURE_ETHERNET)
+    val supported = listOf(
+            FEATURE_WIFI,
+            FEATURE_WIFI_DIRECT,
+            FEATURE_BLUETOOTH,
+            FEATURE_BLUETOOTH_LE,
+            FEATURE_ETHERNET
+    )
     doReturn(true).`when`(pm).hasSystemFeature(argThat { supported.contains(it) })
     val myPackageName = realContext.packageName
-    val myPackageInfo = realContext.packageManager.getPackageInfo(myPackageName,
-            PackageManager.GET_PERMISSIONS)
+    val myPackageInfo = realContext.packageManager.getPackageInfo(
+        myPackageName,
+            PackageManager.GET_PERMISSIONS
+    )
     // Very high version code so that the checks for the module version will always
     // say that it is recent enough. This is the most sensible default, but if some
     // test needs to test with different version codes they can re-mock this with a
@@ -115,7 +131,10 @@
     myPackageInfo.longVersionCode = 9999999L
     doReturn(arrayOf(myPackageName)).`when`(pm).getPackagesForUid(Binder.getCallingUid())
     doReturn(myPackageInfo).`when`(pm).getPackageInfoAsUser(
-            eq(myPackageName), anyInt(), eq(UserHandle.getCallingUserId()))
+            eq(myPackageName),
+            anyInt(),
+            eq(UserHandle.getCallingUserId())
+    )
     doReturn(listOf(myPackageInfo)).`when`(pm)
             .getInstalledPackagesAsUser(eq(PackageManager.GET_PERMISSIONS), anyInt())
 }
@@ -136,12 +155,19 @@
         handler as Handler
         val delayMs = ((date as Long) - SystemClock.elapsedRealtime()).coerceAtLeast(0)
         if (delayMs > UNREASONABLY_LONG_ALARM_WAIT_MS) {
-            fail("Attempting to send msg more than $UNREASONABLY_LONG_ALARM_WAIT_MS" +
-                    "ms into the future : $delayMs")
+            fail(
+                "Attempting to send msg more than $UNREASONABLY_LONG_ALARM_WAIT_MS" +
+                    "ms into the future : $delayMs"
+            )
         }
         alrmHdlr.postDelayed({ handler.post(wakeupMsg::onAlarm) }, wakeupMsg, delayMs)
-    }.`when`(am).setExact(eq(AlarmManager.ELAPSED_REALTIME_WAKEUP), anyLong(), anyString(),
-            any<WakeupMessage>(), any())
+    }.`when`(am).setExact(
+            eq(AlarmManager.ELAPSED_REALTIME_WAKEUP),
+            anyLong(),
+            anyString(),
+            any<WakeupMessage>(),
+            any()
+    )
     doAnswer {
         alrmHdlr.removeCallbacksAndMessages(it.getArgument<WakeupMessage>(0))
     }.`when`(am).cancel(any<WakeupMessage>())
@@ -185,13 +211,20 @@
 
 private val TEST_LINGER_DELAY_MS = 400
 private val TEST_NASCENT_DELAY_MS = 300
-internal fun makeConnectivityService(context: Context, netd: INetd, deps: Dependencies) =
+internal fun makeConnectivityService(
+        context: Context,
+        netd: INetd,
+        deps: Dependencies,
+        mPermDeps: PermissionMonitor.Dependencies
+) =
         ConnectivityService(
                 context,
                 mock<IDnsResolver>(),
                 mock<IpConnectivityLog>(),
                 netd,
-                deps).also {
+                deps,
+                mPermDeps
+        ).also {
             it.mLingerDelayMs = TEST_LINGER_DELAY_MS
             it.mNascentDelayMs = TEST_NASCENT_DELAY_MS
         }
diff --git a/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java b/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
index e6aba22..97ca8ad 100644
--- a/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
+++ b/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
@@ -18,6 +18,9 @@
 
 import static android.net.TestNetworkManager.TEST_TAP_PREFIX;
 
+import static com.android.server.ethernet.EthernetTracker.DEFAULT_CAPABILITIES;
+import static com.google.common.truth.Truth.assertThat;
+
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertThrows;
@@ -44,6 +47,7 @@
 
 import androidx.test.filters.SmallTest;
 
+import com.android.server.ethernet.EthernetTracker.EthernetConfigParser;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
@@ -79,6 +83,7 @@
         initMockResources();
         doReturn(false).when(mFactory).updateInterfaceLinkState(anyString(), anyBoolean());
         doReturn(new String[0]).when(mNetd).interfaceGetList();
+        doReturn(new String[0]).when(mFactory).getAvailableInterfaces(anyBoolean());
         mHandlerThread = new HandlerThread(THREAD_NAME);
         mHandlerThread.start();
         tracker = new EthernetTracker(mContext, mHandlerThread.getThreadHandler(), mFactory, mNetd,
@@ -166,164 +171,101 @@
                 EthernetTracker.parseStaticIpConfiguration(configAsString));
     }
 
-    private NetworkCapabilities.Builder makeEthernetCapabilitiesBuilder(boolean clearAll) {
+    private NetworkCapabilities.Builder makeEthernetCapabilitiesBuilder(boolean clearDefaults) {
         final NetworkCapabilities.Builder builder =
-                clearAll ? NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                clearDefaults
+                        ? NetworkCapabilities.Builder.withoutDefaultCapabilities()
                         : new NetworkCapabilities.Builder();
         return builder.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
                 .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
                 .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED);
     }
 
-    /**
-     * Test: Attempt to create a capabilties with various valid sets of capabilities/transports
-     */
+
     @Test
-    public void createNetworkCapabilities() {
+    public void testNetworkCapabilityParsing() {
+        final NetworkCapabilities baseNc = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
+                .setLinkUpstreamBandwidthKbps(100 * 1000 /* 100 Mbps */)
+                .setLinkDownstreamBandwidthKbps(100 * 1000 /* 100 Mbps */)
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING)
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED)
+                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED)
+                .build();
 
-        // Particularly common expected results
-        NetworkCapabilities defaultEthernetCleared =
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
-                        .build();
+        // Empty capabilities always default to the baseNc above.
+        EthernetConfigParser p = new EthernetConfigParser("eth0;", false /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(baseNc);
+        p = new EthernetConfigParser("eth0;", true /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(baseNc);
 
-        NetworkCapabilities ethernetClearedWithCommonCaps =
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
-                        .addCapability(12)
-                        .addCapability(13)
-                        .addCapability(14)
-                        .addCapability(15)
-                        .build();
+        // On Android B+, "*" defaults to using DEFAULT_CAPABILITIES.
+        p = new EthernetConfigParser("eth0;*;;;;;;", true /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(DEFAULT_CAPABILITIES);
 
-        // Empty capabilities and transports lists with a "please clear defaults" should
-        // yield an empty capabilities set with TRANPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "");
+        // But not so before B.
+        p = new EthernetConfigParser("eth0;*", false /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(baseNc);
 
-        // Empty capabilities and transports without the clear defaults flag should return the
-        // default capabilities set with TRANSPORT_ETHERNET
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(false /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
-                        .build(),
-                false, "", "");
+        p = new EthernetConfigParser("eth0;12,13,14,15;", false /*isAtLeastB*/);
+        assertThat(p.mCaps.getCapabilities()).asList().containsAtLeast(12, 13, 14, 15);
 
-        // A list of capabilities without the clear defaults flag should return the default
-        // capabilities, mixed with the desired capabilities, and TRANSPORT_ETHERNET
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(false /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
-                        .addCapability(11)
-                        .addCapability(12)
-                        .build(),
-                false, "11,12", "");
+        p = new EthernetConfigParser("eth0;12,13,500,abc", false /*isAtLeastB*/);
+        // 18, 20, 21 are added by EthernetConfigParser.
+        assertThat(p.mCaps.getCapabilities()).asList().containsExactly(12, 13, 18, 20, 21);
 
-        // Adding a list of capabilities with a clear defaults will leave exactly those capabilities
-        // with a default TRANSPORT_ETHERNET since no overrides are specified
-        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, true, "12,13,14,15", "");
+        p = new EthernetConfigParser("eth0;1,2,3;;0", false /*isAtLeastB*/);
+        assertThat(p.mCaps.getCapabilities()).asList().containsAtLeast(1, 2, 3);
+        assertThat(p.mCaps.hasSingleTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).isTrue();
 
-        // Adding any invalid capabilities to the list will cause them to be ignored
-        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, true, "12,13,14,15,65,73", "");
-        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, true, "12,13,14,15,abcdefg", "");
+        // TRANSPORT_VPN (4) is not allowed.
+        p = new EthernetConfigParser("eth0;;;4", false /*isAtLeastB*/);
+        assertThat(p.mCaps.hasSingleTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).isTrue();
+        p = new EthernetConfigParser("eth0;*;;4", true /*isAtLeastB*/);
+        assertThat(p.mCaps.hasSingleTransport(NetworkCapabilities.TRANSPORT_ETHERNET)).isTrue();
 
-        // Adding a valid override transport will remove the default TRANSPORT_ETHERNET transport
-        // and apply only the override to the capabiltities object
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(0)
-                        .build(),
-                true, "", "0");
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(1)
-                        .build(),
-                true, "", "1");
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(2)
-                        .build(),
-                true, "", "2");
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addTransportType(3)
-                        .build(),
-                true, "", "3");
+        // invalid capability and transport type
+        p = new EthernetConfigParser("eth0;-1,a,1000,,;;-1", false /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(baseNc);
 
-        // "4" is TRANSPORT_VPN, which is unsupported. Should default back to TRANPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "4");
+        p = new EthernetConfigParser("eth0;*;;0", false /*isAtLeastB*/);
+        assertThat(p.mCaps.hasSingleTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).isTrue();
+        p = new EthernetConfigParser("eth0;*;;0", true /*isAtLeastB*/);
+        assertThat(p.mCaps.hasSingleTransport(NetworkCapabilities.TRANSPORT_CELLULAR)).isTrue();
 
-        // "5" is TRANSPORT_WIFI_AWARE, which is currently supported due to no legacy TYPE_NONE
-        // conversion. When that becomes available, this test must be updated
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "5");
-
-        // "6" is TRANSPORT_LOWPAN, which is currently supported due to no legacy TYPE_NONE
-        // conversion. When that becomes available, this test must be updated
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "6");
-
-        // Adding an invalid override transport will leave the transport as TRANSPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared,true, "", "100");
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "abcdefg");
-
-        // Ensure the adding of both capabilities and transports work
-        assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
-                        .setLinkUpstreamBandwidthKbps(100000)
-                        .setLinkDownstreamBandwidthKbps(100000)
-                        .addCapability(12)
-                        .addCapability(13)
-                        .addCapability(14)
-                        .addCapability(15)
-                        .addTransportType(3)
-                        .build(),
-                true, "12,13,14,15", "3");
-
-        // Ensure order does not matter for capability list
-        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, true, "13,12,15,14", "");
-    }
-
-    private void assertParsedNetworkCapabilities(NetworkCapabilities expectedNetworkCapabilities,
-            boolean clearCapabilties, String configCapabiltiies,String configTransports) {
-        assertEquals(expectedNetworkCapabilities,
-                EthernetTracker.createNetworkCapabilities(clearCapabilties, configCapabiltiies,
-                        configTransports).build());
+        NetworkCapabilities nc = new NetworkCapabilities.Builder(DEFAULT_CAPABILITIES)
+                .removeTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
+                .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+                .build();
+        p = new EthernetConfigParser("eth0;*;;0", true /*isAtLeastB*/);
+        assertThat(p.mCaps).isEqualTo(nc);
     }
 
     @Test
-    public void testCreateEthernetTrackerConfigReturnsCorrectValue() {
-        final String capabilities = "2";
-        final String ipConfig = "3";
-        final String transport = "4";
-        final String configString = String.join(";", TEST_IFACE, capabilities, ipConfig, transport);
+    public void testInterfaceNameParsing() {
+        EthernetConfigParser p = new EthernetConfigParser("eth12", false /*isAtLeastB*/);
+        assertThat(p.mIface).isEqualTo("eth12");
 
-        final EthernetTracker.EthernetTrackerConfig config =
-                EthernetTracker.createEthernetTrackerConfig(configString);
+        p = new EthernetConfigParser("", true /*isAtLeastB*/);
+        assertThat(p.mIface).isEqualTo("");
 
-        assertEquals(TEST_IFACE, config.mIface);
-        assertEquals(capabilities, config.mCapabilities);
-        assertEquals(ipConfig, config.mIpConfig);
-        assertEquals(transport, config.mTransport);
+        p = new EthernetConfigParser("eth0;12;", true /*isAtLeastB*/);
+        assertThat(p.mIface).isEqualTo("eth0");
     }
 
     @Test
-    public void testCreateEthernetTrackerConfigThrowsNpeWithNullInput() {
-        assertThrows(NullPointerException.class,
-                () -> EthernetTracker.createEthernetTrackerConfig(null));
+    public void testIpConfigParsing() {
+        // Note that EthernetConfigParser doesn't actually parse the IpConfig (yet).
+        final EthernetConfigParser p = new EthernetConfigParser(
+                "eth0;1,2,3;ip=192.168.0.10/24 gateway=192.168.0.1 dns=4.4.4.4,8.8.8.8;1",
+                false /*isAtLeastB*/);
+        assertThat(p.mIpConfig)
+                .isEqualTo("ip=192.168.0.10/24 gateway=192.168.0.1 dns=4.4.4.4,8.8.8.8");
+    }
+
+    @Test
+    public void testCreateEthernetConfigParserThrowsNpeWithNullInput() {
+        assertThrows(NullPointerException.class, () -> new EthernetConfigParser(null, false));
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt b/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt
new file mode 100644
index 0000000..7ebe384
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt
@@ -0,0 +1,271 @@
+/*
+ * Copyright (C) 2025 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.net
+
+import android.os.Build
+import com.android.internal.util.HexDump
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.google.common.truth.Truth.assertThat
+import java.io.IOException
+import java.nio.BufferUnderflowException
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TIMEOUT = 1000L
+
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class HeaderCompressionUtilsTest {
+
+    private fun decompressHex(hex: String): ByteArray {
+        val bytes = HexDump.hexStringToByteArray(hex)
+        val buf = bytes.copyOf(1500)
+        val newLen = HeaderCompressionUtils.decompress6lowpan(buf, bytes.size)
+        return buf.copyOf(newLen)
+    }
+
+    private fun compressHex(hex: String): ByteArray {
+        val buf = HexDump.hexStringToByteArray(hex)
+        val newLen = HeaderCompressionUtils.compress6lowpan(buf, buf.size)
+        return buf.copyOf(newLen)
+    }
+
+    private fun String.decodeHex() = HexDump.hexStringToByteArray(this)
+
+    @Test
+    fun testHeaderDecompression() {
+        // TF: 00, NH: 0, HLIM: 00, CID: 0, SAC: 0, SAM: 00, M: 0, DAC: 0, DAM: 00
+        var input = "6000" +
+                    "ccf" +                               // ECN + DSCP + 4-bit Pad (here "f")
+                    "12345" +                             // flow label
+                    "11" +                                // next header
+                    "e7" +                                // hop limit
+                    "abcdef1234567890abcdef1234567890" +  // source
+                    "aaabbbcccdddeeefff00011122233344" +  // dest
+                    "abcd"                                // payload
+
+        var output = "6" +                                // version
+                     "cc" +                               // traffic class
+                     "12345" +                            // flow label
+                     "0002" +                             // payload length
+                     "11" +                               // next header
+                     "e7" +                               // hop limit
+                     "abcdef1234567890abcdef1234567890" + // source
+                     "aaabbbcccdddeeefff00011122233344" + // dest
+                     "abcd"                               // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 01, NH: 0, HLIM: 01, CID: 0, SAC: 0, SAM: 01, M: 0, DAC: 0, DAM: 01
+        input  = "6911" +
+                 "5" +                                // ECN + 2-bit pad (here "1")
+                 "f100e" +                            // flow label
+                 "42" +                               // next header
+                 "1102030405060708" +                 // source
+                 "aa0b0c0d0e0f1011" +                 // dest
+                 "abcd"                               // payload
+
+        output = "6" +                                // version
+                 "01" +                               // traffic class
+                 "f100e" +                            // flow label
+                 "0002" +                             // payload length
+                 "42" +                               // next header
+                 "01" +                               // hop limit
+                 "fe800000000000001102030405060708" + // source
+                 "fe80000000000000aa0b0c0d0e0f1011" + // dest
+                 "abcd"                               // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 10, M: 0, DAC: 0, DAM: 10
+        input  = "7222" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "1234" +                             // source
+                 "abcd" +                             // dest
+                 "abcdef"                             // payload
+
+        output = "6" +                                // version
+                 "cc" +                               // traffic class
+                 "00000" +                            // flow label
+                 "0003" +                             // payload length
+                 "43" +                               // next header
+                 "40" +                               // hop limit
+                 "fe80000000000000000000fffe001234" + // source
+                 "fe80000000000000000000fffe00abcd" + // dest
+                 "abcdef"                             // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 00
+        input  = "7b28" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "ff020000000000000000000000000001" + // dest
+                 "abcdef01"                           // payload
+
+        output = "6" +                                // version
+                 "00" +                               // traffic class
+                 "00000" +                            // flow label
+                 "0004" +                             // payload length
+                 "44" +                               // next header
+                 "ff" +                               // hop limit
+                 "fe80000000000000000000fffe001234" + // source
+                 "ff020000000000000000000000000001" + // dest
+                 "abcdef01"                           // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 01
+        input  = "7b29" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "02abcdef1234" +                     // dest
+                 "abcdef01"                           // payload
+
+        output = "6" +                                // version
+                 "00" +                               // traffic class
+                 "00000" +                            // flow label
+                 "0004" +                             // payload length
+                 "44" +                               // next header
+                 "ff" +                               // hop limit
+                 "fe80000000000000000000fffe001234" + // source
+                 "ff02000000000000000000abcdef1234" + // dest
+                 "abcdef01"                           // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 10
+        input  = "7b2a" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "ee123456" +                         // dest
+                 "abcdef01"                           // payload
+
+        output = "6" +                                // version
+                 "00" +                               // traffic class
+                 "00000" +                            // flow label
+                 "0004" +                             // payload length
+                 "44" +                               // next header
+                 "ff" +                               // hop limit
+                 "fe80000000000000000000fffe001234" + // source
+                 "ffee0000000000000000000000123456" + // dest
+                 "abcdef01"                           // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7b2b" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "abcdef01"                           // payload
+
+        output = "6" +                                // version
+                 "00" +                               // traffic class
+                 "00000" +                            // flow label
+                 "0004" +                             // payload length
+                 "44" +                               // next header
+                 "ff" +                               // hop limit
+                 "fe80000000000000000000fffe001234" + // source
+                 "ff020000000000000000000000000089" + // dest
+                 "abcdef01"                           // payload
+        assertThat(decompressHex(input)).isEqualTo(output.decodeHex())
+    }
+
+    @Test
+    fun testHeaderDecompression_invalidPacket() {
+        // 1-byte packet
+        var input = "60"
+        assertFailsWith(BufferUnderflowException::class) { decompressHex(input) }
+
+        // Short packet -- incomplete header
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7b2b" +
+                 "44" +                               // next header
+                 "1234"                               // source
+        assertFailsWith(BufferUnderflowException::class) { decompressHex(input) }
+
+        // Packet starts with 0b111 instead of 0b011
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "fb2b" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "abcdef01"                           // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option NH = 1. Note that the packet is not valid as the code should throw as soon
+        // as the illegal option is encountered.
+        // TF: 11, NH: 1, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7f2b" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option CID = 1.
+        // TF: 11, NH: 0, HLIM: 11, CID: 1, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7bab00" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option SAC = 1.
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 1, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7b6b" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option DAC = 1.
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 10, M: 0, DAC: 1, DAM: 10
+        input  = "7226" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "1234" +                             // source
+                 "abcd" +                             // dest
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+
+        // Unsupported option DAM = 11
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 10, M: 0, DAC: 0, DAM: 11
+        input  = "7223" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "1234" +                             // source
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Unsupported option SAM = 11
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 11, M: 0, DAC: 0, DAM: 10
+        input  = "7232" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "abcd" +                             // dest
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+    }
+
+    @Test
+    fun testHeaderCompression() {
+        val input  = "60120304000011fffe800000000000000000000000000001fe800000000000000000000000000002"
+        val output = "60000102030411fffe800000000000000000000000000001fe800000000000000000000000000002"
+        assertThat(compressHex(input)).isEqualTo(output.decodeHex())
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt b/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt
new file mode 100644
index 0000000..e261732
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/L2capPacketForwarderTest.kt
@@ -0,0 +1,248 @@
+/*
+ * Copyright (C) 2025 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.net
+
+import android.bluetooth.BluetoothSocket
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.ParcelFileDescriptor
+import android.system.Os
+import android.system.OsConstants.AF_UNIX
+import android.system.OsConstants.SHUT_RD
+import android.system.OsConstants.SHUT_WR
+import android.system.OsConstants.SOCK_SEQPACKET
+import android.system.OsConstants.SOL_SOCKET
+import android.system.OsConstants.SO_RCVTIMEO
+import android.system.OsConstants.SO_SNDTIMEO
+import android.system.StructTimeval
+import com.android.server.net.L2capPacketForwarder.BluetoothSocketWrapper
+import com.android.server.net.L2capPacketForwarder.FdWrapper
+import com.android.testutils.ConnectivityModuleTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.google.common.truth.Truth.assertThat
+import java.io.FileDescriptor
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.ByteBuffer
+import kotlin.arrayOf
+import kotlin.random.Random
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+private const val TIMEOUT = 1000L
+
+@ConnectivityModuleTest
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class L2capPacketForwarderTest {
+    private lateinit var forwarder: L2capPacketForwarder
+    private val tunFds = arrayOf(FileDescriptor(), FileDescriptor())
+    private val l2capFds = arrayOf(FileDescriptor(), FileDescriptor())
+    private lateinit var l2capInputStream: BluetoothL2capInputStream
+    private lateinit var l2capOutputStream: BluetoothL2capOutputStream
+    @Mock private lateinit var bluetoothSocket: BluetoothSocket
+    @Mock private lateinit var callback: L2capPacketForwarder.ICallback
+
+    private val handlerThread = HandlerThread("L2capPacketForwarderTest thread").apply { start() }
+    private val handler = Handler(handlerThread.looper)
+
+    /** Imitates the behavior of an L2CAP BluetoothSocket */
+    private class BluetoothL2capInputStream(
+        val fd: FileDescriptor,
+    ) : InputStream() {
+        val l2capBuffer = ByteBuffer.wrap(ByteArray(0xffff)).apply {
+            limit(0)
+        }
+
+        override fun read(): Int {
+            throw NotImplementedError("b/391623333: not implemented correctly for L2cap sockets")
+        }
+
+        /** See BluetoothSocket#read(buf, off, len) */
+        override fun read(b: ByteArray, off: Int, len: Int): Int {
+            // If no more bytes are remaining, read from the fd into the intermediate buffer.
+            if (l2capBuffer.remaining() == 0) {
+                // fillL2capRxBuffer()
+                // refill buffer and return - 1
+                val backingArray = l2capBuffer.array()
+                var bytesRead = 0
+                try {
+                    bytesRead = Os.read(fd, backingArray, 0 /*off*/, backingArray.size)
+                } catch (e: Exception) {
+                    // read failed, timed out, or was interrupted
+                    // InputStream throws IOException
+                    throw IOException(e)
+                }
+                l2capBuffer.rewind()
+                l2capBuffer.limit(bytesRead)
+            }
+
+            val bytesToRead = if (len > l2capBuffer.remaining()) l2capBuffer.remaining() else len
+            l2capBuffer.get(b, off, bytesToRead)
+            return bytesToRead
+        }
+
+        override fun available(): Int {
+            throw NotImplementedError("b/391623333: not implemented correctly for L2cap sockets")
+        }
+
+        override fun close() {
+            try {
+                Os.shutdown(fd, SHUT_RD)
+            } catch (e: Exception) {
+                // InputStream throws IOException
+                throw IOException(e)
+            }
+        }
+    }
+
+    /** Imitates the behavior of an L2CAP BluetoothSocket */
+    private class BluetoothL2capOutputStream(
+        val fd: FileDescriptor,
+    ) : OutputStream() {
+
+        override fun write(b: Int) {
+            throw NotImplementedError("This method does not maintain packet boundaries, do not use")
+        }
+
+        /** See BluetoothSocket#write(buf, off, len) */
+        override fun write(b: ByteArray, off: Int, len: Int) {
+            try {
+                Os.write(fd, b, off, len)
+            } catch (e: Exception) {
+                // OutputStream throws IOException
+                throw IOException(e)
+            }
+        }
+
+        override fun close() {
+            try {
+                Os.shutdown(fd, SHUT_WR)
+            } catch (e: Exception) {
+                // OutputStream throws IOException
+                throw IOException(e)
+            }
+        }
+    }
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+
+        Os.socketpair(AF_UNIX, SOCK_SEQPACKET, 0, tunFds[0], tunFds[1])
+        Os.socketpair(AF_UNIX, SOCK_SEQPACKET, 0, l2capFds[0], l2capFds[1])
+
+        // Set socket i/o timeout for test end.
+        Os.setsockoptTimeval(tunFds[1], SOL_SOCKET, SO_RCVTIMEO, StructTimeval.fromMillis(5000))
+        Os.setsockoptTimeval(tunFds[1], SOL_SOCKET, SO_SNDTIMEO, StructTimeval.fromMillis(5000))
+        Os.setsockoptTimeval(l2capFds[1], SOL_SOCKET, SO_RCVTIMEO, StructTimeval.fromMillis(5000))
+        Os.setsockoptTimeval(l2capFds[1], SOL_SOCKET, SO_SNDTIMEO, StructTimeval.fromMillis(5000))
+
+        l2capInputStream = BluetoothL2capInputStream(l2capFds[0])
+        l2capOutputStream = BluetoothL2capOutputStream(l2capFds[0])
+        doReturn(l2capInputStream).`when`(bluetoothSocket).getInputStream()
+        doReturn(l2capOutputStream).`when`(bluetoothSocket).getOutputStream()
+        doAnswer({
+            l2capInputStream.close()
+            l2capOutputStream.close()
+            try {
+                // libcore's Linux_close properly invalidates the FileDescriptor, so it is safe to
+                // close multiple times.
+                Os.close(l2capFds[0])
+            } catch (e: Exception) {
+                // BluetoothSocket#close can be called multiple times. Catch EBADF on subsequent
+                // invocations.
+            }
+        }).`when`(bluetoothSocket).close()
+
+        forwarder = L2capPacketForwarder(
+                handler,
+                FdWrapper(ParcelFileDescriptor(tunFds[0])),
+                BluetoothSocketWrapper(bluetoothSocket),
+                false /* compressHeaders */,
+                callback
+        )
+    }
+
+    @After
+    fun tearDown() {
+        if (::forwarder.isInitialized) {
+            // forwarder closes tunFds[0] and l2capFds[0]
+            forwarder.tearDown()
+        } else {
+            Os.close(tunFds[0])
+            Os.close(l2capFds[0])
+        }
+        Os.close(tunFds[1])
+        Os.close(l2capFds[1])
+
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    fun sendPacket(fd: FileDescriptor, size: Int = 1280): ByteArray {
+        val packet = ByteArray(size)
+        Random.nextBytes(packet)
+        Os.write(fd, packet, 0 /*off*/, packet.size)
+        return packet
+    }
+
+    fun assertPacketReceived(fd: FileDescriptor, expected: ByteArray) {
+        val readBuffer = ByteArray(expected.size)
+        Os.read(fd, readBuffer, 0 /*off*/, readBuffer.size)
+        assertThat(readBuffer).isEqualTo(expected)
+    }
+
+    @Test
+    fun testForwarding_withoutHeaderCompression() {
+        var packet = sendPacket(l2capFds[1])
+        var packet2 = sendPacket(l2capFds[1])
+        assertPacketReceived(tunFds[1], packet)
+        assertPacketReceived(tunFds[1], packet2)
+
+        packet = sendPacket(tunFds[1])
+        packet2 = sendPacket(tunFds[1])
+        assertPacketReceived(l2capFds[1], packet)
+        assertPacketReceived(l2capFds[1], packet2)
+    }
+
+    @Test
+    fun testForwarding_packetExceedsMtu() {
+        // Reading from tun drops packets that exceed MTU.
+        // drop
+        sendPacket(tunFds[1], L2capPacketForwarder.MTU + 1)
+        // drop
+        sendPacket(tunFds[1], L2capPacketForwarder.MTU + 42)
+        var packet = sendPacket(l2capFds[1], 1280)
+        assertPacketReceived(tunFds[1], packet)
+
+        // On the BluetoothSocket side, reads that exceed MTU stop forwarding.
+        sendPacket(l2capFds[1], L2capPacketForwarder.MTU + 1)
+        verify(callback, timeout(TIMEOUT)).onError()
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsFactoryTest.java b/tests/unit/java/com/android/server/net/NetworkStatsFactoryTest.java
index 63daebc..89acf69 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsFactoryTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsFactoryTest.java
@@ -20,6 +20,7 @@
 import static android.net.NetworkStats.DEFAULT_NETWORK_NO;
 import static android.net.NetworkStats.METERED_ALL;
 import static android.net.NetworkStats.METERED_NO;
+import static android.net.NetworkStats.METERED_YES;
 import static android.net.NetworkStats.ROAMING_ALL;
 import static android.net.NetworkStats.ROAMING_NO;
 import static android.net.NetworkStats.SET_ALL;
@@ -29,6 +30,8 @@
 import static android.net.NetworkStats.TAG_NONE;
 import static android.net.NetworkStats.UID_ALL;
 
+import static com.android.server.net.NetworkStatsFactory.CONFIG_PER_UID_TAG_THROTTLING;
+import static com.android.server.net.NetworkStatsFactory.CONFIG_PER_UID_TAG_THROTTLING_THRESHOLD;
 import static com.android.server.net.NetworkStatsFactory.kernelToTag;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
@@ -36,6 +39,9 @@
 import static org.junit.Assert.fail;
 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.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
 
 import android.content.Context;
@@ -52,12 +58,15 @@
 import com.android.server.BpfNetMaps;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule;
+import com.android.testutils.com.android.testutils.SetFeatureFlagsRule.FeatureFlag;
 
 import libcore.io.IoUtils;
 import libcore.testing.io.TestIoUtils;
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
@@ -66,6 +75,7 @@
 import java.io.File;
 import java.io.IOException;
 import java.net.ProtocolException;
+import java.util.HashMap;
 
 /** Tests for {@link NetworkStatsFactory}. */
 @RunWith(DevSdkIgnoreRunner.class)
@@ -73,6 +83,7 @@
 @DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
 public class NetworkStatsFactoryTest extends NetworkStatsBaseTest {
     private static final String CLAT_PREFIX = "v4-";
+    private static final int TEST_TAGS_PER_UID_THRESHOLD = 10;
 
     private File mTestProc;
     private NetworkStatsFactory mFactory;
@@ -80,6 +91,16 @@
     @Mock private NetworkStatsFactory.Dependencies mDeps;
     @Mock private BpfNetMaps mBpfNetMaps;
 
+    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));
+
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
@@ -90,6 +111,10 @@
         // related to networkStatsFactory is compiled to a minimal native library and loaded here.
         System.loadLibrary("networkstatsfactorytestjni");
         doReturn(mBpfNetMaps).when(mDeps).createBpfNetMaps(any());
+        doAnswer(invocation -> mFeatureFlags.getOrDefault((String) invocation.getArgument(1), true))
+            .when(mDeps).isFeatureNotChickenedOut(any(), anyString());
+        doReturn(TEST_TAGS_PER_UID_THRESHOLD).when(mDeps)
+                .getDeviceConfigPropertyInt(eq(CONFIG_PER_UID_TAG_THROTTLING_THRESHOLD), anyInt());
 
         mFactory = new NetworkStatsFactory(mContext, mDeps);
         mFactory.updateUnderlyingNetworkInfos(new UnderlyingNetworkInfo[0]);
@@ -498,6 +523,71 @@
         assertValues(removedUidsStats, TEST_IFACE, UID_GREEN, 64L, 3L, 1024L, 8L);
     }
 
+    @FeatureFlag(name = CONFIG_PER_UID_TAG_THROTTLING)
+    @Test
+    public void testFilterTooManyTags_featureEnabled() throws Exception {
+        doTestFilterTooManyTags(true);
+    }
+
+    @FeatureFlag(name = CONFIG_PER_UID_TAG_THROTTLING, enabled = false)
+    @Test
+    public void testFilterTooManyTags_featureDisabled() throws Exception {
+        doTestFilterTooManyTags(false);
+    }
+
+    private void doTestFilterTooManyTags(boolean supportPerUidTagThrottling) throws Exception {
+        // Add entries for UID_RED which reaches the threshold.
+        final NetworkStats statsWithManyTags = new NetworkStats(0L, TEST_TAGS_PER_UID_THRESHOLD);
+        for (int tag = 1; tag <= TEST_TAGS_PER_UID_THRESHOLD; tag++) {
+            statsWithManyTags.combineValues(
+                    new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT, tag,
+                            METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 12L, 18L, 14L, 1L, 0L));
+        }
+        doReturn(statsWithManyTags).when(mDeps).getNetworkStatsDetail();
+        final NetworkStats stats1 = mFactory.readNetworkStatsDetail();
+        assertEquals(stats1.size(), TEST_TAGS_PER_UID_THRESHOLD);
+
+        // Add 2 new entries with pre-existing tag, verify they can be added no matter what.
+        final NetworkStats newDiffWithExistingTag = new NetworkStats(0L, 2);
+        // This one should be added as a new entry, as the metered data doesn't exist yet.
+        newDiffWithExistingTag.combineValues(
+                new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT,
+                        TEST_TAGS_PER_UID_THRESHOLD,
+                        METERED_YES, ROAMING_NO, DEFAULT_NETWORK_NO, 3L, 5L, 8L, 1L, 1L));
+        // This one should be combined into existing entry.
+        newDiffWithExistingTag.combineValues(
+                new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT,
+                        TEST_TAGS_PER_UID_THRESHOLD,
+                        METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 1L, 2L, 3L, 4L, 5L));
+
+        doReturn(newDiffWithExistingTag).when(mDeps).getNetworkStatsDetail();
+        final NetworkStats stats2 = mFactory.readNetworkStatsDetail();
+        assertEquals(stats2.size(), TEST_TAGS_PER_UID_THRESHOLD + 1);
+        assertValues(stats2, TEST_IFACE, UID_RED, SET_DEFAULT, TEST_TAGS_PER_UID_THRESHOLD,
+                METERED_YES, ROAMING_NO, DEFAULT_NETWORK_NO, 3L, 5L, 8L, 1L, 1L);
+        assertValues(stats2, TEST_IFACE, UID_RED, SET_DEFAULT, TEST_TAGS_PER_UID_THRESHOLD,
+                METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 13L, 20L, 17L, 5L, 5L);
+
+        // Add an entry which exceeds the threshold, verify the entry is filtered out.
+        final NetworkStats newDiffWithNonExistingTag = new NetworkStats(0L, 1);
+        newDiffWithNonExistingTag.combineValues(
+                new NetworkStats.Entry(TEST_IFACE, UID_RED, SET_DEFAULT,
+                        TEST_TAGS_PER_UID_THRESHOLD + 1,
+                        METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 12L, 18L, 14L, 1L, 0L));
+        doReturn(newDiffWithNonExistingTag).when(mDeps).getNetworkStatsDetail();
+        final NetworkStats stats3 = mFactory.readNetworkStatsDetail();
+        if (supportPerUidTagThrottling) {
+            assertEquals(stats3.size(), TEST_TAGS_PER_UID_THRESHOLD + 1);
+            assertNoStatsEntry(stats3, TEST_IFACE, UID_RED, SET_DEFAULT,
+                    TEST_TAGS_PER_UID_THRESHOLD + 1);
+        } else {
+            assertEquals(stats3.size(), TEST_TAGS_PER_UID_THRESHOLD + 2);
+            assertValues(stats3, TEST_IFACE, UID_RED, SET_DEFAULT,
+                    TEST_TAGS_PER_UID_THRESHOLD + 1,
+                    METERED_NO, ROAMING_NO, DEFAULT_NETWORK_NO, 12L, 18L, 14L, 1L, 0L);
+        }
+    }
+
     private NetworkStats buildEmptyStats() {
         return new NetworkStats(SystemClock.elapsedRealtime(), 0);
     }
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
index e62ac74..a534c22 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
@@ -35,8 +35,8 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Matchers.any;
-import static org.mockito.Matchers.anyInt;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 
 import android.content.Context;
 import android.net.DataUsageRequest;
@@ -288,7 +288,7 @@
 
         mStatsObservers.unregister(request, UID_BLUE);
         waitForObserverToIdle();
-        Mockito.verifyZeroInteractions(mUsageCallbackBinder);
+        Mockito.verifyNoMoreInteractions(mUsageCallbackBinder);
 
         // Verify that system uid can unregister for other uids.
         mStatsObservers.unregister(request, Process.SYSTEM_UID);
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index b528480..66af56b 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -65,7 +65,6 @@
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 import static android.text.format.DateUtils.WEEK_IN_MILLIS;
 
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
 import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
 import static com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf;
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_POLL;
@@ -93,9 +92,10 @@
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Matchers.eq;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
@@ -163,12 +163,13 @@
 import com.android.net.module.util.BpfDump;
 import com.android.net.module.util.IBpfMap;
 import com.android.net.module.util.LocationPermissionChecker;
+import com.android.net.module.util.SkDestroyListener;
 import com.android.net.module.util.Struct;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.Struct.U8;
 import com.android.net.module.util.bpf.CookieTagMapKey;
 import com.android.net.module.util.bpf.CookieTagMapValue;
-import com.android.server.BpfNetMaps;
+import com.android.net.module.util.netlink.InetDiagMessage;
 import com.android.server.connectivity.ConnectivityResources;
 import com.android.server.net.NetworkStatsService.AlertObserver;
 import com.android.server.net.NetworkStatsService.NetworkStatsSettings;
@@ -211,6 +212,7 @@
 import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
 
 /**
  * Tests for {@link NetworkStatsService}.
@@ -286,8 +288,6 @@
     private LocationPermissionChecker mLocationPermissionChecker;
     private TestBpfMap<S32, U8> mUidCounterSetMap = spy(new TestBpfMap<>(S32.class, U8.class));
     @Mock
-    private BpfNetMaps mBpfNetMaps;
-    @Mock
     private SkDestroyListener mSkDestroyListener;
 
     private TestBpfMap<CookieTagMapKey, CookieTagMapValue> mCookieTagMap = new TestBpfMap<>(
@@ -608,13 +608,8 @@
         }
 
         @Override
-        public BpfNetMaps makeBpfNetMaps(Context ctx) {
-            return mBpfNetMaps;
-        }
-
-        @Override
-        public SkDestroyListener makeSkDestroyListener(
-                IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
+        public SkDestroyListener makeSkDestroyListener(Consumer<InetDiagMessage> consumer,
+                Handler handler) {
             return mSkDestroyListener;
         }
 
diff --git a/tests/unit/jni/Android.bp b/tests/unit/jni/Android.bp
index 50971e7..1e9db03 100644
--- a/tests/unit/jni/Android.bp
+++ b/tests/unit/jni/Android.bp
@@ -22,6 +22,7 @@
     ],
 
     shared_libs: [
+        "libbase",
         "liblog",
         "libnativehelper",
         "libnetdutils",
@@ -42,7 +43,7 @@
     ],
     static_libs: [
         "libnet_utils_device_common_bpfjni",
-        "libnet_utils_device_common_timerfdjni",
+        "libserviceconnectivityjni",
         "libtcutils",
     ],
     shared_libs: [
diff --git a/tests/unit/jni/android_net_frameworktests_util/onload.cpp b/tests/unit/jni/android_net_frameworktests_util/onload.cpp
index a0ce4f8..f70b04b 100644
--- a/tests/unit/jni/android_net_frameworktests_util/onload.cpp
+++ b/tests/unit/jni/android_net_frameworktests_util/onload.cpp
@@ -24,7 +24,7 @@
 
 int register_com_android_net_module_util_BpfMap(JNIEnv* env, char const* class_name);
 int register_com_android_net_module_util_TcUtils(JNIEnv* env, char const* class_name);
-int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+int register_com_android_net_module_util_ServiceConnectivityJni(JNIEnv *env,
                                                       char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
@@ -40,8 +40,8 @@
     if (register_com_android_net_module_util_TcUtils(env,
             "android/net/frameworktests/util/TcUtils") < 0) return JNI_ERR;
 
-    if (register_com_android_net_module_util_TimerFdUtils(
-            env, "android/net/frameworktests/util/TimerFdUtils") < 0)
+    if (register_com_android_net_module_util_ServiceConnectivityJni(
+            env, "android/net/frameworktests/util/ServiceConnectivityJni") < 0)
       return JNI_ERR;
 
     return JNI_VERSION_1_6;
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 40842f1..0c38f93 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -16,6 +16,9 @@
     },
     {
       "name": "ThreadNetworkTrelDisabledTests"
+    },
+    {
+      "name": "ThreadBorderRouterIntegrationTests"
     }
   ]
 }
diff --git a/thread/demoapp/AndroidManifest.xml b/thread/demoapp/AndroidManifest.xml
index c31bb71..fddc151 100644
--- a/thread/demoapp/AndroidManifest.xml
+++ b/thread/demoapp/AndroidManifest.xml
@@ -33,6 +33,7 @@
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
             </intent-filter>
         </activity>
     </application>
diff --git a/thread/docs/build-an-android-border-router.md b/thread/docs/build-an-android-border-router.md
index f90a23b..2687e26 100644
--- a/thread/docs/build-an-android-border-router.md
+++ b/thread/docs/build-an-android-border-router.md
@@ -380,10 +380,12 @@
 [config_thread.xml](https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Connectivity/service/ServiceConnectivityResources/res/values/config_thread.xml)
 for the full list.
 
-Typically, you must change the `config_thread_vendor_name`,
-`config_thread_vendor_oui` and `config_thread_model_name` to your vendor or
-product values. Those values will be included in the `_meshcop._udp` mDNS
-service which is always advertised by a Thread Border Router.
+Typically, you must set `config_thread_border_router_default_enabled` to `true`
+to enable your device as a Thread Border Router, and change the
+`config_thread_vendor_name`, `config_thread_vendor_oui` and
+`config_thread_model_name` to your vendor or product values. Those values will
+be included in the `_meshcop._udp` mDNS service which is always advertised by a
+Thread Border Router.
 
 To add the overlay, you need to create a new `ConnectivityOverlayOrange`
 runtime_resource_overlay target for your Orange device. Create a new
@@ -436,6 +438,7 @@
   ```
 - `config_thread.xml`:
   ```
+  <bool name="config_thread_border_router_default_enabled">true</bool>
   <string translatable="false" name="config_thread_vendor_name">Banana Inc.</string>
   <string translatable="false" name="config_thread_vendor_oui">AC:DE:48</string>
   <string translatable="false" name="config_thread_model_name">Orange</string>
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 73a6bda..b649716 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -227,8 +227,8 @@
      * specific error:
      *
      * <ul>
-     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not
-     *       attached to Thread network
+     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not a
+     *       Border Router or not attached to Thread network
      *   <li>{@link ThreadNetworkException#ERROR_BUSY} when ephemeral key mode is already activated
      *       on the device, caller can recover from this error when the ephemeral key mode gets
      *       deactivated
@@ -267,7 +267,8 @@
      * connection will be terminated.
      *
      * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The call will
-     * always succeed if the device is not in ephemeral key mode.
+     * always succeed if the device is not in ephemeral key mode. It returns an error {@link
+     * ThreadNetworkException#ERROR_FAILED_PRECONDITION} if this device is not a Border Router.
      *
      * @param executor the executor to execute {@code receiver}
      * @param receiver the receiver to receive the result of this operation
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index c55096b..e99c88e 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -128,6 +128,7 @@
 import com.android.net.module.util.SharedLog;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.connectivity.MockableSystemProperties;
 import com.android.server.thread.openthread.BackboneRouterState;
 import com.android.server.thread.openthread.DnsTxtAttribute;
 import com.android.server.thread.openthread.IChannelMasksReceiver;
@@ -191,6 +192,7 @@
 
     private final Context mContext;
     private final Handler mHandler;
+    private final MockableSystemProperties mSystemProperties;
 
     // Below member fields can only be accessed from the handler thread (`mHandler`). In
     // particular, the constructor does not run on the handler thread, so it must not touch any of
@@ -235,6 +237,7 @@
     ThreadNetworkControllerService(
             Context context,
             Handler handler,
+            MockableSystemProperties systemProperties,
             NetworkProvider networkProvider,
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
@@ -249,6 +252,7 @@
             Map<Network, LinkProperties> networkToLinkProperties) {
         mContext = context;
         mHandler = handler;
+        mSystemProperties = systemProperties;
         mNetworkProvider = networkProvider;
         mOtDaemonSupplier = otDaemonSupplier;
         mConnectivityManager = connectivityManager;
@@ -286,6 +290,7 @@
         return new ThreadNetworkControllerService(
                 context,
                 handler,
+                new MockableSystemProperties(),
                 networkProvider,
                 () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")),
                 connectivityManager,
@@ -355,7 +360,7 @@
                 newOtDaemonConfig(mPersistentSettings.getConfiguration()),
                 mTunIfController.getTunFd(),
                 mNsdPublisher,
-                getMeshcopTxtAttributes(mResources.get()),
+                getMeshcopTxtAttributes(mResources.get(), mSystemProperties),
                 mCountryCodeSupplier.get(),
                 FeatureFlags.isTrelEnabled(),
                 mOtDaemonCallbackProxy);
@@ -365,10 +370,37 @@
         return mOtDaemon;
     }
 
+    static String getVendorName(Resources resources, MockableSystemProperties systemProperties) {
+        final String PROP_MANUFACTURER = "ro.product.manufacturer";
+        String vendorName = resources.getString(R.string.config_thread_vendor_name);
+        if (vendorName.equalsIgnoreCase(PROP_MANUFACTURER)) {
+            vendorName = systemProperties.get(PROP_MANUFACTURER);
+            // Assume it's always ASCII chars in ro.product.manufacturer
+            if (vendorName.length() > MAX_VENDOR_NAME_UTF8_BYTES) {
+                vendorName = vendorName.substring(0, MAX_VENDOR_NAME_UTF8_BYTES);
+            }
+        }
+        return vendorName;
+    }
+
+    static String getModelName(Resources resources, MockableSystemProperties systemProperties) {
+        final String PROP_MODEL = "ro.product.model";
+        String modelName = resources.getString(R.string.config_thread_model_name);
+        if (modelName.equalsIgnoreCase(PROP_MODEL)) {
+            modelName = systemProperties.get(PROP_MODEL);
+            // Assume it's always ASCII chars in ro.product.model
+            if (modelName.length() > MAX_MODEL_NAME_UTF8_BYTES) {
+                modelName = modelName.substring(0, MAX_MODEL_NAME_UTF8_BYTES);
+            }
+        }
+        return modelName;
+    }
+
     @VisibleForTesting
-    static MeshcopTxtAttributes getMeshcopTxtAttributes(Resources resources) {
-        final String modelName = resources.getString(R.string.config_thread_model_name);
-        final String vendorName = resources.getString(R.string.config_thread_vendor_name);
+    static MeshcopTxtAttributes getMeshcopTxtAttributes(
+            Resources resources, MockableSystemProperties systemProperties) {
+        final String vendorName = getVendorName(resources, systemProperties);
+        final String modelName = getModelName(resources, systemProperties);
         final String vendorOui = resources.getString(R.string.config_thread_vendor_oui);
         final String[] vendorSpecificTxts =
                 resources.getStringArray(R.array.config_thread_mdns_vendor_specific_txts);
@@ -559,7 +591,7 @@
             // The persistent setting keeps the desired enabled state, thus it's set regardless
             // the otDaemon set enabled state operation succeeded or not, so that it can recover
             // to the desired value after reboot.
-            mPersistentSettings.put(ThreadPersistentSettings.THREAD_ENABLED.key, isEnabled);
+            mPersistentSettings.put(ThreadPersistentSettings.KEY_THREAD_ENABLED, isEnabled);
         }
 
         try {
@@ -623,12 +655,22 @@
         mNat64CidrController.maybeUpdateNat64Cidr();
     }
 
-    private static OtDaemonConfiguration newOtDaemonConfig(
-            @NonNull ThreadConfiguration threadConfig) {
+    private OtDaemonConfiguration newOtDaemonConfig(ThreadConfiguration threadConfig) {
+        int srpServerConfig = R.bool.config_thread_srp_server_wait_for_border_routing_enabled;
+        boolean srpServerWaitEnabled = mResources.get().getBoolean(srpServerConfig);
+        int autoJoinConfig = R.bool.config_thread_border_router_auto_join_enabled;
+        boolean autoJoinEnabled = mResources.get().getBoolean(autoJoinConfig);
+        boolean countryCodeEnabled =
+                mResources.get().getBoolean(R.bool.config_thread_country_code_enabled);
         return new OtDaemonConfiguration.Builder()
                 .setBorderRouterEnabled(threadConfig.isBorderRouterEnabled())
                 .setNat64Enabled(threadConfig.isNat64Enabled())
                 .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
+                .setSrpServerWaitForBorderRoutingEnabled(srpServerWaitEnabled)
+                .setBorderRouterAutoJoinEnabled(autoJoinEnabled)
+                .setCountryCodeEnabled(countryCodeEnabled)
+                .setVendorName(getVendorName(mResources.get(), mSystemProperties))
+                .setModelName(getModelName(mResources.get(), mSystemProperties))
                 .build();
     }
 
@@ -738,7 +780,7 @@
     private boolean shouldEnableThread() {
         return !mForceStopOtDaemonEnabled
                 && !mUserRestricted
-                && mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED);
+                && mPersistentSettings.get(ThreadPersistentSettings.KEY_THREAD_ENABLED);
     }
 
     private void requestUpstreamNetwork() {
@@ -874,10 +916,8 @@
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED);
         final var scoreBuilder = new NetworkScore.Builder();
 
-        if (isBorderRouterMode()) {
-            netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK);
-            scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK);
-        }
+        netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK);
+        scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK);
 
         return new NetworkAgent(
                 mContext,
@@ -885,7 +925,7 @@
                 LOG.getTag(),
                 netCapsBuilder.build(),
                 getTunIfLinkProperties(),
-                isBorderRouterMode() ? newLocalNetworkConfig() : null,
+                newLocalNetworkConfig(),
                 scoreBuilder.build(),
                 new NetworkAgentConfig.Builder().build(),
                 mNetworkProvider) {
@@ -894,9 +934,8 @@
             @Override
             public void onNetworkUnwanted() {
                 LOG.i("Thread network is unwanted by ConnectivityService");
-                if (!isBorderRouterMode()) {
-                    leave(false /* eraseDataset */, new LoggingOperationReceiver("leave"));
-                }
+                // TODO(b/374037595): leave() the current network when the new APIs for mobile
+                // is available
             }
         };
     }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
index 2cd34e8..16196fa 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
@@ -16,7 +16,9 @@
 
 package com.android.server.thread;
 
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
+
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
 
 import android.annotation.Nullable;
 import android.annotation.StringDef;
@@ -28,11 +30,13 @@
 import android.location.Address;
 import android.location.Geocoder;
 import android.location.Location;
+import android.location.LocationListener;
 import android.location.LocationManager;
 import android.net.thread.IOperationReceiver;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.ActiveCountryCodeChangedCallback;
 import android.os.Build;
+import android.os.Bundle;
 import android.sysprop.ThreadNetworkProperties;
 import android.telephony.SubscriptionInfo;
 import android.telephony.SubscriptionManager;
@@ -115,6 +119,13 @@
             new ArrayMap();
     private final ThreadPersistentSettings mPersistentSettings;
 
+    @Nullable private LocationListener mLocationListener;
+    @Nullable private WifiCountryCodeCallback mWifiCountryCodeCallback;
+    @Nullable private BroadcastReceiver mTelephonyBroadcastReceiver;
+
+    /** Indicates whether the Thread co-processor supports setting the country code. */
+    private boolean mIsCpSettingCountryCodeSupported = true;
+
     @Nullable private CountryCodeInfo mCurrentCountryCodeInfo;
     @Nullable private CountryCodeInfo mLocationCountryCodeInfo;
     @Nullable private CountryCodeInfo mOverrideCountryCodeInfo;
@@ -213,6 +224,10 @@
                 .getBoolean(R.bool.config_thread_location_use_for_country_code_enabled);
     }
 
+    private boolean isCountryCodeEnabled() {
+        return mResources.get().getBoolean(R.bool.config_thread_country_code_enabled);
+    }
+
     public ThreadNetworkCountryCode(
             LocationManager locationManager,
             ThreadNetworkControllerService threadNetworkControllerService,
@@ -260,6 +275,11 @@
 
     /** Sets up this country code module to listen to location country code changes. */
     public synchronized void initialize() {
+        if (!isCountryCodeEnabled()) {
+            LOG.i("Thread country code is disabled");
+            return;
+        }
+
         registerGeocoderCountryCodeCallback();
         registerWifiCountryCodeCallback();
         registerTelephonyCountryCodeCallback();
@@ -267,13 +287,40 @@
         updateCountryCode(false /* forceUpdate */);
     }
 
+    private synchronized void unregisterAllCountryCodeCallbacks() {
+        unregisterGeocoderCountryCodeCallback();
+        unregisterWifiCountryCodeCallback();
+        unregisterTelephonyCountryCodeCallback();
+    }
+
     private synchronized void registerGeocoderCountryCodeCallback() {
         if ((mGeocoder != null) && isLocationUseForCountryCodeEnabled()) {
+            mLocationListener =
+                    new LocationListener() {
+                        public void onLocationChanged(Location location) {
+                            setCountryCodeFromGeocodingLocation(location);
+                        }
+
+                        // not used yet
+                        public void onProviderDisabled(String provider) {}
+
+                        public void onProviderEnabled(String provider) {}
+
+                        public void onStatusChanged(String provider, int status, Bundle extras) {}
+                    };
+
             mLocationManager.requestLocationUpdates(
                     LocationManager.PASSIVE_PROVIDER,
                     TIME_BETWEEN_LOCATION_UPDATES_MS,
                     DISTANCE_BETWEEN_LOCALTION_UPDATES_METERS,
-                    location -> setCountryCodeFromGeocodingLocation(location));
+                    mLocationListener);
+        }
+    }
+
+    private synchronized void unregisterGeocoderCountryCodeCallback() {
+        if (mLocationListener != null) {
+            mLocationManager.removeUpdates(mLocationListener);
+            mLocationListener = null;
         }
     }
 
@@ -313,8 +360,16 @@
 
     private synchronized void registerWifiCountryCodeCallback() {
         if (mWifiManager != null) {
+            mWifiCountryCodeCallback = new WifiCountryCodeCallback();
             mWifiManager.registerActiveCountryCodeChangedCallback(
-                    r -> r.run(), new WifiCountryCodeCallback());
+                    r -> r.run(), mWifiCountryCodeCallback);
+        }
+    }
+
+    private synchronized void unregisterWifiCountryCodeCallback() {
+        if ((mWifiManager != null) && (mWifiCountryCodeCallback != null)) {
+            mWifiManager.unregisterActiveCountryCodeChangedCallback(mWifiCountryCodeCallback);
+            mWifiCountryCodeCallback = null;
         }
     }
 
@@ -353,7 +408,7 @@
             return;
         }
 
-        BroadcastReceiver broadcastReceiver =
+        mTelephonyBroadcastReceiver =
                 new BroadcastReceiver() {
                     @Override
                     public void onReceive(Context context, Intent intent) {
@@ -377,11 +432,18 @@
                 };
 
         mContext.registerReceiver(
-                broadcastReceiver,
+                mTelephonyBroadcastReceiver,
                 new IntentFilter(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED),
                 Context.RECEIVER_EXPORTED);
     }
 
+    private synchronized void unregisterTelephonyCountryCodeCallback() {
+        if (mTelephonyBroadcastReceiver != null) {
+            mContext.unregisterReceiver(mTelephonyBroadcastReceiver);
+            mTelephonyBroadcastReceiver = null;
+        }
+    }
+
     private synchronized void updateTelephonyCountryCodeFromSimCard() {
         List<SubscriptionInfo> subscriptionInfoList =
                 mSubscriptionManager.getActiveSubscriptionInfoList();
@@ -496,7 +558,7 @@
             return mLocationCountryCodeInfo;
         }
 
-        String settingsCountryCode = mPersistentSettings.get(THREAD_COUNTRY_CODE);
+        String settingsCountryCode = mPersistentSettings.get(KEY_COUNTRY_CODE);
         if (settingsCountryCode != null) {
             return new CountryCodeInfo(settingsCountryCode, COUNTRY_CODE_SOURCE_SETTINGS);
         }
@@ -514,13 +576,17 @@
             public void onSuccess() {
                 synchronized ("ThreadNetworkCountryCode.this") {
                     mCurrentCountryCodeInfo = countryCodeInfo;
-                    mPersistentSettings.put(
-                            THREAD_COUNTRY_CODE.key, countryCodeInfo.getCountryCode());
+                    mPersistentSettings.put(KEY_COUNTRY_CODE, countryCodeInfo.getCountryCode());
                 }
             }
 
             @Override
             public void onError(int otError, String message) {
+                if (otError == ERROR_UNSUPPORTED_FEATURE) {
+                    mIsCpSettingCountryCodeSupported = false;
+                    unregisterAllCountryCodeCallbacks();
+                }
+
                 LOG.e(
                         "Error "
                                 + otError
@@ -547,6 +613,11 @@
             return;
         }
 
+        if (!mIsCpSettingCountryCodeSupported) {
+            LOG.i("Thread co-processor doesn't support setting the country code");
+            return;
+        }
+
         LOG.i("Set country code: " + countryCodeInfo);
         mThreadNetworkControllerService.setCountryCode(
                 countryCodeInfo.getCountryCode().toUpperCase(Locale.ROOT),
@@ -593,6 +664,8 @@
     /** Dumps the current state of this ThreadNetworkCountryCode object. */
     public synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
         pw.println("---- Dump of ThreadNetworkCountryCode begin ----");
+        pw.println("isCountryCodeEnabled            : " + isCountryCodeEnabled());
+        pw.println("mIsCpSettingCountryCodeSupported: " + mIsCpSettingCountryCodeSupported);
         pw.println("mOverrideCountryCodeInfo        : " + mOverrideCountryCodeInfo);
         pw.println("mTelephonyCountryCodeSlotInfoMap: " + mTelephonyCountryCodeSlotInfoMap);
         pw.println("mTelephonyCountryCodeInfo       : " + mTelephonyCountryCodeInfo);
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index 746b587..fd6dec7 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -40,6 +40,8 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Store persistent data for Thread network settings. These are key (string) / value pairs that are
@@ -53,54 +55,55 @@
     /** File name used for storing settings. */
     private static final String FILE_NAME = "ThreadPersistentSettings.xml";
 
-    /** Current config store data version. This will be incremented for any additions. */
+    /** Current config store data version. This MUST be incremented for any incompatible changes. */
     private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
 
     /**
      * Stores the version of the data. This can be used to handle migration of data if some
      * non-backward compatible change introduced.
      */
-    private static final String VERSION_KEY = "version";
+    private static final String KEY_VERSION = "version";
 
-    /******** Thread persistent setting keys ***************/
-    /** Stores the Thread feature toggle state, true for enabled and false for disabled. */
-    public static final Key<Boolean> THREAD_ENABLED = new Key<>("thread_enabled", true);
+    /**
+     * Saves the boolean flag for Thread being enabled. The value defaults to resource overlay value
+     * {@code R.bool.config_thread_default_enabled}.
+     */
+    public static final Key<Boolean> KEY_THREAD_ENABLED = new Key<>("thread_enabled");
+
+    /**
+     * Saves the boolean flag for border router being enabled. The value defaults to resource
+     * overlay value {@code R.bool.config_thread_border_router_default_enabled}.
+     */
+    private static final Key<Boolean> KEY_CONFIG_BORDER_ROUTER_ENABLED =
+            new Key<>("config_border_router_enabled");
+
+    /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
+    private static final Key<Boolean> KEY_CONFIG_NAT64_ENABLED = new Key<>("config_nat64_enabled");
+
+    /**
+     * Stores the Thread DHCPv6-PD feature toggle state, true for enabled and false for disabled.
+     */
+    private static final Key<Boolean> KEY_CONFIG_DHCP6_PD_ENABLED =
+            new Key<>("config_dhcp6_pd_enabled");
 
     /**
      * Indicates that Thread was enabled (i.e. via the setEnabled() API) when the airplane mode is
      * turned on in settings. When this value is {@code true}, the current airplane mode state will
      * be ignored when evaluating the Thread enabled state.
      */
-    public static final Key<Boolean> THREAD_ENABLED_IN_AIRPLANE_MODE =
-            new Key<>("thread_enabled_in_airplane_mode", false);
+    public static final Key<Boolean> KEY_THREAD_ENABLED_IN_AIRPLANE_MODE =
+            new Key<>("thread_enabled_in_airplane_mode");
 
     /** Stores the Thread country code, null if no country code is stored. */
-    public static final Key<String> THREAD_COUNTRY_CODE = new Key<>("thread_country_code", null);
-
-    /**
-     * Saves the boolean flag for border router being enabled. The value defaults to {@code true} if
-     * this config is missing.
-     */
-    private static final Key<Boolean> CONFIG_BORDER_ROUTER_ENABLED =
-            new Key<>("config_border_router_enabled", true);
-
-    /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
-    private static final Key<Boolean> CONFIG_NAT64_ENABLED =
-            new Key<>("config_nat64_enabled", false);
-
-    /**
-     * Stores the Thread DHCPv6-PD feature toggle state, true for enabled and false for disabled.
-     */
-    private static final Key<Boolean> CONFIG_DHCP6_PD_ENABLED =
-            new Key<>("config_dhcp6_pd_enabled", false);
-
-    /******** Thread persistent setting keys ***************/
+    public static final Key<String> KEY_COUNTRY_CODE = new Key<>("thread_country_code");
 
     @GuardedBy("mLock")
     private final AtomicFile mAtomicFile;
 
     private final Object mLock = new Object();
 
+    private final Map<String, Object> mDefaultValues = new HashMap<>();
+
     @GuardedBy("mLock")
     private final PersistableBundle mSettings = new PersistableBundle();
 
@@ -116,19 +119,22 @@
     ThreadPersistentSettings(AtomicFile atomicFile, ConnectivityResources resources) {
         mAtomicFile = atomicFile;
         mResources = resources;
+
+        mDefaultValues.put(
+                KEY_THREAD_ENABLED.key,
+                mResources.get().getBoolean(R.bool.config_thread_default_enabled));
+        mDefaultValues.put(
+                KEY_CONFIG_BORDER_ROUTER_ENABLED.key,
+                mResources.get().getBoolean(R.bool.config_thread_border_router_default_enabled));
+        mDefaultValues.put(KEY_CONFIG_NAT64_ENABLED.key, false);
+        mDefaultValues.put(KEY_CONFIG_DHCP6_PD_ENABLED.key, false);
+        mDefaultValues.put(KEY_THREAD_ENABLED_IN_AIRPLANE_MODE.key, false);
+        mDefaultValues.put(KEY_COUNTRY_CODE.key, null);
     }
 
     /** Initialize the settings by reading from the settings file. */
     public void initialize() {
         readFromStoreFile();
-        synchronized (mLock) {
-            if (!mSettings.containsKey(THREAD_ENABLED.key)) {
-                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));
-            }
-        }
     }
 
     private void putObject(String key, @Nullable Object value) {
@@ -173,25 +179,17 @@
         return (T) value;
     }
 
-    /**
-     * Store a value to the stored settings.
-     *
-     * @param key One of the settings keys.
-     * @param value Value to be stored.
-     */
-    public <T> void put(String key, @Nullable T value) {
-        putObject(key, value);
+    /** Stores a value to the stored settings. */
+    public <T> void put(Key<T> key, @Nullable T value) {
+        putObject(key.key, value);
         writeToStoreFile();
     }
 
-    /**
-     * Retrieve a value from the stored settings.
-     *
-     * @param key One of the settings keys.
-     * @return value stored in settings, defValue if the key does not exist.
-     */
+    /** Retrieves a value from the stored settings. */
+    @Nullable
     public <T> T get(Key<T> key) {
-        return getObject(key.key, key.defaultValue);
+        T defaultValue = (T) mDefaultValues.get(key.key);
+        return getObject(key.key, defaultValue);
     }
 
     /**
@@ -204,9 +202,9 @@
         if (getConfiguration().equals(configuration)) {
             return false;
         }
-        putObject(CONFIG_BORDER_ROUTER_ENABLED.key, configuration.isBorderRouterEnabled());
-        putObject(CONFIG_NAT64_ENABLED.key, configuration.isNat64Enabled());
-        putObject(CONFIG_DHCP6_PD_ENABLED.key, configuration.isDhcpv6PdEnabled());
+        put(KEY_CONFIG_BORDER_ROUTER_ENABLED, configuration.isBorderRouterEnabled());
+        put(KEY_CONFIG_NAT64_ENABLED, configuration.isNat64Enabled());
+        put(KEY_CONFIG_DHCP6_PD_ENABLED, configuration.isDhcpv6PdEnabled());
         writeToStoreFile();
         return true;
     }
@@ -214,9 +212,9 @@
     /** Retrieve the {@link ThreadConfiguration} from the persistent settings. */
     public ThreadConfiguration getConfiguration() {
         return new ThreadConfiguration.Builder()
-                .setBorderRouterEnabled(get(CONFIG_BORDER_ROUTER_ENABLED))
-                .setNat64Enabled(get(CONFIG_NAT64_ENABLED))
-                .setDhcpv6PdEnabled(get(CONFIG_DHCP6_PD_ENABLED))
+                .setBorderRouterEnabled(get(KEY_CONFIG_BORDER_ROUTER_ENABLED))
+                .setNat64Enabled(get(KEY_CONFIG_NAT64_ENABLED))
+                .setDhcpv6PdEnabled(get(KEY_CONFIG_DHCP6_PD_ENABLED))
                 .build();
     }
 
@@ -225,18 +223,11 @@
      *
      * @param <T> Type of the value.
      */
-    public static class Key<T> {
-        public final String key;
-        public final T defaultValue;
+    public static final class Key<T> {
+        @VisibleForTesting final String key;
 
-        private Key(String key, T defaultValue) {
+        private Key(String key) {
             this.key = key;
-            this.defaultValue = defaultValue;
-        }
-
-        @Override
-        public String toString() {
-            return "[Key: " + key + ", DefaultValue: " + defaultValue + "]";
         }
     }
 
@@ -247,7 +238,7 @@
             synchronized (mLock) {
                 bundleToWrite = new PersistableBundle(mSettings);
             }
-            bundleToWrite.putInt(VERSION_KEY, CURRENT_SETTINGS_STORE_DATA_VERSION);
+            bundleToWrite.putInt(KEY_VERSION, CURRENT_SETTINGS_STORE_DATA_VERSION);
             bundleToWrite.writeToStream(outputStream);
             synchronized (mLock) {
                 writeToAtomicFile(mAtomicFile, outputStream.toByteArray());
@@ -267,7 +258,7 @@
             final ByteArrayInputStream inputStream = new ByteArrayInputStream(readData);
             final PersistableBundle bundleRead = PersistableBundle.readFromStream(inputStream);
             // Version unused for now. May be needed in the future for handling migrations.
-            bundleRead.remove(VERSION_KEY);
+            bundleRead.remove(KEY_VERSION);
             synchronized (mLock) {
                 mSettings.putAll(bundleRead);
             }
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index 520a434..2f2a5d1 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -326,12 +326,13 @@
     private static LinkAddress newLinkAddress(
             Ipv6AddressInfo addressInfo, boolean hasActiveOmrAddress) {
         // Mesh-local addresses and OMR address have the same scope, to distinguish them we set
-        // mesh-local addresses as deprecated when there is an active OMR address.
+        // mesh-local addresses as deprecated when there is an active OMR address. If OMR address
+        // is missing, only ML-EID in mesh-local addresses will be set preferred.
         // For OMR address and link-local address we only use the value isPreferred set by
         // ot-daemon.
         boolean isPreferred = addressInfo.isPreferred;
-        if (addressInfo.isMeshLocal && hasActiveOmrAddress) {
-            isPreferred = false;
+        if (addressInfo.isMeshLocal) {
+            isPreferred = (!hasActiveOmrAddress && addressInfo.isMeshLocalEid);
         }
 
         final long deprecationTimeMillis =
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 2630d21..901dee7 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -51,7 +51,6 @@
     libs: [
         "android.test.base.stubs",
         "android.test.runner.stubs",
-        "framework-connectivity-module-api-stubs-including-flagged",
     ],
     // Test coverage system runs on different devices. Need to
     // compile for all architectures.
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index e954d3b..89d2ce5 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -57,13 +57,4 @@
         <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
 
-    <!--
-        This doesn't override a read-only flag, to run the tests locally with `epskc_enabled` flag
-        enabled, set the flag to `is_fixed_read_only: false`. This should be removed after the
-        `epskc_enabled` flag is rolled out.
-    -->
-    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
-        <option name="flag-value"
-                value="thread_network/com.android.net.thread.flags.epskc_enabled=true"/>
-    </target_preparer>
 </configuration>
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 2d487ca..2d68119 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -49,6 +49,8 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
@@ -94,12 +96,15 @@
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -118,6 +123,7 @@
 /** CTS tests for {@link ThreadNetworkController}. */
 @LargeTest
 @RequiresThreadFeature
+@RunWith(Parameterized.class)
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
     private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
@@ -134,8 +140,6 @@
     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() {
                 {
@@ -161,6 +165,22 @@
     private final List<Consumer<ThreadConfiguration>> mConfigurationCallbacksToCleanUp =
             new ArrayList<>();
 
+    public final boolean mIsBorderRouterEnabled;
+    private final ThreadConfiguration mDefaultConfig;
+
+    @Parameterized.Parameters
+    public static Collection configArguments() {
+        return Arrays.asList(new Object[][] {{false}, {true}});
+    }
+
+    public ThreadNetworkControllerTest(boolean isBorderRouterEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
+        mDefaultConfig =
+                new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(isBorderRouterEnabled)
+                        .build();
+    }
+
     @Before
     public void setUp() throws Exception {
         mController =
@@ -175,8 +195,10 @@
         mHandlerThread.start();
 
         setEnabledAndWait(mController, true);
-        setConfigurationAndWait(mController, DEFAULT_CONFIG);
-        deactivateEphemeralKeyModeAndWait(mController);
+        setConfigurationAndWait(mController, mDefaultConfig);
+        if (mDefaultConfig.isBorderRouterEnabled()) {
+            deactivateEphemeralKeyModeAndWait(mController);
+        }
     }
 
     @After
@@ -185,7 +207,7 @@
         setEnabledAndWait(mController, true);
         leaveAndWait(mController);
         tearDownTestNetwork();
-        setConfigurationAndWait(mController, DEFAULT_CONFIG);
+        setConfigurationAndWait(mController, mDefaultConfig);
         for (Consumer<ThreadConfiguration> configurationCallback :
                 mConfigurationCallbacksToCleanUp) {
             try {
@@ -197,7 +219,9 @@
             }
         }
         mConfigurationCallbacksToCleanUp.clear();
-        deactivateEphemeralKeyModeAndWait(mController);
+        if (mDefaultConfig.isBorderRouterEnabled()) {
+            deactivateEphemeralKeyModeAndWait(mController);
+        }
     }
 
     @Test
@@ -573,7 +597,7 @@
                     @Override
                     public void onActiveOperationalDatasetChanged(
                             ActiveOperationalDataset activeDataset) {
-                        if (activeDataset.equals(activeDataset2)) {
+                        if (Objects.equals(activeDataset, activeDataset2)) {
                             dataset2IsApplied.complete(true);
                         }
                     }
@@ -843,6 +867,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         joinRandomizedDatasetAndWait(mController);
         CompletableFuture<Void> startFuture = new CompletableFuture<>();
 
@@ -861,6 +886,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         dropAllPermissions();
 
         assertThrows(
@@ -874,6 +900,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
 
         assertThrows(
@@ -885,6 +912,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
         Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
 
@@ -897,6 +925,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         joinRandomizedDatasetAndWait(mController);
         CompletableFuture<Void> future1 = new CompletableFuture<>();
         CompletableFuture<Void> future2 = new CompletableFuture<>();
@@ -945,6 +974,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         dropAllPermissions();
 
         assertThrows(
@@ -956,9 +986,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void deactivateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
             throws Exception {
-        setConfigurationAndWait(
-                mController,
-                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        assumeFalse(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
         CompletableFuture<Void> future = new CompletableFuture<>();
 
@@ -975,6 +1003,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
         CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
         CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
@@ -1011,6 +1040,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
         CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
         CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
@@ -1050,6 +1080,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
         joinRandomizedDatasetAndWait(mController);
 
@@ -1068,6 +1099,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
         Triple<Integer, String, Instant> epskc1;
         try {
@@ -1173,7 +1205,7 @@
                 THREAD_NETWORK_PRIVILEGED,
                 () -> registerConfigurationCallback(mController, mExecutor, callback));
         assertThat(getConfigFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
-                .isEqualTo(DEFAULT_CONFIG);
+                .isEqualTo(mDefaultConfig);
     }
 
     @Test
@@ -1216,7 +1248,7 @@
             setFuture1.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
             setFuture2.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
 
-            listener.expectConfiguration(DEFAULT_CONFIG);
+            listener.expectConfiguration(mDefaultConfig);
             listener.expectConfiguration(config1);
             listener.expectConfiguration(config2);
             listener.expectNoMoreConfiguration();
@@ -1296,6 +1328,7 @@
     }
 
     @Test
+    @Ignore("b/333649897, b/332195449: The 3 meshcop tests are flaky in different environments")
     public void meshcopService_threadEnabledButNotJoined_discoveredButNoNetwork() throws Exception {
         setUpTestNetwork();
 
@@ -1348,6 +1381,7 @@
     }
 
     @Test
+    @Ignore("b/333649897, b/332195449: The 3 meshcop tests are flaky in different environments")
     public void meshcopService_threadDisabled_notDiscovered() throws Exception {
         setUpTestNetwork();
         CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
index b6d0d31..6165afa 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
@@ -19,12 +19,10 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assume.assumeFalse;
-import static org.junit.Assume.assumeNotNull;
 import static org.junit.Assume.assumeTrue;
 
 import android.content.Context;
 import android.content.pm.PackageManager;
-import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkManager;
 import android.os.Build;
 
@@ -41,12 +39,12 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.util.List;
-
 /** Tests for {@link ThreadNetworkManager}. */
 @SmallTest
 @RunWith(AndroidJUnit4.class)
 public class ThreadNetworkManagerTest {
+    private static final String THREAD_NETWORK_FEATURE = "android.hardware.thread_network";
+
     @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
@@ -68,7 +66,7 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void getManager_hasThreadFeatureOnVOrHigher_returnsNonNull() {
-        assumeTrue(mPackageManager.hasSystemFeature("android.hardware.thread_network"));
+        assumeTrue(mPackageManager.hasSystemFeature(THREAD_NETWORK_FEATURE));
 
         assertThat(mManager).isNotNull();
     }
@@ -85,26 +83,10 @@
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     @IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
-    public void getManager_onUAndTv_returnsNonNull() {
+    public void getManager_onUAndTvWithThreadFeature_returnsNonNull() {
         assumeTrue(mPackageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK));
+        assumeTrue(mPackageManager.hasSystemFeature(THREAD_NETWORK_FEATURE));
 
         assertThat(mManager).isNotNull();
     }
-
-    @Test
-    public void getManager_noThreadFeature_returnsNull() {
-        assumeFalse(mPackageManager.hasSystemFeature("android.hardware.thread_network"));
-
-        assertThat(mManager).isNull();
-    }
-
-    @Test
-    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
-    public void getAllThreadNetworkControllers_managerIsNotNull_returnsNotEmptyList() {
-        assumeNotNull(mManager);
-
-        List<ThreadNetworkController> controllers = mManager.getAllThreadNetworkControllers();
-
-        assertThat(controllers).isNotEmpty();
-    }
 }
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 798a51e..8a72017 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -64,6 +64,26 @@
 }
 
 android_test {
+    name: "ThreadBorderRouterIntegrationTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTestBorderRouter.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults",
+    ],
+    test_suites: [
+        "mts-tethering",
+        "general-tests",
+    ],
+    srcs: [
+        "borderrouter/**/*.java",
+        "borderrouter/**/*.kt",
+    ],
+    compile_multilib: "both",
+}
+
+android_test {
     name: "ThreadNetworkTrelDisabledTests",
     platform_apis: true,
     manifest: "AndroidManifest.xml",
diff --git a/thread/tests/integration/AndroidManifest.xml b/thread/tests/integration/AndroidManifest.xml
index a049184..8bee1e1 100644
--- a/thread/tests/integration/AndroidManifest.xml
+++ b/thread/tests/integration/AndroidManifest.xml
@@ -24,6 +24,7 @@
     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
     <uses-permission android:name="android.permission.THREAD_NETWORK_PRIVILEGED"/>
     <uses-permission android:name="android.permission.NETWORK_SETTINGS"/>
+    <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
     <uses-permission android:name="android.permission.INTERNET"/>
 
     <application android:debuggable="true">
diff --git a/thread/tests/integration/AndroidTestBorderRouter.xml b/thread/tests/integration/AndroidTestBorderRouter.xml
new file mode 100644
index 0000000..644e839
--- /dev/null
+++ b/thread/tests/integration/AndroidTestBorderRouter.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2025 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.
+ -->
+
+<configuration description="Config for Thread Border Router integration tests">
+    <option name="test-tag" value="ThreadBorderRouterIntegrationTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <!--
+        Only run tests if the device under test is SDK version 34 (Android 14) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk34ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.DeviceFeatureModuleController">
+        <option name="required-feature" value="android.hardware.thread_network" />
+    </object>
+
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <!-- Install test -->
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadBorderRouterIntegrationTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.thread.tests.integration" />
+    </test>
+
+    <!-- Enable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=true"/>
+    </target_preparer>
+</configuration>
diff --git a/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java
new file mode 100644
index 0000000..292079f
--- /dev/null
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2025 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.ThreadNetworkController.DEVICE_ROLE_DETACHED;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
+import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6Addresses;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
+import static android.net.thread.utils.IntegrationTestUtils.getPrefixesFromNetData;
+import static android.net.thread.utils.IntegrationTestUtils.getThreadNetwork;
+import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
+import static android.net.thread.utils.IntegrationTestUtils.waitFor;
+import static android.os.SystemClock.elapsedRealtime;
+
+import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.InetAddresses;
+import android.net.IpPrefix;
+import android.net.LinkProperties;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
+import android.net.thread.utils.ThreadStateListener;
+import android.os.HandlerThread;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.google.common.collect.FluentIterable;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** Tests for E2E Border Router integration with ot-daemon, ConnectivityService, etc.. */
+@LargeTest
+@RequiresThreadFeature
+@RunWith(AndroidJUnit4.class)
+public class BorderRouterIntegrationTest {
+    // The maximum time for changes to be propagated to netdata.
+    private static final Duration NET_DATA_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The maximum time for OT addresses to be propagated to the TUN interface "thread-wpan"
+    private static final Duration TUN_ADDR_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The maximum time for changes in netdata to be propagated to link properties.
+    private static final Duration LINK_PROPERTIES_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The duration between attached and OMR address shows up on thread-wpan
+    private static final Duration OMR_LINK_ADDR_TIMEOUT = Duration.ofSeconds(30);
+
+    // 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");
+    private static final ActiveOperationalDataset DEFAULT_DATASET =
+            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
+
+    private static final Inet6Address GROUP_ADDR_ALL_ROUTERS =
+            (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
+
+    private static final String TEST_NO_SLAAC_PREFIX = "9101:dead:beef:cafe::/64";
+    private static final InetAddress TEST_NO_SLAAC_PREFIX_ADDRESS =
+            InetAddresses.parseNumericAddress("9101:dead:beef:cafe::");
+
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private ExecutorService mExecutor;
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+    private FullThreadDevice mFtd;
+    private HandlerThread mHandlerThread;
+    private TapTestNetworkTracker mTestNetworkTracker;
+
+    @Before
+    public void setUp() throws Exception {
+        mExecutor = Executors.newSingleThreadExecutor();
+        mFtd = new FullThreadDevice(10 /* nodeId */);
+        mOtCtl = new OtDaemonController();
+        mController.setEnabledAndWait(true);
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(true).build());
+        mController.leaveAndWait();
+
+        mHandlerThread = new HandlerThread("ThreadIntegrationTest");
+        mHandlerThread.start();
+
+        mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
+        assertThat(mTestNetworkTracker).isNotNull();
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        ThreadStateListener.stopAllListeners();
+
+        if (mTestNetworkTracker != null) {
+            mTestNetworkTracker.tearDown();
+        }
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
+
+        mFtd.destroy();
+        mExecutor.shutdownNow();
+    }
+
+    @Test
+    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
+            throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        runShellCommand("stop ot-daemon");
+
+        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
+        assertThat(mOtCtl.isInterfaceUp()).isTrue();
+        assertThat(runShellCommand("ifconfig thread-wpan")).contains("UP POINTOPOINT RUNNING");
+    }
+
+    @Test
+    public void joinNetwork_tunInterfaceJoinsAllRouterMulticastGroup() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        waitFor(
+                () -> isInMulticastGroup("thread-wpan", GROUP_ADDR_ALL_ROUTERS),
+                TUN_ADDR_UPDATE_TIMEOUT);
+    }
+
+    @Test
+    public void joinNetwork_allMlAddrAreNotPreferredAndOmrIsPreferred() throws Exception {
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+        mController.joinAndWait(DEFAULT_DATASET);
+        waitFor(
+                () -> getIpv6Addresses("thread-wpan").contains(mOtCtl.getOmrAddress()),
+                OMR_LINK_ADDR_TIMEOUT);
+
+        IpPrefix meshLocalPrefix = DEFAULT_DATASET.getMeshLocalPrefix();
+        var linkAddrs = FluentIterable.from(getIpv6LinkAddresses("thread-wpan"));
+        var meshLocalAddrs = linkAddrs.filter(addr -> meshLocalPrefix.contains(addr.getAddress()));
+        assertThat(meshLocalAddrs).isNotEmpty();
+        assertThat(meshLocalAddrs.allMatch(addr -> !addr.isPreferred())).isTrue();
+        assertThat(meshLocalAddrs.allMatch(addr -> addr.getDeprecationTime() <= elapsedRealtime()))
+                .isTrue();
+        var omrAddrs = linkAddrs.filter(addr -> addr.getAddress().equals(mOtCtl.getOmrAddress()));
+        assertThat(omrAddrs).hasSize(1);
+        assertThat(omrAddrs.get(0).isPreferred()).isTrue();
+        assertThat(omrAddrs.get(0).getDeprecationTime() > elapsedRealtime()).isTrue();
+    }
+
+    @Test
+    @RequiresSimulationThreadDevice
+    public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        startFtdChild(mFtd, DEFAULT_DATASET);
+        List<Inet6Address> meshLocalAddresses = mOtCtl.getMeshLocalAddresses();
+
+        for (Inet6Address address : meshLocalAddresses) {
+            assertWithMessage(
+                            "There may be duplicated replies of ping request to "
+                                    + address.getHostAddress())
+                    .that(mFtd.ping(address, 2))
+                    .isEqualTo(2);
+        }
+    }
+
+    @Test
+    public void addPrefixToNetData_routeIsAddedToTunInterface() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        // Ftd child doesn't have the ability to add a prefix, so let BR itself add a prefix.
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+        waitFor(
+                () -> {
+                    String netData = mOtCtl.executeCommand("netdata show");
+                    return getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
+                },
+                NET_DATA_UPDATE_TIMEOUT);
+
+        assertRouteAddedOrRemovedInLinkProperties(true /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    @Test
+    public void removePrefixFromNetData_routeIsRemovedFromTunInterface() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+
+        mOtCtl.executeCommand("prefix remove " + TEST_NO_SLAAC_PREFIX);
+        mOtCtl.executeCommand("netdata register");
+        waitFor(
+                () -> {
+                    String netData = mOtCtl.executeCommand("netdata show");
+                    return !getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
+                },
+                NET_DATA_UPDATE_TIMEOUT);
+
+        assertRouteAddedOrRemovedInLinkProperties(
+                false /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    @Test
+    public void toggleThreadNetwork_routeFromPreviousNetDataIsRemoved() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+
+        mController.leaveAndWait();
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        assertRouteAddedOrRemovedInLinkProperties(
+                false /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    private void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+    }
+
+    private void assertRouteAddedOrRemovedInLinkProperties(boolean isAdded, InetAddress addr)
+            throws Exception {
+        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+
+        waitFor(
+                () -> {
+                    try {
+                        LinkProperties lp =
+                                cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
+                        return lp != null
+                                && isAdded
+                                        == lp.getRoutes().stream().anyMatch(r -> r.matches(addr));
+                    } catch (Exception e) {
+                        return false;
+                    }
+                },
+                LINK_PROPERTIES_UPDATE_TIMEOUT);
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
similarity index 99%
rename from thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
rename to thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
index 875a4ad..1d210c6 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
@@ -14,12 +14,12 @@
  * limitations under the License.
  */
 
-package android.net.thread;
+package android.net.thread.borderrouter;
 
 import static android.net.InetAddresses.parseNumericAddress;
 import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 import static android.net.thread.utils.IntegrationTestUtils.buildIcmpv4EchoReply;
-import static android.net.thread.utils.IntegrationTestUtils.enableThreadAndJoinNetwork;
+import static android.net.thread.utils.IntegrationTestUtils.enableBorderRouterAndJoinNetwork;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv4Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
@@ -92,10 +92,10 @@
 import java.util.function.Predicate;
 
 /** Integration test cases for Thread Border Routing feature. */
-@RunWith(AndroidJUnit4.class)
+@LargeTest
 @RequiresThreadFeature
 @RequiresSimulationThreadDevice
-@LargeTest
+@RunWith(AndroidJUnit4.class)
 public class BorderRoutingTest {
     private static final String TAG = BorderRoutingTest.class.getSimpleName();
     private static final int NUM_FTD = 2;
@@ -128,7 +128,7 @@
 
     @BeforeClass
     public static void beforeClass() throws Exception {
-        enableThreadAndJoinNetwork(DEFAULT_DATASET);
+        enableBorderRouterAndJoinNetwork(DEFAULT_DATASET);
     }
 
     @AfterClass
diff --git a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
similarity index 97%
rename from thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
rename to thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
index 3c9aa07..ad98305 100644
--- a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package android.net.thread
+package android.net.thread.borderrouter
 
 import android.content.Context
 import android.net.DnsResolver.CLASS_IN
@@ -60,10 +60,10 @@
 import org.junit.runner.RunWith
 
 /** Integration test cases for Thread Internet Access features. */
+@LargeTest
 @RunWith(AndroidJUnit4::class)
 @RequiresThreadFeature
 @RequiresSimulationThreadDevice
-@LargeTest
 class InternetAccessTest {
     companion object {
         private val TAG = BorderRoutingTest::class.java.simpleName
@@ -167,6 +167,8 @@
         val ftd = ftds[0]
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
         dnsServer.start()
+        ftd.autoStartSrpClient()
+        ftd.waitForSrpServer()
 
         val ipv4Addresses =
             ftd.resolveHost("google.com", TYPE_A).map { extractIpv4AddressFromMappedAddress(it) }
@@ -181,6 +183,8 @@
         val ftd = ftds[0]
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
         dnsServer.start()
+        ftd.autoStartSrpClient()
+        ftd.waitForSrpServer()
 
         assertThat(ftd.resolveHost("google.com", TYPE_A)).isEmpty()
         assertThat(ftd.resolveHost("google.com", TYPE_AAAA)).isEmpty()
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 6c2a9bb..a9c0da2 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -21,6 +21,7 @@
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost;
 import static android.net.thread.utils.IntegrationTestUtils.discoverService;
+import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWait;
 import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr;
 import static android.net.thread.utils.IntegrationTestUtils.resolveService;
 import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil;
@@ -47,6 +48,7 @@
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.os.HandlerThread;
+import android.os.SystemProperties;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
@@ -113,8 +115,10 @@
 
     @Before
     public void setUp() throws Exception {
-        mOtCtl.factoryReset();
         mController.setEnabledAndWait(true);
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(true).build();
+        mController.setConfigurationAndWait(config);
         mController.joinAndWait(DEFAULT_DATASET);
         mNsdManager = mContext.getSystemService(NsdManager.class);
 
@@ -158,6 +162,143 @@
     }
 
     @Test
+    public void advertisingProxy_borderRouterDisabled_clientServiceRemovedWhenLeaveIsCalled()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.get(0);
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+
+        CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                discoverForServiceLost(mNsdManager, "_matter._tcp", serviceLostFuture);
+        mController.leaveAndWait();
+
+        // Verify the service becomes lost.
+        try {
+            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            mNsdManager.stopServiceDiscovery(listener);
+        }
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_matter._tcp"));
+    }
+
+    @Test
+    public void advertisingProxy_borderRouterDisabled_clientServiceRemovedWhen2ndSrpServerEnabled()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *  (Cuttlefish)                |
+         *                              +------- 2nd SRP Server
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.get(0);
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+
+        FullThreadDevice srpServer2 = mFtds.get(1);
+        joinNetworkAndWait(srpServer2, DEFAULT_DATASET);
+        CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                discoverForServiceLost(mNsdManager, "_matter._tcp", serviceLostFuture);
+        srpServer2.setSrpServerEnabled(true);
+
+        // Verify the service becomes lost.
+        try {
+            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            mNsdManager.stopServiceDiscovery(listener);
+        }
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_matter._tcp"));
+    }
+
+    @Test
+    public void advertisingProxy_borderRouterDisabled_clientMleIdAddressIsAdvertised()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *  (Cuttlefish)
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.getFirst();
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+        NsdServiceInfo resolvedService = resolveService(mNsdManager, discoveredService);
+        assertThat(resolvedService.getServiceName()).isEqualTo("thread-srp-client-service");
+        assertThat(resolvedService.getServiceType()).isEqualTo("_matter._tcp");
+        assertThat(resolvedService.getPort()).isEqualTo(12345);
+        assertThat(resolvedService.getAttributes())
+                .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                .containsExactly("key1", bytes(1), "key2", bytes(2));
+        assertThat(resolvedService.getHostname()).isEqualTo("thread-srp-client-host");
+        assertThat(resolvedService.getHostAddresses()).containsExactly(srpClient.getMlEid());
+    }
+
+    @Test
     public void advertisingProxy_multipleSrpClientsRegisterServices_servicesResolvableByMdns()
             throws Exception {
         /*
@@ -314,14 +455,22 @@
     }
 
     @Test
-    public void meshcopOverlay_vendorAndModelNameAreSetToOverlayValue() throws Exception {
+    public void meshcopOverlay_vendorAndModelNameAreSetToSystemProperties() throws Exception {
         NsdServiceInfo discoveredService = discoverService(mNsdManager, "_meshcop._udp");
         assertThat(discoveredService).isNotNull();
         NsdServiceInfo meshcopService = resolveService(mNsdManager, discoveredService);
+        String expectedVendorName = SystemProperties.get("ro.product.manufacturer");
+        if (expectedVendorName.length() > 24) {
+            expectedVendorName = expectedVendorName.substring(0, 24);
+        }
+        String expectedModelName = SystemProperties.get("ro.product.model");
+        if (expectedModelName.length() > 24) {
+            expectedModelName = expectedModelName.substring(0, 24);
+        }
 
         Map<String, byte[]> txtMap = meshcopService.getAttributes();
-        assertThat(txtMap.get("vn")).isEqualTo("Android".getBytes(UTF_8));
-        assertThat(txtMap.get("mn")).isEqualTo("Thread Border Router".getBytes(UTF_8));
+        assertThat(txtMap.get("vn")).isEqualTo(expectedVendorName.getBytes(UTF_8));
+        assertThat(txtMap.get("mn")).isEqualTo(expectedModelName.getBytes(UTF_8));
     }
 
     @Test
@@ -455,7 +604,8 @@
                 DeviceConfigUtils.getDeviceConfigPropertyBoolean(
                         "thread_network", "TrelFeature__enabled", false));
 
-        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_trel._udp");
+        NsdServiceInfo discoveredService =
+                discoverService(mNsdManager, "_trel._udp", mOtCtl.getExtendedAddr());
         assertThat(discoveredService).isNotNull();
         // Resolve service with the current TREL port, otherwise it may return stale service from
         // a previous infra link setup.
@@ -478,7 +628,9 @@
                 DeviceConfigUtils.getDeviceConfigPropertyBoolean(
                         "thread_network", "TrelFeature__enabled", false));
 
-        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_trel._udp"));
+        assertThrows(
+                TimeoutException.class,
+                () -> discoverService(mNsdManager, "_trel._udp", mOtCtl.getExtendedAddr()));
     }
 
     private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index d41550b..f586f6e 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -17,43 +17,43 @@
 package android.net.thread;
 
 import static android.Manifest.permission.ACCESS_NETWORK_STATE;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
 import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_CONFIG;
-import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6Addresses;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
-import static android.net.thread.utils.IntegrationTestUtils.getPrefixesFromNetData;
-import static android.net.thread.utils.IntegrationTestUtils.getThreadNetwork;
-import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
+import static android.net.thread.utils.OtDaemonController.DIAG_VENDOR_MODEL_TLV_TYPE;
+import static android.net.thread.utils.OtDaemonController.DIAG_VENDOR_NAME_TLV_TYPE;
 import static android.net.thread.utils.ThreadNetworkControllerWrapper.JOIN_TIMEOUT;
+import static android.os.SystemClock.elapsedRealtime;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
-import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static org.junit.Assume.assumeTrue;
+
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
-import android.net.LinkAddress;
-import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.net.thread.utils.ThreadStateListener;
@@ -63,6 +63,8 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.google.common.collect.FluentIterable;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -92,11 +94,11 @@
     // The maximum time for OT addresses to be propagated to the TUN interface "thread-wpan"
     private static final Duration TUN_ADDR_UPDATE_TIMEOUT = Duration.ofSeconds(1);
 
-    // The maximum time for changes to be propagated to netdata.
-    private static final Duration NET_DATA_UPDATE_TIMEOUT = Duration.ofSeconds(1);
-
     private static final Duration NETWORK_CALLBACK_TIMEOUT = Duration.ofSeconds(10);
 
+    // The duration between attached and addresses show up on thread-wpan
+    private static final Duration LINK_ADDR_TIMEOUT = Duration.ofSeconds(2);
+
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -107,11 +109,6 @@
                                     + "B9D351B40C0402A0FFF8");
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
-    private static final ThreadConfiguration DEFAULT_CONFIG =
-            new ThreadConfiguration.Builder().build();
-
-    private static final Inet6Address GROUP_ADDR_ALL_ROUTERS =
-            (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
 
     private static final String TEST_NO_SLAAC_PREFIX = "9101:dead:beef:cafe::/64";
     private static final InetAddress TEST_NO_SLAAC_PREFIX_ADDRESS =
@@ -128,14 +125,19 @@
 
     @Before
     public void setUp() throws Exception {
+        getInstrumentation()
+                .getUiAutomation()
+                .grantRuntimePermission(
+                        "com.android.thread.tests.integration",
+                        "android.permission.NEARBY_WIFI_DEVICES");
+
         mExecutor = Executors.newSingleThreadExecutor();
-        mOtCtl = new OtDaemonController();
-        mController.leaveAndWait();
-
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        mOtCtl.factoryReset();
-
         mFtd = new FullThreadDevice(10 /* nodeId */);
+        mOtCtl = new OtDaemonController();
+        mController.setEnabledAndWait(true);
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        mController.leaveAndWait();
     }
 
     @After
@@ -144,7 +146,6 @@
 
         mController.setTestNetworkAsUpstreamAndWait(null);
         mController.leaveAndWait();
-        mController.setConfigurationAndWait(DEFAULT_CONFIG);
 
         mFtd.destroy();
         mExecutor.shutdownNow();
@@ -162,19 +163,6 @@
     }
 
     @Test
-    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
-            throws Exception {
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        runShellCommand("stop ot-daemon");
-
-        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
-        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
-        assertThat(mOtCtl.isInterfaceUp()).isTrue();
-        assertThat(runShellCommand("ifconfig thread-wpan")).contains("UP POINTOPOINT RUNNING");
-    }
-
-    @Test
     public void otDaemonFactoryReset_deviceRoleIsStopped() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
 
@@ -216,6 +204,8 @@
 
     @Test
     public void otDaemonRestart_latestCountryCodeIsSetToOtDaemon() throws Exception {
+        assumeTrue(mOtCtl.isCountryCodeSupported());
+
         runThreadCommand("force-country-code enabled CN");
 
         runShellCommand("stop ot-daemon");
@@ -227,6 +217,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void udp_appStartEchoServer_endDeviceUdpEchoSuccess() throws Exception {
         // Topology:
         //   Test App ------ thread-wpan ------ End Device
@@ -245,30 +236,25 @@
     }
 
     @Test
-    public void joinNetworkWithBrDisabled_meshLocalAddressesArePreferred() throws Exception {
-        // When BR feature is disabled, there is no OMR address, so the mesh-local addresses are
-        // expected to be preferred.
-        mOtCtl.executeCommand("br disable");
+    public void joinNetwork_onlyMlEidIsPreferred() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
+        waitFor(
+                () -> getIpv6Addresses("thread-wpan").contains(mOtCtl.getMlEid()),
+                LINK_ADDR_TIMEOUT);
 
         IpPrefix meshLocalPrefix = DEFAULT_DATASET.getMeshLocalPrefix();
-        List<LinkAddress> linkAddresses = getIpv6LinkAddresses("thread-wpan");
-        for (LinkAddress address : linkAddresses) {
-            if (meshLocalPrefix.contains(address.getAddress())) {
-                assertThat(address.getDeprecationTime())
-                        .isGreaterThan(SystemClock.elapsedRealtime());
-                assertThat(address.isPreferred()).isTrue();
-            }
-        }
-
-        mOtCtl.executeCommand("br enable");
-    }
-
-    @Test
-    public void joinNetwork_tunInterfaceJoinsAllRouterMulticastGroup() throws Exception {
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        assertTunInterfaceMemberOfGroup(GROUP_ADDR_ALL_ROUTERS);
+        var linkAddrs = FluentIterable.from(getIpv6LinkAddresses("thread-wpan"));
+        var meshLocalAddrs = linkAddrs.filter(addr -> meshLocalPrefix.contains(addr.getAddress()));
+        var mlEidAddrs = meshLocalAddrs.filter(addr -> addr.getAddress().equals(mOtCtl.getMlEid()));
+        var nonMlEidAddrs = meshLocalAddrs.filter(addr -> !mlEidAddrs.contains(addr));
+        assertThat(mlEidAddrs).hasSize(1);
+        assertThat(mlEidAddrs.allMatch(addr -> addr.isPreferred())).isTrue();
+        assertThat(mlEidAddrs.allMatch(addr -> addr.getDeprecationTime() > elapsedRealtime()))
+                .isTrue();
+        assertThat(nonMlEidAddrs).isNotEmpty();
+        assertThat(nonMlEidAddrs.allMatch(addr -> !addr.isPreferred())).isTrue();
+        assertThat(nonMlEidAddrs.allMatch(addr -> addr.getDeprecationTime() <= elapsedRealtime()))
+                .isTrue();
     }
 
     @Test
@@ -286,6 +272,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
@@ -301,84 +288,29 @@
     }
 
     @Test
-    public void addPrefixToNetData_routeIsAddedToTunInterface() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        // Ftd child doesn't have the ability to add a prefix, so let BR itself add a prefix.
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-        waitFor(
-                () -> {
-                    String netData = mOtCtl.executeCommand("netdata show");
-                    return getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
-                },
-                NET_DATA_UPDATE_TIMEOUT);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isTrue();
-    }
-
-    @Test
-    public void removePrefixFromNetData_routeIsRemovedFromTunInterface() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-
-        mOtCtl.executeCommand("prefix remove " + TEST_NO_SLAAC_PREFIX);
-        mOtCtl.executeCommand("netdata register");
-        waitFor(
-                () -> {
-                    String netData = mOtCtl.executeCommand("netdata show");
-                    return !getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
-                },
-                NET_DATA_UPDATE_TIMEOUT);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isFalse();
-    }
-
-    @Test
-    public void toggleThreadNetwork_routeFromPreviousNetDataIsRemoved() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-
-        mController.leaveAndWait();
-        mOtCtl.factoryReset();
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isFalse();
-    }
-
-    @Test
-    public void setConfiguration_disableBorderRouter_noBrfunctionsEnabled() throws Exception {
-        NetworkRequest request =
-                new NetworkRequest.Builder()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                        .build();
+    @RequiresSimulationThreadDevice
+    public void setConfiguration_disableBorderRouter_borderRoutingDisabled() throws Exception {
         startFtdLeader(mFtd, DEFAULT_DATASET);
 
         mController.setConfigurationAndWait(
                 new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
         mController.joinAndWait(DEFAULT_DATASET);
-        NetworkCapabilities caps = registerNetworkCallbackAndWait(request);
 
-        assertThat(caps.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)).isFalse();
         assertThat(mOtCtl.getBorderRoutingState()).ignoringCase().isEqualTo("disabled");
-        assertThat(mOtCtl.getSrpServerState()).ignoringCase().isNotEqualTo("disabled");
         // TODO: b/376217403 - enables / disables Border Agent at runtime
     }
 
+    @Test
+    public void networkDiagnostic_vendorAndModelNameAreSet() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        var tlvTypes = List.of(DIAG_VENDOR_NAME_TLV_TYPE, DIAG_VENDOR_MODEL_TLV_TYPE);
+        var result = mOtCtl.netDiagGet(mOtCtl.getMlEid(), tlvTypes);
+
+        assertThat(result.get("Vendor Name")).isNotEmpty();
+        assertThat(result.get("Vendor Model")).isNotEmpty();
+    }
+
     private NetworkCapabilities registerNetworkCallbackAndWait(NetworkRequest request)
             throws Exception {
         CompletableFuture<Network> networkFuture = new CompletableFuture<>();
@@ -441,8 +373,4 @@
             throw new IllegalStateException(e);
         }
     }
-
-    private void assertTunInterfaceMemberOfGroup(Inet6Address address) throws Exception {
-        waitFor(() -> isInMulticastGroup(TUN_IF_NAME, address), TUN_ADDR_UPDATE_TIMEOUT);
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 2f0ab34..804a332 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -28,11 +28,13 @@
 
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
 
 import android.content.Context;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
 
@@ -66,11 +68,7 @@
 
     @Before
     public void setUp() throws Exception {
-        // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
-        // results in timeout error.
-        // A future fix will provide proper support for factoryreset, allowing us to replace the
-        // legacy "ot-ctl".
-        mOtCtl.factoryReset();
+        mController.leaveAndWait();
 
         mFtd = new FullThreadDevice(10 /* nodeId */);
         ensureThreadEnabled();
@@ -143,6 +141,8 @@
 
     @Test
     public void forceCountryCode_setCN_getCountryCodeReturnsCN() {
+        assumeTrue(mOtCtl.isCountryCodeSupported());
+
         runThreadCommand("force-country-code enabled CN");
 
         final String result = runThreadCommand("get-country-code");
@@ -168,6 +168,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
diff --git a/thread/tests/multidevices/Android.bp b/thread/tests/multidevices/Android.bp
index 050caa8..1d2ae62 100644
--- a/thread/tests/multidevices/Android.bp
+++ b/thread/tests/multidevices/Android.bp
@@ -35,9 +35,4 @@
         "mts-tethering",
         "general-tests",
     ],
-    version: {
-        py3: {
-            embedded_launcher: true,
-        },
-    },
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index dcbb3f5..a73390c 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -34,7 +34,9 @@
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
+import static com.android.server.thread.ThreadNetworkControllerService.getMeshcopTxtAttributes;
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_THREAD_ENABLED;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -42,13 +44,12 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNotNull;
-import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.clearInvocations;
@@ -93,17 +94,16 @@
 
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
 import com.android.connectivity.resources.R;
 import com.android.dx.mockito.inline.extended.ExtendedMockito;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.connectivity.MockableSystemProperties;
 import com.android.server.thread.openthread.DnsTxtAttribute;
 import com.android.server.thread.openthread.IOtStatusReceiver;
 import com.android.server.thread.openthread.MeshcopTxtAttributes;
-import com.android.server.thread.openthread.OtDaemonConfiguration;
 import com.android.server.thread.openthread.testing.FakeOtDaemon;
 
 import org.junit.After;
@@ -112,6 +112,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.InOrder;
@@ -124,6 +125,8 @@
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
@@ -132,7 +135,7 @@
 
 /** Unit tests for {@link ThreadNetworkControllerService}. */
 @SmallTest
-@RunWith(AndroidJUnit4.class)
+@RunWith(Parameterized.class)
 // This test doesn't really need to run on the UI thread, but @Before and @Test annotated methods
 // need to run in the same thread because there are code in {@code ThreadNetworkControllerService}
 // checking that all its methods are running in the thread of the handler it's using. This is due
@@ -177,6 +180,7 @@
     private static final String TEST_MODEL_NAME = "test model";
     private static final LinkAddress TEST_NAT64_CIDR = new LinkAddress("192.168.255.0/24");
 
+    @Mock private MockableSystemProperties mMockSystemProperties;
     @Mock private ConnectivityManager mMockConnectivityManager;
     @Mock private RoutingCoordinatorManager mMockRoutingCoordinatorManager;
     @Mock private NetworkAgent mMockNetworkAgent;
@@ -200,6 +204,17 @@
     @Rule(order = 1)
     public final TemporaryFolder tempFolder = new TemporaryFolder();
 
+    private final boolean mIsBorderRouterEnabled;
+
+    @Parameterized.Parameters
+    public static Collection configArguments() {
+        return Arrays.asList(new Object[][] {{false}, {true}});
+    }
+
+    public ThreadNetworkControllerServiceTest(boolean isBorderRouterEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
+    }
+
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
@@ -231,6 +246,13 @@
 
         when(mConnectivityResources.get()).thenReturn(mResources);
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+        when(mResources.getBoolean(eq(R.bool.config_thread_border_router_default_enabled)))
+                .thenReturn(mIsBorderRouterEnabled);
+        when(mResources.getBoolean(
+                        eq(R.bool.config_thread_srp_server_wait_for_border_routing_enabled)))
+                .thenReturn(true);
+        when(mResources.getBoolean(eq(R.bool.config_thread_border_router_auto_join_enabled)))
+                .thenReturn(true);
         when(mResources.getString(eq(R.string.config_thread_vendor_name)))
                 .thenReturn(TEST_VENDOR_NAME);
         when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
@@ -239,6 +261,7 @@
                 .thenReturn(TEST_MODEL_NAME);
         when(mResources.getStringArray(eq(R.array.config_thread_mdns_vendor_specific_txts)))
                 .thenReturn(new String[] {});
+        when(mResources.getBoolean(eq(R.bool.config_thread_country_code_enabled))).thenReturn(true);
 
         final AtomicFile storageFile = new AtomicFile(tempFolder.newFile("thread_settings.xml"));
         mPersistentSettings = new ThreadPersistentSettings(storageFile, mConnectivityResources);
@@ -248,6 +271,7 @@
                 new ThreadNetworkControllerService(
                         mContext,
                         handler,
+                        mMockSystemProperties,
                         networkProvider,
                         () -> mFakeOtDaemon,
                         mMockConnectivityManager,
@@ -285,6 +309,11 @@
 
     @Test
     public void initialize_resourceOverlayValuesAreSetToOtDaemon() throws Exception {
+        when(mResources.getBoolean(
+                        eq(R.bool.config_thread_srp_server_wait_for_border_routing_enabled)))
+                .thenReturn(false);
+        when(mResources.getBoolean(eq(R.bool.config_thread_border_router_auto_join_enabled)))
+                .thenReturn(false);
         when(mResources.getString(eq(R.string.config_thread_vendor_name)))
                 .thenReturn(TEST_VENDOR_NAME);
         when(mResources.getString(eq(R.string.config_thread_vendor_oui)))
@@ -297,6 +326,8 @@
         mService.initialize();
         mTestLooper.dispatchAll();
 
+        assertThat(mFakeOtDaemon.getConfiguration().srpServerWaitForBorderRoutingEnabled).isFalse();
+        assertThat(mFakeOtDaemon.getConfiguration().borderRouterAutoJoinEnabled).isFalse();
         MeshcopTxtAttributes meshcopTxts = mFakeOtDaemon.getOverriddenMeshcopTxtAttributes();
         assertThat(meshcopTxts.vendorName).isEqualTo(TEST_VENDOR_NAME);
         assertThat(meshcopTxts.vendorOui).isEqualTo(TEST_VENDOR_OUI_BYTES);
@@ -306,6 +337,23 @@
     }
 
     @Test
+    public void initialize_vendorAndModelNameSetToProperty_propertiesAreSetToOtDaemon()
+            throws Exception {
+        when(mMockSystemProperties.get(eq("ro.product.manufacturer"))).thenReturn("Banana");
+        when(mResources.getString(eq(R.string.config_thread_vendor_name)))
+                .thenReturn("ro.product.manufacturer");
+        when(mMockSystemProperties.get(eq("ro.product.model"))).thenReturn("Orange");
+        when(mResources.getString(eq(R.string.config_thread_model_name)))
+                .thenReturn("ro.product.model");
+
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        assertThat(mFakeOtDaemon.getConfiguration().vendorName).isEqualTo("Banana");
+        assertThat(mFakeOtDaemon.getConfiguration().modelName).isEqualTo("Orange");
+    }
+
+    @Test
     public void initialize_nat64Disabled_doesNotRequestNat64CidrAndConfiguresOtDaemon()
             throws Exception {
         ThreadConfiguration config =
@@ -315,8 +363,7 @@
         mTestLooper.dispatchAll();
 
         verify(mMockRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
-        verify(mFakeOtDaemon, times(1)).setNat64Cidr(isNull(), any());
-        verify(mFakeOtDaemon, never()).setNat64Cidr(isNotNull(), any());
+        assertThat(mFakeOtDaemon.getNat64Cidr()).isNull();
     }
 
     @Test
@@ -329,11 +376,8 @@
         mTestLooper.dispatchAll();
 
         verify(mMockRoutingCoordinatorManager, times(1)).requestDownstreamAddress(any());
-        verify(mFakeOtDaemon, times(1))
-                .setConfiguration(
-                        new OtDaemonConfiguration.Builder().setNat64Enabled(true).build(),
-                        null /* receiver */);
-        verify(mFakeOtDaemon, times(1)).setNat64Cidr(eq(TEST_NAT64_CIDR.toString()), any());
+        assertThat(mFakeOtDaemon.getConfiguration().nat64Enabled).isTrue();
+        assertThat(mFakeOtDaemon.getNat64Cidr()).isEqualTo(TEST_NAT64_CIDR.toString());
     }
 
     @Test
@@ -370,7 +414,7 @@
         when(mResources.getString(eq(R.string.config_thread_vendor_name))).thenReturn("");
 
         MeshcopTxtAttributes meshcopTxts =
-                ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources);
+                getMeshcopTxtAttributes(mResources, mMockSystemProperties);
 
         assertThat(meshcopTxts.vendorName).isEqualTo("");
     }
@@ -382,7 +426,31 @@
 
         assertThrows(
                 IllegalStateException.class,
-                () -> ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources));
+                () -> getMeshcopTxtAttributes(mResources, mMockSystemProperties));
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_VendorNameSetToManufacturer_manufacturerPropertyIsUsed() {
+        when(mMockSystemProperties.get(eq("ro.product.manufacturer"))).thenReturn("Banana");
+        when(mResources.getString(eq(R.string.config_thread_vendor_name)))
+                .thenReturn("ro.product.manufacturer");
+
+        MeshcopTxtAttributes meshcopTxts =
+                getMeshcopTxtAttributes(mResources, mMockSystemProperties);
+
+        assertThat(meshcopTxts.vendorName).isEqualTo("Banana");
+    }
+
+    @Test
+    public void getMeshcopTxtAttributes_ModelNameSetToModelProperty_modelPropertyIsUsed() {
+        when(mMockSystemProperties.get(eq("ro.product.model"))).thenReturn("Orange");
+        when(mResources.getString(eq(R.string.config_thread_model_name)))
+                .thenReturn("ro.product.model");
+
+        MeshcopTxtAttributes meshcopTxts =
+                getMeshcopTxtAttributes(mResources, mMockSystemProperties);
+
+        assertThat(meshcopTxts.modelName).isEqualTo("Orange");
     }
 
     @Test
@@ -392,14 +460,14 @@
 
         assertThrows(
                 IllegalStateException.class,
-                () -> ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources));
+                () -> getMeshcopTxtAttributes(mResources, mMockSystemProperties));
     }
 
     @Test
     public void getMeshcopTxtAttributes_emptyModelName_accepted() {
         when(mResources.getString(eq(R.string.config_thread_model_name))).thenReturn("");
 
-        var meshcopTxts = ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources);
+        var meshcopTxts = getMeshcopTxtAttributes(mResources, mMockSystemProperties);
         assertThat(meshcopTxts.modelName).isEqualTo("");
     }
 
@@ -431,7 +499,7 @@
 
     private byte[] getMeshcopTxtAttributesWithVendorOui(String vendorOui) {
         when(mResources.getString(eq(R.string.config_thread_vendor_oui))).thenReturn(vendorOui);
-        return ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources).vendorOui;
+        return getMeshcopTxtAttributes(mResources, mMockSystemProperties).vendorOui;
     }
 
     @Test
@@ -552,7 +620,7 @@
         mTestLooper.dispatchAll();
 
         assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_DISABLED);
-        assertThat(mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED)).isTrue();
+        assertThat(mPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
@@ -856,16 +924,12 @@
 
         verify(mockReceiver, times(1)).onSuccess();
         verify(mMockRoutingCoordinatorManager, times(1)).requestDownstreamAddress(any());
-        verify(mFakeOtDaemon, times(1))
-                .setConfiguration(
-                        eq(new OtDaemonConfiguration.Builder().setNat64Enabled(true).build()),
-                        any(IOtStatusReceiver.class));
-        verify(mFakeOtDaemon, times(1))
-                .setNat64Cidr(eq(TEST_NAT64_CIDR.toString()), any(IOtStatusReceiver.class));
+        assertThat(mFakeOtDaemon.getConfiguration().nat64Enabled).isTrue();
+        assertThat(mFakeOtDaemon.getNat64Cidr()).isEqualTo(TEST_NAT64_CIDR.toString());
     }
 
     @Test
-    public void setConfiguration_enablesNat64_otDaemonRemoteFailure_serviceDoesNotCrash()
+    public void setConfiguration_enablesNat64AndOtDaemonRemoteFailure_serviceDoesNotCrash()
             throws Exception {
         mService.initialize();
         mTestLooper.dispatchAll();
@@ -899,16 +963,14 @@
         verify(mockReceiver, times(1)).onSuccess();
         verify(mMockRoutingCoordinatorManager, times(1)).releaseDownstream(any());
         verify(mMockRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
-        verify(mFakeOtDaemon, times(1))
-                .setConfiguration(
-                        eq(new OtDaemonConfiguration.Builder().setNat64Enabled(false).build()),
-                        any(IOtStatusReceiver.class));
-        verify(mFakeOtDaemon, times(1)).setNat64Cidr(isNull(), any(IOtStatusReceiver.class));
-        verify(mFakeOtDaemon, never()).setNat64Cidr(isNotNull(), any(IOtStatusReceiver.class));
+        assertThat(mFakeOtDaemon.getConfiguration().nat64Enabled).isFalse();
+        assertThat(mFakeOtDaemon.getNat64Cidr()).isNull();
     }
 
     @Test
-    public void initialize_upstreamNetworkRequestHasCertainTransportTypesAndCapabilities() {
+    public void initialize_borderRouterEnabled_upstreamNetworkRequestHasExpectedTransportAndCaps() {
+        assumeTrue(mIsBorderRouterEnabled);
+
         mService.initialize();
         mTestLooper.dispatchAll();
 
@@ -987,7 +1049,8 @@
     }
 
     @Test
-    public void activateEphemeralKeyMode_succeed() throws Exception {
+    public void activateEphemeralKeyMode_borderRouterEnabled_succeed() throws Exception {
+        assumeTrue(mIsBorderRouterEnabled);
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
 
@@ -998,7 +1061,8 @@
     }
 
     @Test
-    public void deactivateEphemeralKeyMode_succeed() throws Exception {
+    public void deactivateEphemeralKeyMode_borderRouterEnabled_succeed() throws Exception {
+        assumeTrue(mIsBorderRouterEnabled);
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
 
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
index ca9741d..1a6b3cc 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
@@ -17,9 +17,10 @@
 package com.android.server.thread;
 
 import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -109,6 +110,7 @@
 
     private ThreadNetworkCountryCode mThreadNetworkCountryCode;
     private boolean mErrorSetCountryCode;
+    private boolean mErrorUnsupportedFeatureSetCountryCode;
 
     @Captor private ArgumentCaptor<LocationListener> mLocationListenerCaptor;
     @Captor private ArgumentCaptor<Geocoder.GeocodeListener> mGeocodeListenerCaptor;
@@ -143,6 +145,10 @@
 
                     if (mErrorSetCountryCode) {
                         cb.onError(ERROR_INTERNAL_ERROR, new String("Invalid country code"));
+                    } else if (mErrorUnsupportedFeatureSetCountryCode) {
+                        cb.onError(
+                                ERROR_UNSUPPORTED_FEATURE,
+                                new String("Setting country code is not supported"));
                     } else {
                         cb.onSuccess();
                     }
@@ -200,6 +206,21 @@
     }
 
     @Test
+    public void initialize_countryCodeDisabled_defaultCountryCodeIsUsed() {
+        when(mResources.getBoolean(R.bool.config_thread_country_code_enabled)).thenReturn(false);
+
+        mThreadNetworkCountryCode.initialize();
+
+        verifyNoMoreInteractions(mWifiManager);
+        verifyNoMoreInteractions(mTelephonyManager);
+        verifyNoMoreInteractions(mSubscriptionManager);
+        verifyNoMoreInteractions(mGeocoder);
+        verifyNoMoreInteractions(mLocationManager);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
     public void initialize_locationUseIsDisabled_locationFunctionIsNotCalled() {
         when(mResources.getBoolean(R.bool.config_thread_location_use_for_country_code_enabled))
                 .thenReturn(false);
@@ -453,8 +474,41 @@
     }
 
     @Test
+    public void setCountryCodeNotSupported_returnUnsupportedFeatureError_countryCodeNotSetAgain() {
+        mThreadNetworkCountryCode.initialize();
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+
+        mErrorUnsupportedFeatureSetCountryCode = true;
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(TEST_COUNTRY_CODE_CN), mOperationReceiverCaptor.capture());
+
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_US);
+        verifyNoMoreInteractions(mThreadNetworkControllerService);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
+    public void setCountryCodeNotSupported_returnUnsupportedFeatureError_unregisterAllCallbacks() {
+        mThreadNetworkCountryCode.initialize();
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+
+        mErrorUnsupportedFeatureSetCountryCode = true;
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(TEST_COUNTRY_CODE_CN), mOperationReceiverCaptor.capture());
+
+        verify(mLocationManager).removeUpdates(mLocationListenerCaptor.capture());
+        verify(mWifiManager)
+                .unregisterActiveCountryCodeChangedCallback(
+                        mWifiCountryCodeReceiverCaptor.capture());
+        verify(mContext).unregisterReceiver(mTelephonyCountryCodeReceiverCaptor.capture());
+    }
+
+    @Test
     public void settingsCountryCode_settingsCountryCodeIsActive_settingsCountryCodeIsUsed() {
-        when(mPersistentSettings.get(THREAD_COUNTRY_CODE)).thenReturn(TEST_COUNTRY_CODE_CN);
+        when(mPersistentSettings.get(KEY_COUNTRY_CODE)).thenReturn(TEST_COUNTRY_CODE_CN);
         mThreadNetworkCountryCode.initialize();
 
         assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
@@ -468,6 +522,8 @@
         mThreadNetworkCountryCode.dump(new FileDescriptor(), printWriter, null);
         String outputString = stringWriter.toString();
 
+        assertThat(outputString).contains("isCountryCodeEnabled");
+        assertThat(outputString).contains("mIsCpSettingCountryCodeSupported");
         assertThat(outputString).contains("mOverrideCountryCodeInfo");
         assertThat(outputString).contains("mTelephonyCountryCodeSlotInfoMap");
         assertThat(outputString).contains("mTelephonyCountryCodeInfo");
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
index ba489d9..15f3d0b 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
@@ -16,8 +16,8 @@
 
 package com.android.server.thread;
 
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_THREAD_ENABLED;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -35,6 +35,7 @@
 
 import com.android.connectivity.resources.R;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.thread.ThreadPersistentSettings.Key;
 
 import org.junit.After;
 import org.junit.Before;
@@ -83,68 +84,81 @@
 
     @Test
     public void initialize_readsFromFile() throws Exception {
-        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
+        byte[] data = createXmlForParsing(KEY_THREAD_ENABLED, false);
         setupAtomicFileForRead(data);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
     }
 
     @Test
     public void initialize_ThreadDisabledInResources_returnsThreadDisabled() throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
-        setupAtomicFileForRead(new byte[0]);
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
+    }
+
+    @Test
+    public void initialize_ThreadEnabledInResources_returnsThreadEnabled() throws Exception {
+        when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
+
+        mThreadPersistentSettings.initialize();
+
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void initialize_ThreadDisabledInResourcesButEnabledInXml_returnsThreadEnabled()
             throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
-        byte[] data = createXmlForParsing(THREAD_ENABLED.key, true);
-        setupAtomicFileForRead(data);
+        setupAtomicFileForRead(createXmlForParsing(KEY_THREAD_ENABLED, true));
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
-        mThreadPersistentSettings.put(THREAD_ENABLED.key, true);
+        mThreadPersistentSettings.put(KEY_THREAD_ENABLED, true);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
-        mThreadPersistentSettings.put(THREAD_ENABLED.key, false);
+        mThreadPersistentSettings.put(KEY_THREAD_ENABLED, false);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
     }
 
     @Test
     public void put_ThreadCountryCodeString_returnsString() throws Exception {
-        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, TEST_COUNTRY_CODE);
+        mThreadPersistentSettings.put(KEY_COUNTRY_CODE, TEST_COUNTRY_CODE);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
     }
 
     @Test
     public void put_ThreadCountryCodeNull_returnsNull() throws Exception {
-        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, null);
+        mThreadPersistentSettings.put(KEY_COUNTRY_CODE, null);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isNull();
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isNull();
     }
 
     @Test
@@ -202,10 +216,10 @@
         return new AtomicFile(mTemporaryFolder.newFile());
     }
 
-    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
+    private byte[] createXmlForParsing(Key<Boolean> key, Boolean value) throws Exception {
         PersistableBundle bundle = new PersistableBundle();
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
-        bundle.putBoolean(key, value);
+        bundle.putBoolean(key.key, value);
         bundle.writeToStream(outputStream);
         return outputStream.toByteArray();
     }
diff --git a/thread/tests/utils/Android.bp b/thread/tests/utils/Android.bp
index 726ec9d..7990752 100644
--- a/thread/tests/utils/Android.bp
+++ b/thread/tests/utils/Android.bp
@@ -31,6 +31,7 @@
     ],
     srcs: [
         "src/**/*.java",
+        "src/**/*.kt",
     ],
     defaults: [
         "framework-connectivity-test-defaults",
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
similarity index 98%
rename from thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
rename to thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
index 209eed6..ed63fd0 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
@@ -239,6 +239,12 @@
         executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message);
     }
 
+    /** Sets `true` to enable SRP server on this device. */
+    public void setSrpServerEnabled(boolean enabled) {
+        String cmd = enabled ? "enable" : "disable";
+        executeCommand("srp server " + cmd);
+    }
+
     /** Enables the SRP client and run in autostart mode. */
     public void autoStartSrpClient() {
         executeCommand("srp client autostart enable");
@@ -521,7 +527,7 @@
     }
 
     /** Waits for an SRP server to be present in Network Data */
-    private void waitForSrpServer() throws TimeoutException {
+    public void waitForSrpServer() throws TimeoutException {
         // CLI output:
         // > srp client server
         // [fd64:db12:25f4:7e0b:1bfc:6344:25ac:2dd7]:53538
diff --git a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java b/thread/tests/utils/src/android/net/thread/utils/InfraNetworkDevice.java
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
rename to thread/tests/utils/src/android/net/thread/utils/InfraNetworkDevice.java
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
similarity index 91%
rename from thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
rename to thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
index 316f570..77d0955 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -39,6 +39,7 @@
 import android.os.SystemClock
 import android.system.OsConstants
 import android.system.OsConstants.IPPROTO_ICMP
+import android.util.Log
 import androidx.test.core.app.ApplicationProvider
 import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
 import com.android.net.module.util.IpUtils
@@ -84,6 +85,8 @@
 
 /** Utilities for Thread integration tests. */
 object IntegrationTestUtils {
+    private val TAG = IntegrationTestUtils::class.simpleName
+
     // 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
@@ -452,9 +455,8 @@
     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)) {
+            if (line.contains(address.hostAddress!!)) {
                 return true
             }
         }
@@ -476,14 +478,37 @@
         return addresses
     }
 
-    /** Return the first discovered service of `serviceType`.  */
+    /** Returns the list of [InetAddress] of the given network. */
+    @JvmStatic
+    fun getIpv6Addresses(interfaceName: String): List<InetAddress> {
+        return getIpv6LinkAddresses(interfaceName).map { it.address }
+    }
+
+    /** Return the first discovered service of `serviceType`. */
     @JvmStatic
     @Throws(Exception::class)
     fun discoverService(nsdManager: NsdManager, serviceType: String): NsdServiceInfo {
+        return discoverService(nsdManager, serviceType, null)
+    }
+
+    /**
+     * Returns the service that matches `serviceType` and `serviceName`.
+     *
+     * If `serviceName` is null, returns the first discovered service. `serviceName` is not case
+     * sensitive.
+     */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun discoverService(nsdManager: NsdManager, serviceType: String, serviceName: String?):
+            NsdServiceInfo {
         val serviceInfoFuture = CompletableFuture<NsdServiceInfo>()
         val listener: NsdManager.DiscoveryListener = object : DefaultDiscoveryListener() {
             override fun onServiceFound(serviceInfo: NsdServiceInfo) {
-                serviceInfoFuture.complete(serviceInfo)
+                Log.d(TAG, "onServiceFound: $serviceInfo")
+                if (serviceName == null ||
+                        serviceInfo.getServiceName().equals(serviceName, true /* ignore case */)) {
+                    serviceInfoFuture.complete(serviceInfo)
+                }
             }
         }
         nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
@@ -530,6 +555,7 @@
         val resolvedServiceInfoFuture = CompletableFuture<NsdServiceInfo>()
         val callback: NsdManager.ServiceInfoCallback = object : DefaultServiceInfoCallback() {
             override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
+                Log.d(TAG, "onServiceUpdated: $serviceInfo")
                 if (predicate.test(serviceInfo)) {
                     resolvedServiceInfoFuture.complete(serviceInfo)
                 }
@@ -578,6 +604,17 @@
     }
 
     /**
+     * Let the FTD join the specified Thread network and wait for it becomes a Child or Router.
+     */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun joinNetworkAndWait(ftd: FullThreadDevice, dataset: ActiveOperationalDataset) {
+        ftd.factoryReset()
+        ftd.joinNetwork(dataset)
+        ftd.waitForStateAnyOf(listOf("router", "child"), JOIN_TIMEOUT)
+    }
+
+    /**
      * Let the FTD join the specified Thread network and wait for border routing to be available.
      *
      * @return the OMR address
@@ -598,15 +635,31 @@
     /** Enables Thread and joins the specified Thread network. */
     @JvmStatic
     fun enableThreadAndJoinNetwork(dataset: ActiveOperationalDataset) {
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        OtDaemonController().factoryReset();
-
         val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
         val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        controller.leaveAndWait();
+
         controller.setEnabledAndWait(true);
         controller.joinAndWait(dataset);
     }
 
+    /** Enables Border Router and joins the specified Thread network. */
+    @JvmStatic
+    fun enableBorderRouterAndJoinNetwork(dataset: ActiveOperationalDataset) {
+        val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
+        val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        controller.leaveAndWait();
+
+        controller.setEnabledAndWait(true);
+        val config = ThreadConfiguration.Builder().setBorderRouterEnabled(true).build();
+        controller.setConfigurationAndWait(config);
+        controller.joinAndWait(dataset);
+    }
+
     /** Leaves the Thread network and disables Thread. */
     @JvmStatic
     fun leaveNetworkAndDisableThread() {
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
similarity index 71%
rename from thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
rename to thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
index 9fbfa45..41d9eaf 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
@@ -24,9 +24,14 @@
 import com.android.compatibility.common.util.SystemUtil;
 
 import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * Wrapper of the "/system/bin/ot-ctl" which can be used to send CLI commands to ot-daemon to
@@ -35,6 +40,9 @@
  * <p>Note that this class takes root privileged to run.
  */
 public final class OtDaemonController {
+    public static final int DIAG_VENDOR_NAME_TLV_TYPE = 25;
+    public static final int DIAG_VENDOR_MODEL_TLV_TYPE = 26;
+
     private static final String OT_CTL = "/system/bin/ot-ctl";
 
     /**
@@ -72,6 +80,25 @@
                 .toList();
     }
 
+    /** Returns the OMR address of this device or {@code null} if it doesn't exist. */
+    @Nullable
+    public Inet6Address getOmrAddress() {
+        List<Inet6Address> allAddresses = new ArrayList<>(getAddresses());
+        allAddresses.removeAll(getMeshLocalAddresses());
+
+        List<Inet6Address> omrAddresses =
+                allAddresses.stream()
+                        .filter(addr -> !addr.isLinkLocalAddress())
+                        .collect(Collectors.toList());
+        if (omrAddresses.isEmpty()) {
+            return null;
+        } else if (omrAddresses.size() > 1) {
+            throw new IllegalStateException();
+        }
+
+        return omrAddresses.getFirst();
+    }
+
     /** Returns {@code true} if the Thread interface is up. */
     public boolean isInterfaceUp() {
         String output = executeCommand("ifconfig");
@@ -156,11 +183,46 @@
         return executeCommandAndParse("extpanid").get(0);
     }
 
+    public boolean isCountryCodeSupported() {
+        final String result = executeCommand("region");
+
+        return !result.equals("Error 12: NotImplemented\r\n");
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
 
     /**
+     * Sends DIAG_GET request to the given peer device and returns the parsed result as a dict of
+     * the requested TLV values.
+     *
+     * <p>For example, a request {@code netDiagGet("fdad:3d13:7b11:4049:ed1a:7e87:4770:a345",
+     * [DIAG_VENDOR_NAME_TLV_TYPE, DIAG_VENDOR_MODEL_TLV_TYPE])} can return a dict of {@code
+     * {"Vendor Name" : "ABC", "Vendor Model" : "Cuttlefish"}}
+     */
+    public Map<String, String> netDiagGet(InetAddress peerAddr, List<Integer> tlvTypes) {
+        String tlvTypeList =
+                tlvTypes.stream().map(Object::toString).collect(Collectors.joining(" "));
+
+        List<String> outputs =
+                executeCommandAndParse(
+                        "networkdiagnostic get " + peerAddr.getHostAddress() + " " + tlvTypeList);
+        Map<String, String> result = new HashMap<>();
+        for (String line : outputs) {
+            if (line.startsWith("DIAG_GET")) {
+                continue;
+            }
+            String[] keyValue = line.split(":");
+            if (keyValue.length != 2) {
+                throw new IllegalStateException("Unexpected OT output: " + line);
+            }
+            result.put(keyValue[0].strip(), keyValue[1].strip());
+        }
+        return result;
+    }
+
+    /**
      * Executes a ot-ctl command and parse the output to a list of strings.
      *
      * <p>The trailing "Done" in the command output will be dropped.
diff --git a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
index b586a19..62c2785 100644
--- a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
+++ b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
@@ -16,44 +16,23 @@
 package android.net.thread.utils;
 
 import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
-import static android.net.InetAddresses.parseNumericAddress;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_TRUSTED;
-import static android.net.NetworkCapabilities.TRANSPORT_TEST;
-import static android.system.OsConstants.AF_INET6;
-import static android.system.OsConstants.IPPROTO_UDP;
-import static android.system.OsConstants.SOCK_DGRAM;
 
 import static com.android.testutils.RecorderCallback.CallbackEntry.LINK_PROPERTIES_CHANGED;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
-import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.Network;
-import android.net.NetworkAgentConfig;
-import android.net.NetworkCapabilities;
-import android.net.NetworkRequest;
 import android.net.TestNetworkInterface;
 import android.net.TestNetworkManager;
-import android.net.TestNetworkSpecifier;
 import android.os.Looper;
-import android.system.ErrnoException;
-import android.system.Os;
 
-import com.android.compatibility.common.util.PollingCheck;
 import com.android.testutils.TestableNetworkAgent;
 import com.android.testutils.TestableNetworkCallback;
 
-import java.io.FileDescriptor;
 import java.io.IOException;
-import java.net.InterfaceAddress;
-import java.net.NetworkInterface;
-import java.net.SocketException;
 import java.time.Duration;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
 
 /** A class that can create/destroy a test network based on TAP interface. */
 public final class TapTestNetworkTracker {
@@ -100,27 +79,17 @@
     private void setUpTestNetwork() throws Exception {
         mInterface = mContext.getSystemService(TestNetworkManager.class).createTapInterface();
 
-        mConnectivityManager.requestNetwork(newNetworkRequest(), mNetworkCallback);
+        mConnectivityManager.requestNetwork(
+                TestableNetworkAgent.Companion.makeNetworkRequestForInterface(
+                        mInterface.getInterfaceName()),
+                mNetworkCallback);
 
         LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(getInterfaceName());
         mAgent =
-                new TestableNetworkAgent(
-                        mContext,
-                        mLooper,
-                        newNetworkCapabilities(),
-                        lp,
-                        new NetworkAgentConfig.Builder().build());
-        mNetwork = mAgent.register();
-        mAgent.markConnected();
+                TestableNetworkAgent.Companion.createOnInterface(
+                        mContext, mLooper, mInterface.getInterfaceName(), TIMEOUT.toMillis());
 
-        PollingCheck.check(
-                "No usable address on interface",
-                TIMEOUT.toMillis(),
-                () -> hasUsableAddress(mNetwork, getInterfaceName()));
-
-        lp.setLinkAddresses(makeLinkAddresses());
-        mAgent.sendLinkProperties(lp);
         mNetworkCallback.eventuallyExpect(
                 LINK_PROPERTIES_CHANGED,
                 TIMEOUT.toMillis(),
@@ -133,59 +102,4 @@
         mInterface.getFileDescriptor().close();
         mAgent.waitForIdle(TIMEOUT.toMillis());
     }
-
-    private NetworkRequest newNetworkRequest() {
-        return new NetworkRequest.Builder()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()))
-                .build();
-    }
-
-    private NetworkCapabilities newNetworkCapabilities() {
-        return new NetworkCapabilities()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()));
-    }
-
-    private List<LinkAddress> makeLinkAddresses() {
-        List<LinkAddress> linkAddresses = new ArrayList<>();
-        List<InterfaceAddress> interfaceAddresses = Collections.emptyList();
-
-        try {
-            interfaceAddresses =
-                    NetworkInterface.getByName(getInterfaceName()).getInterfaceAddresses();
-        } catch (SocketException ignored) {
-            // Ignore failures when getting the addresses.
-        }
-
-        for (InterfaceAddress address : interfaceAddresses) {
-            linkAddresses.add(
-                    new LinkAddress(address.getAddress(), address.getNetworkPrefixLength()));
-        }
-
-        return linkAddresses;
-    }
-
-    private static boolean hasUsableAddress(Network network, String interfaceName) {
-        try {
-            if (NetworkInterface.getByName(interfaceName).getInterfaceAddresses().isEmpty()) {
-                return false;
-            }
-        } catch (SocketException e) {
-            return false;
-        }
-        // Check if the link-local address can be used. Address flags are not available without
-        // elevated permissions, so check that bindSocket works.
-        try {
-            FileDescriptor sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
-            network.bindSocket(sock);
-            Os.connect(sock, parseNumericAddress("ff02::fb%" + interfaceName), 12345);
-            Os.close(sock);
-        } catch (ErrnoException | IOException e) {
-            return false;
-        }
-        return true;
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestDnsServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestDnsServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt b/thread/tests/utils/src/android/net/thread/utils/TestTunNetworkUtils.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestTunNetworkUtils.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestUdpEchoServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestUdpEchoServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestUdpServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestUdpServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
similarity index 97%
rename from thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
rename to thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
index b6114f3..c4150cb 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -233,7 +233,10 @@
     public void setNat64EnabledAndWait(boolean enabled) throws Exception {
         final ThreadConfiguration config = getConfiguration();
         final ThreadConfiguration newConfig =
-                new ThreadConfiguration.Builder(config).setNat64Enabled(enabled).build();
+                new ThreadConfiguration.Builder(config)
+                        .setBorderRouterEnabled(true)
+                        .setNat64Enabled(enabled)
+                        .build();
         setConfigurationAndWait(newConfig);
     }