Merge "Remove casbor@google.com from remoteauth/OWNERS" into main
diff --git a/Cronet/OWNERS b/Cronet/OWNERS
deleted file mode 100644
index c24680e..0000000
--- a/Cronet/OWNERS
+++ /dev/null
@@ -1,2 +0,0 @@
-set noparent
-file:platform/packages/modules/Connectivity:main:/OWNERS_core_networking
diff --git a/OWNERS_core_networking b/OWNERS_core_networking
index 6d8ed4a..078ccde 100644
--- a/OWNERS_core_networking
+++ b/OWNERS_core_networking
@@ -1,12 +1,13 @@
 jchalard@google.com
 junyulai@google.com
 lorenzo@google.com
-martinwu@google.com
 maze@google.com
 motomuman@google.com
 paulhu@google.com
 prohr@google.com
 reminv@google.com
-satk@google.com
 xiaom@google.com
 yuyanghuang@google.com
+
+martinwu@google.com #{LAST_RESORT_SUGGESTION}
+satk@google.com #{LAST_RESORT_SUGGESTION}
\ No newline at end of file
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 94adc5b..1d2041b 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -316,6 +316,14 @@
         }
       ]
     },
+    {
+      "name": "CtsHostsideNetworkTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "options": [
+        {
+          "exclude-annotation": "androidx.test.filters.RequiresDevice"
+        }
+      ]
+    },
     // Test with APK modules only, in cases where APEX is not supported, or the other modules
     // were simply not updated
     {
@@ -414,15 +422,14 @@
           "exclude-annotation": "androidx.test.filters.RequiresDevice"
         }
       ]
-    },
-    // TODO: upgrade to presubmit. Postsubmit on virtual devices to monitor flakiness only.
+    }
+  ],
+  "automotive-mumd-presubmit": [
     {
-      "name": "CtsHostsideNetworkTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
-      "options": [
-        {
-          "exclude-annotation": "androidx.test.filters.RequiresDevice"
-        }
-      ]
+      "name": "CtsNetTestCases"
+    },
+    {
+      "name": "CtsNetTestCasesUpdateStatsPermission"
     }
   ],
   "imports": [
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 70b38a4..4d173a5 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -63,6 +63,7 @@
     static_libs: [
         "androidx.annotation_annotation",
         "connectivity-net-module-utils-bpf",
+        "com.android.net.flags-aconfig-java",
         "modules-utils-build",
         "modules-utils-statemachine",
         "networkstack-client",
@@ -79,6 +80,7 @@
     ],
     defaults: ["TetheringExternalLibs"],
     libs: [
+        "framework-annotations-lib",
         "framework-tethering.impl",
     ],
     manifest: "AndroidManifestBase.xml",
@@ -203,6 +205,7 @@
     use_embedded_native_libs: true,
     privapp_allowlist: ":privapp_allowlist_com.android.tethering",
     apex_available: ["com.android.tethering"],
+    updatable: true,
 }
 
 android_app {
@@ -220,6 +223,7 @@
     lint: {
         error_checks: ["NewApi"],
     },
+    updatable: true,
 }
 
 sdk {
diff --git a/Tethering/AndroidManifest.xml b/Tethering/AndroidManifest.xml
index 6a363b0..32442f5 100644
--- a/Tethering/AndroidManifest.xml
+++ b/Tethering/AndroidManifest.xml
@@ -32,7 +32,11 @@
     <uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED" />
     <uses-permission android:name="android.permission.BROADCAST_STICKY" />
     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
     <uses-permission android:name="android.permission.MANAGE_USB" />
+    <!-- MANAGE_USERS is for accessing multi-user APIs, note that QUERY_USERS should
+         not be used since it is not a privileged permission until U. -->
+    <uses-permission android:name="android.permission.MANAGE_USERS"/>
     <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
     <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
     <uses-permission android:name="android.permission.READ_NETWORK_USAGE_HISTORY" />
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 3b197fc..19dd492 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -98,7 +98,6 @@
     ],
     canned_fs_config: "canned_fs_config",
     bpfs: [
-        "block.o",
         "clatd.o",
         "dscpPolicy.o",
         "netd.o",
@@ -156,7 +155,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.
@@ -196,6 +198,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/permissions/permissions.xml b/Tethering/apex/permissions/permissions.xml
index f26a961..4051877 100644
--- a/Tethering/apex/permissions/permissions.xml
+++ b/Tethering/apex/permissions/permissions.xml
@@ -18,7 +18,9 @@
 <permissions>
     <privapp-permissions package="com.android.networkstack.tethering">
         <permission name="android.permission.BLUETOOTH_PRIVILEGED" />
+        <permission name="android.permission.INTERACT_ACROSS_USERS"/>
         <permission name="android.permission.MANAGE_USB"/>
+        <permission name="android.permission.MANAGE_USERS"/>
         <permission name="android.permission.MODIFY_PHONE_STATE"/>
         <permission name="android.permission.READ_NETWORK_USAGE_HISTORY"/>
         <permission name="android.permission.TETHER_PRIVILEGED"/>
diff --git a/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java b/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
index 0df9047..af061e4 100644
--- a/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
@@ -198,4 +198,13 @@
     public String toString() {
         return "Netd used";
     }
+
+    @Override
+    public int getLastMaxConnectionAndResetToCurrent() {
+        return 0;
+    }
+
+    @Override
+    public void clearConnectionCounters() {
+    }
 }
diff --git a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
index e6e99f4..b460f0d 100644
--- a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
@@ -19,6 +19,7 @@
 import static android.net.netstats.provider.NetworkStatsProvider.QUOTA_UNLIMITED;
 
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_ACTIVE_SESSIONS_METRICS;
 
 import android.system.ErrnoException;
 import android.system.Os;
@@ -108,6 +109,22 @@
     // TODO: Add IPv6 rule count.
     private final SparseArray<Integer> mRule4CountOnUpstream = new SparseArray<>();
 
+    private final boolean mSupportActiveSessionsMetrics;
+    /**
+     * Tracks the current number of tethering connections and the maximum
+     * observed since the last metrics collection. Used to provide insights
+     * into the distribution of active tethering sessions for metrics reporting.
+
+     * These variables are accessed on the handler thread, which includes:
+     *  1. ConntrackEvents signaling the addition or removal of an IPv4 rule.
+     *  2. ConntrackEvents indicating the removal of a tethering client,
+     *     triggering the removal of associated rules.
+     *  3. Removal of the last IpServer, which resets counters to handle
+     *     potential synchronization issues.
+     */
+    private int mLastMaxConnectionCount = 0;
+    private int mCurrentConnectionCount = 0;
+
     public BpfCoordinatorShimImpl(@NonNull final Dependencies deps) {
         mLog = deps.getSharedLog().forSubComponent(TAG);
 
@@ -156,6 +173,9 @@
         } catch (ErrnoException e) {
             mLog.e("Could not clear mBpfDevMap: " + e);
         }
+
+        mSupportActiveSessionsMetrics = deps.isFeatureEnabled(deps.getContext(),
+                TETHER_ACTIVE_SESSIONS_METRICS);
     }
 
     @Override
@@ -350,6 +370,12 @@
                 final int upstreamIfindex = (int) key.iif;
                 int count = mRule4CountOnUpstream.get(upstreamIfindex, 0 /* default */);
                 mRule4CountOnUpstream.put(upstreamIfindex, ++count);
+
+                if (mSupportActiveSessionsMetrics) {
+                    mCurrentConnectionCount++;
+                    mLastMaxConnectionCount = Math.max(mCurrentConnectionCount,
+                            mLastMaxConnectionCount);
+                }
             } else {
                 mBpfUpstream4Map.insertEntry(key, value);
             }
@@ -385,6 +411,10 @@
                 } else {
                     mRule4CountOnUpstream.put(upstreamIfindex, count);
                 }
+
+                if (mSupportActiveSessionsMetrics) {
+                    mCurrentConnectionCount--;
+                }
             } else {
                 if (!mBpfUpstream4Map.deleteEntry(key)) return false;  // Rule did not exist
             }
@@ -465,14 +495,16 @@
 
     @Override
     public String toString() {
-        return String.join(", ", new String[] {
-                mapStatus(mBpfDownstream6Map, "mBpfDownstream6Map"),
-                mapStatus(mBpfUpstream6Map, "mBpfUpstream6Map"),
-                mapStatus(mBpfDownstream4Map, "mBpfDownstream4Map"),
-                mapStatus(mBpfUpstream4Map, "mBpfUpstream4Map"),
-                mapStatus(mBpfStatsMap, "mBpfStatsMap"),
-                mapStatus(mBpfLimitMap, "mBpfLimitMap"),
-                mapStatus(mBpfDevMap, "mBpfDevMap")
+        return String.join(", ", new String[]{
+            mapStatus(mBpfDownstream6Map, "mBpfDownstream6Map"),
+            mapStatus(mBpfUpstream6Map, "mBpfUpstream6Map"),
+            mapStatus(mBpfDownstream4Map, "mBpfDownstream4Map"),
+            mapStatus(mBpfUpstream4Map, "mBpfUpstream4Map"),
+            mapStatus(mBpfStatsMap, "mBpfStatsMap"),
+            mapStatus(mBpfLimitMap, "mBpfLimitMap"),
+            mapStatus(mBpfDevMap, "mBpfDevMap"),
+            "mCurrentConnectionCount=" + mCurrentConnectionCount,
+            "mLastMaxConnectionCount=" + mLastMaxConnectionCount
         });
     }
 
@@ -507,4 +539,17 @@
 
         return 0;
     }
+
+    /** Get last max connection count and reset to current count. */
+    public int getLastMaxConnectionAndResetToCurrent() {
+        final int ret = mLastMaxConnectionCount;
+        mLastMaxConnectionCount = mCurrentConnectionCount;
+        return ret;
+    }
+
+    /** Clear current connection count. */
+    public void clearConnectionCounters() {
+        mCurrentConnectionCount = 0;
+        mLastMaxConnectionCount = 0;
+    }
 }
diff --git a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
index 026b1c3..cb8bcc9 100644
--- a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
+++ b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
@@ -202,5 +202,11 @@
      * Remove interface index mapping.
      */
     public abstract boolean removeDevMap(int ifIndex);
+
+    /** Get last max connection count and reset to current count. */
+    public abstract int getLastMaxConnectionAndResetToCurrent();
+
+    /** Clear current connection count. */
+    public abstract void clearConnectionCounters();
 }
 
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 2f3307a..e2498e4 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -33,6 +33,10 @@
 
         // Using for test only
         "//cts/tests/netlegacy22.api",
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
diff --git a/Tethering/common/TetheringLib/api/current.txt b/Tethering/common/TetheringLib/api/current.txt
index d802177..932e801 100644
--- a/Tethering/common/TetheringLib/api/current.txt
+++ b/Tethering/common/TetheringLib/api/current.txt
@@ -1 +1,71 @@
 // Signature format: 2.0
+package android.net {
+
+  public final class TetheringInterface implements android.os.Parcelable {
+    ctor public TetheringInterface(int, @NonNull String);
+    ctor @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public TetheringInterface(int, @NonNull String, @Nullable android.net.wifi.SoftApConfiguration);
+    method public int describeContents();
+    method @NonNull public String getInterface();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable @RequiresPermission(value=android.Manifest.permission.NETWORK_SETTINGS, conditional=true) public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
+    method public int getType();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringInterface> CREATOR;
+  }
+
+  public class TetheringManager {
+    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerTetheringEventCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.TetheringEventCallback);
+    method @RequiresPermission(value=android.Manifest.permission.TETHER_PRIVILEGED, conditional=true) public void startTethering(@NonNull android.net.TetheringManager.TetheringRequest, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.StartTetheringCallback);
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @RequiresPermission(value=android.Manifest.permission.TETHER_PRIVILEGED, conditional=true) public void stopTethering(@NonNull android.net.TetheringManager.TetheringRequest, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.StopTetheringCallback);
+    method @RequiresPermission(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.ACCESS_NETWORK_STATE}) public void unregisterTetheringEventCallback(@NonNull android.net.TetheringManager.TetheringEventCallback);
+    field public static final int CONNECTIVITY_SCOPE_GLOBAL = 1; // 0x1
+    field public static final int TETHERING_WIFI = 0; // 0x0
+    field public static final int TETHER_ERROR_DHCPSERVER_ERROR = 12; // 0xc
+    field public static final int TETHER_ERROR_DISABLE_FORWARDING_ERROR = 9; // 0x9
+    field @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public static final int TETHER_ERROR_DUPLICATE_REQUEST = 18; // 0x12
+    field public static final int TETHER_ERROR_ENABLE_FORWARDING_ERROR = 8; // 0x8
+    field public static final int TETHER_ERROR_ENTITLEMENT_UNKNOWN = 13; // 0xd
+    field public static final int TETHER_ERROR_IFACE_CFG_ERROR = 10; // 0xa
+    field public static final int TETHER_ERROR_INTERNAL_ERROR = 5; // 0x5
+    field public static final int TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION = 15; // 0xf
+    field public static final int TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14; // 0xe
+    field public static final int TETHER_ERROR_NO_ERROR = 0; // 0x0
+    field public static final int TETHER_ERROR_PROVISIONING_FAILED = 11; // 0xb
+    field public static final int TETHER_ERROR_SERVICE_UNAVAIL = 2; // 0x2
+    field public static final int TETHER_ERROR_TETHER_IFACE_ERROR = 6; // 0x6
+    field public static final int TETHER_ERROR_UNAVAIL_IFACE = 4; // 0x4
+    field public static final int TETHER_ERROR_UNKNOWN_IFACE = 1; // 0x1
+    field @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public static final int TETHER_ERROR_UNKNOWN_REQUEST = 17; // 0x11
+    field public static final int TETHER_ERROR_UNKNOWN_TYPE = 16; // 0x10
+    field public static final int TETHER_ERROR_UNSUPPORTED = 3; // 0x3
+    field public static final int TETHER_ERROR_UNTETHER_IFACE_ERROR = 7; // 0x7
+  }
+
+  public static interface TetheringManager.StartTetheringCallback {
+    method public default void onTetheringFailed(int);
+    method public default void onTetheringStarted();
+  }
+
+  @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public static interface TetheringManager.StopTetheringCallback {
+    method public default void onStopTetheringFailed(int);
+    method public default void onStopTetheringSucceeded();
+  }
+
+  public static interface TetheringManager.TetheringEventCallback {
+    method public default void onTetheredInterfacesChanged(@NonNull java.util.Set<android.net.TetheringInterface>);
+  }
+
+  public static final class TetheringManager.TetheringRequest implements android.os.Parcelable {
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public int describeContents();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringManager.TetheringRequest> CREATOR;
+  }
+
+  public static class TetheringManager.TetheringRequest.Builder {
+    ctor public TetheringManager.TetheringRequest.Builder(int);
+    method @NonNull public android.net.TetheringManager.TetheringRequest build();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setSoftApConfiguration(@Nullable android.net.wifi.SoftApConfiguration);
+  }
+
+}
+
diff --git a/Tethering/common/TetheringLib/api/module-lib-current.txt b/Tethering/common/TetheringLib/api/module-lib-current.txt
index 460c216..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);
   }
@@ -46,5 +46,15 @@
     method @Deprecated @NonNull public java.util.List<java.lang.String> getTetherableWifiRegexs();
   }
 
+  public static final class TetheringManager.TetheringRequest implements android.os.Parcelable {
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable public String getInterfaceName();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable public String getPackageName();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public int getUid();
+  }
+
+  public static class TetheringManager.TetheringRequest.Builder {
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @NonNull @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public android.net.TetheringManager.TetheringRequest.Builder setInterfaceName(@Nullable String);
+  }
+
 }
 
diff --git a/Tethering/common/TetheringLib/api/system-current.txt b/Tethering/common/TetheringLib/api/system-current.txt
index 3efaac2..c0c0abc 100644
--- a/Tethering/common/TetheringLib/api/system-current.txt
+++ b/Tethering/common/TetheringLib/api/system-current.txt
@@ -19,24 +19,11 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.net.TetheredClient.AddressInfo> CREATOR;
   }
 
-  public final class TetheringInterface implements android.os.Parcelable {
-    ctor public TetheringInterface(int, @NonNull String);
-    method public int describeContents();
-    method @NonNull public String getInterface();
-    method public int getType();
-    method public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringInterface> CREATOR;
-  }
-
   public class TetheringManager {
-    method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerTetheringEventCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.TetheringEventCallback);
-    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 startTethering(@NonNull android.net.TetheringManager.TetheringRequest, @NonNull java.util.concurrent.Executor, @NonNull android.net.TetheringManager.StartTetheringCallback);
-    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(anyOf={android.Manifest.permission.TETHER_PRIVILEGED, android.Manifest.permission.ACCESS_NETWORK_STATE}) public void unregisterTetheringEventCallback(@NonNull android.net.TetheringManager.TetheringEventCallback);
+    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_GLOBAL = 1; // 0x1
     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";
     field public static final String EXTRA_ACTIVE_TETHER = "tetherArray";
@@ -48,25 +35,7 @@
     field public static final int TETHERING_NCM = 4; // 0x4
     field public static final int TETHERING_USB = 1; // 0x1
     field @FlaggedApi("com.android.net.flags.tethering_request_virtual") public static final int TETHERING_VIRTUAL = 7; // 0x7
-    field public static final int TETHERING_WIFI = 0; // 0x0
     field public static final int TETHERING_WIFI_P2P = 3; // 0x3
-    field public static final int TETHER_ERROR_DHCPSERVER_ERROR = 12; // 0xc
-    field public static final int TETHER_ERROR_DISABLE_FORWARDING_ERROR = 9; // 0x9
-    field public static final int TETHER_ERROR_ENABLE_FORWARDING_ERROR = 8; // 0x8
-    field public static final int TETHER_ERROR_ENTITLEMENT_UNKNOWN = 13; // 0xd
-    field public static final int TETHER_ERROR_IFACE_CFG_ERROR = 10; // 0xa
-    field public static final int TETHER_ERROR_INTERNAL_ERROR = 5; // 0x5
-    field public static final int TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION = 15; // 0xf
-    field public static final int TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14; // 0xe
-    field public static final int TETHER_ERROR_NO_ERROR = 0; // 0x0
-    field public static final int TETHER_ERROR_PROVISIONING_FAILED = 11; // 0xb
-    field public static final int TETHER_ERROR_SERVICE_UNAVAIL = 2; // 0x2
-    field public static final int TETHER_ERROR_TETHER_IFACE_ERROR = 6; // 0x6
-    field public static final int TETHER_ERROR_UNAVAIL_IFACE = 4; // 0x4
-    field public static final int TETHER_ERROR_UNKNOWN_IFACE = 1; // 0x1
-    field public static final int TETHER_ERROR_UNKNOWN_TYPE = 16; // 0x10
-    field public static final int TETHER_ERROR_UNSUPPORTED = 3; // 0x3
-    field public static final int TETHER_ERROR_UNTETHER_IFACE_ERROR = 7; // 0x7
     field public static final int TETHER_HARDWARE_OFFLOAD_FAILED = 2; // 0x2
     field public static final int TETHER_HARDWARE_OFFLOAD_STARTED = 1; // 0x1
     field public static final int TETHER_HARDWARE_OFFLOAD_STOPPED = 0; // 0x0
@@ -76,11 +45,6 @@
     method public void onTetheringEntitlementResult(int);
   }
 
-  public static interface TetheringManager.StartTetheringCallback {
-    method public default void onTetheringFailed(int);
-    method public default void onTetheringStarted();
-  }
-
   public static interface TetheringManager.TetheringEventCallback {
     method public default void onClientsChanged(@NonNull java.util.Collection<android.net.TetheredClient>);
     method public default void onError(@NonNull String, int);
@@ -91,31 +55,23 @@
     method public default void onTetherableInterfacesChanged(@NonNull java.util.List<java.lang.String>);
     method public default void onTetherableInterfacesChanged(@NonNull java.util.Set<android.net.TetheringInterface>);
     method public default void onTetheredInterfacesChanged(@NonNull java.util.List<java.lang.String>);
-    method public default void onTetheredInterfacesChanged(@NonNull java.util.Set<android.net.TetheringInterface>);
     method public default void onTetheringSupported(boolean);
     method public default void onUpstreamChanged(@Nullable android.net.Network);
   }
 
   public static final class TetheringManager.TetheringRequest implements android.os.Parcelable {
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public int describeContents();
     method @Nullable public android.net.LinkAddress getClientStaticIpv4Address();
     method public int getConnectivityScope();
     method @Nullable public android.net.LinkAddress getLocalIpv4Address();
     method public boolean getShouldShowEntitlementUi();
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @Nullable public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
     method public int getTetheringType();
     method public boolean isExemptFromEntitlementCheck();
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringManager.TetheringRequest> CREATOR;
   }
 
   public static class TetheringManager.TetheringRequest.Builder {
-    ctor public TetheringManager.TetheringRequest.Builder(int);
-    method @NonNull public android.net.TetheringManager.TetheringRequest build();
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setConnectivityScope(int);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setExemptFromEntitlementCheck(boolean);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setShouldShowEntitlementUi(boolean);
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setSoftApConfiguration(@Nullable android.net.wifi.SoftApConfiguration);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setStaticIpv4Addresses(@NonNull android.net.LinkAddress, @NonNull android.net.LinkAddress);
   }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java b/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
index 84cdef1..250179a 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
@@ -16,78 +16,113 @@
 
 package android.net;
 
+import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
-import android.annotation.SystemApi;
+import android.annotation.RequiresPermission;
+import android.annotation.SuppressLint;
 import android.net.TetheringManager.TetheringType;
+import android.net.wifi.SoftApConfiguration;
 import android.os.Parcel;
 import android.os.Parcelable;
 
+import com.android.net.flags.Flags;
+
 import java.util.Objects;
 
 /**
  * The mapping of tethering interface and type.
- * @hide
  */
-@SystemApi
+@SuppressLint("UnflaggedApi")
 public final class TetheringInterface implements Parcelable {
     private final int mType;
     private final String mInterface;
+    @Nullable
+    private final SoftApConfiguration mSoftApConfig;
 
+    @SuppressLint("UnflaggedApi")
     public TetheringInterface(@TetheringType int type, @NonNull String iface) {
+        this(type, iface, null);
+    }
+
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    public TetheringInterface(@TetheringType int type, @NonNull String iface,
+            @Nullable SoftApConfiguration softApConfig) {
         Objects.requireNonNull(iface);
         mType = type;
         mInterface = iface;
-    }
-
-    private TetheringInterface(@NonNull Parcel in) {
-        this(in.readInt(), in.readString());
+        mSoftApConfig = softApConfig;
     }
 
     /** Get tethering type. */
+    @SuppressLint("UnflaggedApi")
     public int getType() {
         return mType;
     }
 
     /** Get tethering interface. */
     @NonNull
+    @SuppressLint("UnflaggedApi")
     public String getInterface() {
         return mInterface;
     }
 
+    /**
+     * Get the SoftApConfiguration provided for this interface, if any. This will only be populated
+     * for apps with the same uid that specified the configuration, or apps with permission
+     * {@link android.Manifest.permission.NETWORK_SETTINGS}.
+     */
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    @RequiresPermission(value = android.Manifest.permission.NETWORK_SETTINGS, conditional = true)
+    @Nullable
+    @SuppressLint("UnflaggedApi")
+    public SoftApConfiguration getSoftApConfiguration() {
+        return mSoftApConfig;
+    }
+
     @Override
+    @SuppressLint("UnflaggedApi")
     public void writeToParcel(@NonNull Parcel dest, int flags) {
         dest.writeInt(mType);
         dest.writeString(mInterface);
+        dest.writeParcelable(mSoftApConfig, flags);
     }
 
     @Override
+    @SuppressLint("UnflaggedApi")
     public int hashCode() {
-        return Objects.hash(mType, mInterface);
+        return Objects.hash(mType, mInterface, mSoftApConfig);
     }
 
     @Override
+    @SuppressLint("UnflaggedApi")
     public boolean equals(@Nullable Object obj) {
         if (!(obj instanceof TetheringInterface)) return false;
         final TetheringInterface other = (TetheringInterface) obj;
-        return mType == other.mType && mInterface.equals(other.mInterface);
+        return mType == other.mType && mInterface.equals(other.mInterface)
+                && Objects.equals(mSoftApConfig, other.mSoftApConfig);
     }
 
     @Override
+    @SuppressLint("UnflaggedApi")
     public int describeContents() {
         return 0;
     }
 
     @NonNull
+    @SuppressLint("UnflaggedApi")
     public static final Creator<TetheringInterface> CREATOR = new Creator<TetheringInterface>() {
         @NonNull
         @Override
+        @SuppressLint("UnflaggedApi")
         public TetheringInterface createFromParcel(@NonNull Parcel in) {
-            return new TetheringInterface(in);
+            return new TetheringInterface(in.readInt(), in.readString(),
+                    in.readParcelable(SoftApConfiguration.class.getClassLoader()));
         }
 
         @NonNull
         @Override
+        @SuppressLint("UnflaggedApi")
         public TetheringInterface[] newArray(int size) {
             return new TetheringInterface[size];
         }
@@ -97,6 +132,8 @@
     @Override
     public String toString() {
         return "TetheringInterface {mType=" + mType
-                + ", mInterface=" + mInterface + "}";
+                + ", mInterface=" + mInterface
+                + ((mSoftApConfig == null) ? "" : ", mSoftApConfig=" + mSoftApConfig)
+                + "}";
     }
 }
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 5aca642..21f36e8 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -28,11 +28,13 @@
 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;
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.os.Process;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
 import android.util.ArrayMap;
@@ -53,6 +55,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.StringJoiner;
 import java.util.concurrent.Executor;
 import java.util.function.Supplier;
 
@@ -61,9 +64,8 @@
  * <p> The primary responsibilities of this class are to provide the APIs for applications to
  * start tethering, stop tethering, query configuration and query status.
  *
- * @hide
  */
-@SystemApi
+@SuppressLint({"NotCloseable", "UnflaggedApi"})
 public class TetheringManager {
     private static final String TAG = TetheringManager.class.getSimpleName();
     private static final int DEFAULT_TIMEOUT_MS = 60_000;
@@ -93,36 +95,46 @@
      * {@code TetheringManager.EXTRA_ERRORED_TETHER} to indicate
      * the current state of tethering.  Each include a list of
      * interface names in that state (may be empty).
+     * @hide
      *
      * @deprecated New client should use TetheringEventCallback instead.
      */
     @Deprecated
+    @SystemApi
     public static final String ACTION_TETHER_STATE_CHANGED =
             "android.net.conn.TETHER_STATE_CHANGED";
 
     /**
      * gives a String[] listing all the interfaces configured for
      * tethering and currently available for tethering.
+     * @hide
      */
+    @SystemApi
     public static final String EXTRA_AVAILABLE_TETHER = "availableArray";
 
     /**
      * gives a String[] listing all the interfaces currently in local-only
      * mode (ie, has DHCPv4+IPv6-ULA support and no packet forwarding)
+     * @hide
      */
+    @SystemApi
     public static final String EXTRA_ACTIVE_LOCAL_ONLY = "android.net.extra.ACTIVE_LOCAL_ONLY";
 
     /**
      * gives a String[] listing all the interfaces currently tethered
      * (ie, has DHCPv4 support and packets potentially forwarded/NATed)
+     * @hide
      */
+    @SystemApi
     public static final String EXTRA_ACTIVE_TETHER = "tetherArray";
 
     /**
      * gives a String[] listing all the interfaces we tried to tether and
      * failed.  Use {@link #getLastTetherError} to find the error code
      * for any interfaces listed here.
+     * @hide
      */
+    @SystemApi
     public static final String EXTRA_ERRORED_TETHER = "erroredArray";
 
     /** @hide */
@@ -134,6 +146,7 @@
             TETHERING_WIFI_P2P,
             TETHERING_NCM,
             TETHERING_ETHERNET,
+            TETHERING_VIRTUAL,
     })
     public @interface TetheringType {
     }
@@ -141,44 +154,57 @@
     /**
      * Invalid tethering type.
      * @see #startTethering.
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_INVALID   = -1;
 
     /**
      * Wifi tethering type.
      * @see #startTethering.
      */
+    @SuppressLint("UnflaggedApi")
     public static final int TETHERING_WIFI      = 0;
 
     /**
      * USB tethering type.
      * @see #startTethering.
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_USB       = 1;
 
     /**
      * Bluetooth tethering type.
      * @see #startTethering.
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_BLUETOOTH = 2;
 
     /**
      * Wifi P2p tethering type.
      * Wifi P2p tethering is set through events automatically, and don't
      * need to start from #startTethering.
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_WIFI_P2P = 3;
 
     /**
      * Ncm local tethering type.
      * @see #startTethering(TetheringRequest, Executor, StartTetheringCallback)
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_NCM = 4;
 
     /**
      * Ethernet tethering type.
      * @see #startTethering(TetheringRequest, Executor, StartTetheringCallback)
+     * @hide
      */
+    @SystemApi
     public static final int TETHERING_ETHERNET = 5;
 
     /**
@@ -207,6 +233,20 @@
      */
     public static final int MAX_TETHERING_TYPE = TETHERING_VIRTUAL;
 
+    private static String typeToString(@TetheringType int type) {
+        switch (type) {
+            case TETHERING_INVALID: return "TETHERING_INVALID";
+            case TETHERING_WIFI: return "TETHERING_WIFI";
+            case TETHERING_USB: return "TETHERING_USB";
+            case TETHERING_BLUETOOTH: return "TETHERING_BLUETOOTH";
+            case TETHERING_WIFI_P2P: return "TETHERING_WIFI_P2P";
+            case TETHERING_NCM: return "TETHERING_NCM";
+            case TETHERING_ETHERNET: return "TETHERING_ETHERNET";
+            default:
+                return "TETHERING_UNKNOWN(" + type + ")";
+        }
+    }
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(value = {
@@ -237,30 +277,62 @@
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(value = {
             TETHER_ERROR_SERVICE_UNAVAIL,
+            TETHER_ERROR_UNSUPPORTED,
             TETHER_ERROR_INTERNAL_ERROR,
             TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION,
             TETHER_ERROR_UNKNOWN_TYPE,
+            TETHER_ERROR_DUPLICATE_REQUEST,
     })
     public @interface StartTetheringError {
     }
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(value = {
+            TETHER_ERROR_NO_ERROR,
+            TETHER_ERROR_UNKNOWN_REQUEST,
+    })
+    public @interface StopTetheringError {
+    }
+
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_NO_ERROR = 0;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_UNKNOWN_IFACE = 1;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_SERVICE_UNAVAIL = 2;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_UNSUPPORTED = 3;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_UNAVAIL_IFACE = 4;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_INTERNAL_ERROR = 5;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_TETHER_IFACE_ERROR = 6;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_UNTETHER_IFACE_ERROR = 7;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_ENABLE_FORWARDING_ERROR = 8;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_DISABLE_FORWARDING_ERROR = 9;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_IFACE_CFG_ERROR = 10;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_PROVISIONING_FAILED = 11;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_DHCPSERVER_ERROR = 12;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_ENTITLEMENT_UNKNOWN = 13;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION = 15;
+    @SuppressLint("UnflaggedApi")
     public static final int TETHER_ERROR_UNKNOWN_TYPE = 16;
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    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;
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -272,11 +344,23 @@
     public @interface TetherOffloadStatus {
     }
 
-    /** Tethering offload status is stopped. */
+    /**
+     * Tethering offload status is stopped.
+     * @hide
+     */
+    @SystemApi
     public static final int TETHER_HARDWARE_OFFLOAD_STOPPED = 0;
-    /** Tethering offload status is started. */
+    /**
+     * Tethering offload status is started.
+     * @hide
+     */
+    @SystemApi
     public static final int TETHER_HARDWARE_OFFLOAD_STARTED = 1;
-    /** Fail to start tethering offload. */
+    /**
+     * Fail to start tethering offload.
+     * @hide
+     */
+    @SystemApi
     public static final int TETHER_HARDWARE_OFFLOAD_FAILED = 2;
 
     /**
@@ -305,7 +389,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();
@@ -340,9 +426,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;
                 }
@@ -430,7 +515,7 @@
     // Used to dispatch legacy ConnectivityManager methods that expect tethering to be able to
     // return results and perform operations synchronously.
     // TODO: remove once there are no callers of these legacy methods.
-    private class RequestDispatcher {
+    private static class RequestDispatcher {
         private final ConditionVariable mWaiting;
         public volatile int mRemoteResult;
 
@@ -446,8 +531,8 @@
             mWaiting = new ConditionVariable();
         }
 
-        int waitForResult(final RequestHelper request) {
-            getConnector(c -> request.runRequest(c, mListener));
+        int waitForResult(final RequestHelper request, final TetheringManager mgr) {
+            mgr.getConnector(c -> request.runRequest(c, mListener));
             if (!mWaiting.block(DEFAULT_TIMEOUT_MS)) {
                 throw new IllegalStateException("Callback timeout");
             }
@@ -573,6 +658,13 @@
         }
     }
 
+    private void unsupportedAfterV() {
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            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
@@ -582,8 +674,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
@@ -593,6 +687,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();
@@ -603,7 +699,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
@@ -616,14 +712,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);
 
@@ -635,7 +735,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
@@ -663,18 +763,21 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
      * Indicates that this tethering connection will provide connectivity beyond this device (e.g.,
      * global Internet access).
      */
+    @SuppressLint("UnflaggedApi")
     public static final int CONNECTIVITY_SCOPE_GLOBAL = 1;
 
     /**
      * Indicates that this tethering connection will only provide local connectivity.
+     * @hide
      */
+    @SystemApi
     public static final int CONNECTIVITY_SCOPE_LOCAL = 2;
 
     /**
@@ -688,9 +791,21 @@
     })
     public @interface ConnectivityScope {}
 
+    private static String connectivityScopeToString(@ConnectivityScope int scope) {
+        switch (scope) {
+            case CONNECTIVITY_SCOPE_GLOBAL:
+                return "CONNECTIVITY_SCOPE_GLOBAL";
+            case CONNECTIVITY_SCOPE_LOCAL:
+                return "CONNECTIVITY_SCOPE_LOCAL";
+            default:
+                return "CONNECTIVITY_SCOPE_UNKNOWN(" + scope + ")";
+        }
+    }
+
     /**
      *  Use with {@link #startTethering} to specify additional parameters when starting tethering.
      */
+    @SuppressLint("UnflaggedApi")
     public static final class TetheringRequest implements Parcelable {
         /** A configuration set for TetheringRequest. */
         private final TetheringRequestParcel mRequestParcel;
@@ -698,7 +813,7 @@
         /**
          * @hide
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         public TetheringRequest(@NonNull final TetheringRequestParcel request) {
             mRequestParcel = request;
         }
@@ -707,7 +822,7 @@
             mRequestParcel = in.readParcelable(TetheringRequestParcel.class.getClassLoader());
         }
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @NonNull
         public static final Creator<TetheringRequest> CREATOR = new Creator<>() {
             @Override
@@ -721,23 +836,25 @@
             }
         };
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Override
         public int describeContents() {
             return 0;
         }
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Override
         public void writeToParcel(@NonNull Parcel dest, int flags) {
             dest.writeParcelable(mRequestParcel, flags);
         }
 
         /** Builder used to create TetheringRequest. */
+        @SuppressLint({"UnflaggedApi", "StaticFinalBuilder"})
         public static class Builder {
             private final TetheringRequestParcel mBuilderParcel;
 
             /** Default constructor of Builder. */
+            @SuppressLint("UnflaggedApi")
             public Builder(@TetheringType final int type) {
                 mBuilderParcel = new TetheringRequestParcel();
                 mBuilderParcel.tetheringType = type;
@@ -746,7 +863,9 @@
                 mBuilderParcel.exemptFromEntitlementCheck = false;
                 mBuilderParcel.showProvisioningUi = true;
                 mBuilderParcel.connectivityScope = getDefaultConnectivityScope(type);
+                mBuilderParcel.uid = Process.INVALID_UID;
                 mBuilderParcel.softApConfig = null;
+                mBuilderParcel.interfaceName = null;
             }
 
             /**
@@ -757,7 +876,9 @@
              *
              * @param localIPv4Address The preferred local IPv4 link address to use.
              * @param clientAddress The static client address.
+             * @hide
              */
+            @SystemApi
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setStaticIpv4Addresses(@NonNull final LinkAddress localIPv4Address,
@@ -773,7 +894,11 @@
                 return this;
             }
 
-            /** Start tethering without entitlement checks. */
+            /**
+             * Start tethering without entitlement checks.
+             * @hide
+             */
+            @SystemApi
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setExemptFromEntitlementCheck(boolean exempt) {
@@ -784,7 +909,9 @@
             /**
              * If an entitlement check is needed, sets whether to show the entitlement UI or to
              * perform a silent entitlement check. By default, the entitlement UI is shown.
+             * @hide
              */
+            @SystemApi
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setShouldShowEntitlementUi(boolean showUi) {
@@ -793,8 +920,39 @@
             }
 
             /**
-             * Sets the connectivity scope to be provided by this tethering downstream.
+             * Sets the name of the interface. Currently supported only for
+             * - {@link #TETHERING_VIRTUAL}.
+             * - {@link #TETHERING_WIFI} (for Local-only Hotspot)
+             * - {@link #TETHERING_WIFI_P2P}
+             * @hide
              */
+            @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+            @RequiresPermission(anyOf = {
+                    android.Manifest.permission.NETWORK_SETTINGS,
+                    android.Manifest.permission.NETWORK_STACK,
+                    NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            })
+            @NonNull
+            @SystemApi(client = MODULE_LIBRARIES)
+            public Builder setInterfaceName(@Nullable final String interfaceName) {
+                switch (mBuilderParcel.tetheringType) {
+                    case TETHERING_VIRTUAL:
+                    case TETHERING_WIFI_P2P:
+                    case TETHERING_WIFI:
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Interface name cannot be set for"
+                                + " tethering type " + interfaceName);
+                }
+                mBuilderParcel.interfaceName = interfaceName;
+                return this;
+            }
+
+            /**
+             * Sets the connectivity scope to be provided by this tethering downstream.
+             * @hide
+             */
+            @SystemApi
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setConnectivityScope(@ConnectivityScope int scope) {
@@ -814,11 +972,13 @@
              * If TETHERING_WIFI is already enabled and a new request is made with a different
              * SoftApConfiguration, the request will be accepted if the device can support an
              * additional tethering Wi-Fi AP interface. Otherwise, the request will be rejected.
+             * </p>
+             * Non-system callers using TETHERING_WIFI must specify a SoftApConfiguration.
              *
              * @param softApConfig SoftApConfiguration to use.
              * @throws IllegalArgumentException if the tethering type isn't TETHERING_WIFI.
              */
-            @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+            @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setSoftApConfiguration(@Nullable SoftApConfiguration softApConfig) {
@@ -832,6 +992,7 @@
 
             /** Build {@link TetheringRequest} with the currently set configuration. */
             @NonNull
+            @SuppressLint("UnflaggedApi")
             public TetheringRequest build() {
                 return new TetheringRequest(mBuilderParcel);
             }
@@ -840,7 +1001,9 @@
         /**
          * Get the local IPv4 address, if one was configured with
          * {@link Builder#setStaticIpv4Addresses}.
+         * @hide
          */
+        @SystemApi
         @Nullable
         public LinkAddress getLocalIpv4Address() {
             return mRequestParcel.localIPv4Address;
@@ -849,35 +1012,64 @@
         /**
          * Get the static IPv4 address of the client, if one was configured with
          * {@link Builder#setStaticIpv4Addresses}.
+         * @hide
          */
+        @SystemApi
         @Nullable
         public LinkAddress getClientStaticIpv4Address() {
             return mRequestParcel.staticClientAddress;
         }
 
-        /** Get tethering type. */
+        /**
+         * Get tethering type.
+         * @hide
+         */
+        @SystemApi
         @TetheringType
         public int getTetheringType() {
             return mRequestParcel.tetheringType;
         }
 
-        /** Get connectivity type */
+        /**
+         * Get connectivity type
+         * @hide
+         */
+        @SystemApi
         @ConnectivityScope
         public int getConnectivityScope() {
             return mRequestParcel.connectivityScope;
         }
 
-        /** Check if exempt from entitlement check. */
+        /**
+         * Check if exempt from entitlement check.
+         * @hide
+         */
+        @SystemApi
         public boolean isExemptFromEntitlementCheck() {
             return mRequestParcel.exemptFromEntitlementCheck;
         }
 
-        /** Check if show entitlement ui.  */
+        /**
+         * Check if show entitlement ui.
+         * @hide
+         */
+        @SystemApi
         public boolean getShouldShowEntitlementUi() {
             return mRequestParcel.showProvisioningUi;
         }
 
         /**
+         * Get interface name.
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+        @Nullable
+        @SystemApi(client = MODULE_LIBRARIES)
+        public String getInterfaceName() {
+            return mRequestParcel.interfaceName;
+        }
+
+        /**
          * Check whether the two addresses are ipv4 and in the same prefix.
          * @hide
          */
@@ -913,13 +1105,54 @@
         /**
          * Get the desired SoftApConfiguration of the request, if one was specified.
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Nullable
         public SoftApConfiguration getSoftApConfiguration() {
             return mRequestParcel.softApConfig;
         }
 
         /**
+         * Sets the UID of the app that sent this request. This should always be overridden when
+         * receiving TetheringRequest from an external source.
+         * @hide
+         */
+        public void setUid(int uid) {
+            mRequestParcel.uid = uid;
+        }
+
+        /**
+         * Sets the package name of the app that sent this request. This should always be overridden
+         * when receiving a TetheringRequest from an external source.
+         * @hide
+         */
+        public void setPackageName(String packageName) {
+            mRequestParcel.packageName = packageName;
+        }
+
+        /**
+         * Gets the UID of the app that sent this request. This defaults to
+         * {@link Process#INVALID_UID} if unset.
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+        @SystemApi(client = MODULE_LIBRARIES)
+        public int getUid() {
+            return mRequestParcel.uid;
+        }
+
+        /**
+         * Gets the package name of the app that sent this request. This defaults to {@code null} if
+         * unset.
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+        @SystemApi(client = MODULE_LIBRARIES)
+        @Nullable
+        public String getPackageName() {
+            return mRequestParcel.packageName;
+        }
+
+        /**
          * Get a TetheringRequestParcel from the configuration
          * @hide
          */
@@ -927,21 +1160,61 @@
             return mRequestParcel;
         }
 
-        /** String of TetheringRequest detail. */
+        /**
+         * String of TetheringRequest detail.
+         * @hide
+         */
+        @SystemApi
         public String toString() {
-            return "TetheringRequest [ type= " + mRequestParcel.tetheringType
-                    + ", localIPv4Address= " + mRequestParcel.localIPv4Address
-                    + ", staticClientAddress= " + mRequestParcel.staticClientAddress
-                    + ", exemptFromEntitlementCheck= " + mRequestParcel.exemptFromEntitlementCheck
-                    + ", showProvisioningUi= " + mRequestParcel.showProvisioningUi
-                    + ", softApConfig= " + mRequestParcel.softApConfig
-                    + " ]";
+            StringJoiner sj = new StringJoiner(", ", "TetheringRequest[ ", " ]");
+            sj.add(typeToString(mRequestParcel.tetheringType));
+            if (mRequestParcel.localIPv4Address != null) {
+                sj.add("localIpv4Address=" + mRequestParcel.localIPv4Address);
+            }
+            if (mRequestParcel.staticClientAddress != null) {
+                sj.add("staticClientAddress=" + mRequestParcel.staticClientAddress);
+            }
+            if (mRequestParcel.exemptFromEntitlementCheck) {
+                sj.add("exemptFromEntitlementCheck");
+            }
+            if (mRequestParcel.showProvisioningUi) {
+                sj.add("showProvisioningUi");
+            }
+            sj.add(connectivityScopeToString(mRequestParcel.connectivityScope));
+            if (mRequestParcel.softApConfig != null) {
+                sj.add("softApConfig=" + mRequestParcel.softApConfig);
+            }
+            if (mRequestParcel.uid != Process.INVALID_UID) {
+                sj.add("uid=" + mRequestParcel.uid);
+            }
+            if (mRequestParcel.packageName != null) {
+                sj.add("packageName=" + mRequestParcel.packageName);
+            }
+            if (mRequestParcel.interfaceName != null) {
+                sj.add("interfaceName=" + mRequestParcel.interfaceName);
+            }
+            return sj.toString();
         }
 
+        /**
+         * @hide
+         */
+        @SystemApi
         @Override
         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.uid == otherParcel.uid
+                    && Objects.equals(parcel.packageName, otherParcel.packageName);
+        }
+
+        /**
+         * @hide
+         */
+        public boolean equalsIgnoreUidPackage(TetheringRequest otherRequest) {
             TetheringRequestParcel parcel = getParcel();
             TetheringRequestParcel otherParcel = otherRequest.getParcel();
             return parcel.tetheringType == otherParcel.tetheringType
@@ -950,25 +1223,33 @@
                     && parcel.exemptFromEntitlementCheck == otherParcel.exemptFromEntitlementCheck
                     && parcel.showProvisioningUi == otherParcel.showProvisioningUi
                     && parcel.connectivityScope == otherParcel.connectivityScope
-                    && Objects.equals(parcel.softApConfig, otherParcel.softApConfig);
+                    && Objects.equals(parcel.softApConfig, otherParcel.softApConfig)
+                    && Objects.equals(parcel.interfaceName, otherParcel.interfaceName);
         }
 
+        /**
+         * @hide
+         */
+        @SystemApi
         @Override
         public int hashCode() {
             TetheringRequestParcel parcel = getParcel();
             return Objects.hash(parcel.tetheringType, parcel.localIPv4Address,
                     parcel.staticClientAddress, parcel.exemptFromEntitlementCheck,
-                    parcel.showProvisioningUi, parcel.connectivityScope, parcel.softApConfig);
+                    parcel.showProvisioningUi, parcel.connectivityScope, parcel.softApConfig,
+                    parcel.uid, parcel.packageName, parcel.interfaceName);
         }
     }
 
     /**
      * Callback for use with {@link #startTethering} to find out whether tethering succeeded.
      */
+    @SuppressLint("UnflaggedApi")
     public interface StartTetheringCallback {
         /**
          * Called when tethering has been successfully started.
          */
+        @SuppressLint("UnflaggedApi")
         default void onTetheringStarted() {}
 
         /**
@@ -976,26 +1257,40 @@
          *
          * @param error The error that caused the failure.
          */
+        @SuppressLint("UnflaggedApi")
         default void onTetheringFailed(@StartTetheringError final int error) {}
     }
 
     /**
+     * Callback for use with {@link #stopTethering} to find out whether stop tethering succeeded.
+     */
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    public interface StopTetheringCallback {
+        /**
+         * Called when tethering has been successfully stopped.
+         */
+        default void onStopTetheringSucceeded() {}
+
+        /**
+         * Called when starting tethering failed.
+         *
+         * @param error The error that caused the failure.
+         */
+        default void onStopTetheringFailed(@StopTetheringError final int error) {}
+    }
+
+    /**
      * 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 request a {@link TetheringRequest} which can specify the preferred configuration.
      * @param executor {@link Executor} to specify the thread upon which the callback of
      *         TetheringRequest will be invoked.
      * @param callback A callback that will be called to indicate the success status of the
      *                 tethering start request.
      */
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @RequiresPermission(value = android.Manifest.permission.TETHER_PRIVILEGED, conditional = true)
+    @SuppressLint("UnflaggedApi")
     public void startTethering(@NonNull final TetheringRequest request,
             @NonNull final Executor executor, @NonNull final StartTetheringCallback callback) {
         final String callerPkg = mContext.getOpPackageName();
@@ -1021,18 +1316,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) {
@@ -1043,13 +1332,10 @@
      * 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();
         Log.i(TAG, "stopTethering caller:" + callerPkg);
@@ -1067,9 +1353,22 @@
     }
 
     /**
+     * Stops tethering for the given request. Operation will fail with
+     * {@link #TETHER_ERROR_UNKNOWN_REQUEST} if there is no request that matches it.
+     */
+    @RequiresPermission(value = android.Manifest.permission.TETHER_PRIVILEGED, conditional = true)
+    @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();
+    }
+
+    /**
      * Callback for use with {@link #getLatestTetheringEntitlementResult} to find out whether
      * entitlement succeeded.
+     * @hide
      */
+    @SystemApi
     public interface OnTetheringEntitlementResultListener  {
         /**
          * Called to notify entitlement result.
@@ -1090,9 +1389,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.
@@ -1100,11 +1396,10 @@
      * @param listener an {@link OnTetheringEntitlementResultListener} which will be called to
      *         notify the caller of the result of entitlement check. The listener may be called zero
      *         or one time.
+     * @hide
      */
-    @RequiresPermission(anyOf = {
-            android.Manifest.permission.TETHER_PRIVILEGED,
-            android.Manifest.permission.WRITE_SETTINGS
-    })
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     public void requestLatestTetheringEntitlementResult(@TetheringType int type,
             boolean showEntitlementUi,
             @NonNull Executor executor,
@@ -1148,6 +1443,7 @@
      * Callback for use with {@link registerTetheringEventCallback} to find out tethering
      * upstream status.
      */
+    @SuppressLint("UnflaggedApi")
     public interface TetheringEventCallback {
         /**
          * Called when tethering supported status changed.
@@ -1159,7 +1455,9 @@
          * policy restrictions.
          *
          * @param supported whether any tethering type is supported.
+         * @hide
          */
+        @SystemApi
         default void onTetheringSupported(boolean supported) {}
 
         /**
@@ -1184,7 +1482,9 @@
          *
          * @param network the {@link Network} of tethering upstream. Null means tethering doesn't
          * have any upstream.
+         * @hide
          */
+        @SystemApi
         default void onUpstreamChanged(@Nullable Network network) {}
 
         /**
@@ -1211,7 +1511,9 @@
          * <p>This will be called immediately after the callback is registered, and may be called
          * multiple times later upon changes.
          * @param interfaces The list of tetherable interface names.
+         * @hide
          */
+        @SystemApi
         default void onTetherableInterfacesChanged(@NonNull List<String> interfaces) {}
 
         /**
@@ -1221,7 +1523,9 @@
          * <p>This will be called immediately after the callback is registered, and may be called
          * multiple times later upon changes.
          * @param interfaces The set of TetheringInterface of currently tetherable interface.
+         * @hide
          */
+        @SystemApi
         default void onTetherableInterfacesChanged(@NonNull Set<TetheringInterface> interfaces) {
             // By default, the new callback calls the old callback, so apps
             // implementing the old callback just work.
@@ -1234,7 +1538,9 @@
          * <p>This will be called immediately after the callback is registered, and may be called
          * multiple times later upon changes.
          * @param interfaces The lit of 0 or more String of currently tethered interface names.
+         * @hide
          */
+        @SystemApi
         default void onTetheredInterfacesChanged(@NonNull List<String> interfaces) {}
 
         /**
@@ -1245,6 +1551,7 @@
          * @param interfaces The set of 0 or more TetheringInterface of currently tethered
          * interface.
          */
+        @SuppressLint("UnflaggedApi")
         default void onTetheredInterfacesChanged(@NonNull Set<TetheringInterface> interfaces) {
             // By default, the new callback calls the old callback, so apps
             // implementing the old callback just work.
@@ -1257,7 +1564,9 @@
          * <p>This will be called immediately after the callback is registered, and may be called
          * multiple times later upon changes.
          * @param interfaces The list of 0 or more String of active local-only interface names.
+         * @hide
          */
+        @SystemApi
         default void onLocalOnlyInterfacesChanged(@NonNull List<String> interfaces) {}
 
         /**
@@ -1267,7 +1576,9 @@
          * multiple times later upon changes.
          * @param interfaces The set of 0 or more TetheringInterface of active local-only
          * interface.
+         * @hide
          */
+        @SystemApi
         default void onLocalOnlyInterfacesChanged(@NonNull Set<TetheringInterface> interfaces) {
             // By default, the new callback calls the old callback, so apps
             // implementing the old callback just work.
@@ -1281,7 +1592,9 @@
          * on the interface is an error, and may be called multiple times later upon changes.
          * @param ifName Name of the interface.
          * @param error One of {@code TetheringManager#TETHER_ERROR_*}.
+         * @hide
          */
+        @SystemApi
         default void onError(@NonNull String ifName, @TetheringIfaceError int error) {}
 
         /**
@@ -1291,7 +1604,9 @@
          * on the interface is an error, and may be called multiple times later upon changes.
          * @param iface The interface that experienced the error.
          * @param error One of {@code TetheringManager#TETHER_ERROR_*}.
+         * @hide
          */
+        @SystemApi
         default void onError(@NonNull TetheringInterface iface, @TetheringIfaceError int error) {
             // By default, the new callback calls the old callback, so apps
             // implementing the old callback just work.
@@ -1308,7 +1623,9 @@
          * clients may still be reported by this callback after disconnection as the system cannot
          * determine if they are still connected.
          * @param clients The new set of tethered clients; the collection is not ordered.
+         * @hide
          */
+        @SystemApi
         default void onClientsChanged(@NonNull Collection<TetheredClient> clients) {}
 
         /**
@@ -1316,7 +1633,9 @@
          *
          * <p>This will be called immediately after the callback is registered.
          * @param status The offload status.
+         * @hide
          */
+        @SystemApi
         default void onOffloadStatusChanged(@TetherOffloadStatus int status) {}
     }
 
@@ -1410,6 +1729,7 @@
      * @param callback the callback to be called when tethering has change events.
      */
     @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
+    @SuppressLint("UnflaggedApi")
     public void registerTetheringEventCallback(@NonNull Executor executor,
             @NonNull TetheringEventCallback callback) {
         Objects.requireNonNull(executor);
@@ -1568,6 +1888,7 @@
             Manifest.permission.TETHER_PRIVILEGED,
             Manifest.permission.ACCESS_NETWORK_STATE
     })
+    @SuppressLint("UnflaggedApi")
     public void unregisterTetheringEventCallback(@NonNull final TetheringEventCallback callback) {
         Objects.requireNonNull(callback);
 
@@ -1751,21 +2072,17 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
 
         return ret == TETHER_ERROR_NO_ERROR;
     }
 
     /**
      * Stop all active tethering.
-     *
-     * <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
-    })
+    @SystemApi
+    @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
     public void stopAllTethering() {
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "stopAllTethering caller:" + callerPkg);
@@ -1800,6 +2117,6 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 }
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
index ea7a353..97c9b9a 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
@@ -31,4 +31,7 @@
     boolean showProvisioningUi;
     int connectivityScope;
     SoftApConfiguration softApConfig;
+    int uid;
+    String packageName;
+    String interfaceName;
 }
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 b807544..6229f6d 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -16,9 +16,16 @@
 
 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;
@@ -45,7 +52,6 @@
 import android.net.MacAddress;
 import android.net.RouteInfo;
 import android.net.TetheredClient;
-import android.net.TetheringManager;
 import android.net.TetheringManager.TetheringRequest;
 import android.net.dhcp.DhcpLeaseParcelable;
 import android.net.dhcp.DhcpServerCallbacks;
@@ -69,6 +75,7 @@
 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.IIpv4PrefixRequest;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
@@ -76,7 +83,6 @@
 import com.android.net.module.util.SyncStateMachine.StateInfo;
 import com.android.net.module.util.ip.InterfaceController;
 import com.android.networkstack.tethering.BpfCoordinator;
-import com.android.networkstack.tethering.PrivateAddressCoordinator;
 import com.android.networkstack.tethering.TetheringConfiguration;
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
@@ -124,6 +130,8 @@
     // TODO: have PanService use some visible version of this constant
     private static final String BLUETOOTH_IFACE_ADDR = "192.168.44.1/24";
 
+    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
+
     // TODO: have this configurable
     private static final int DHCP_LEASE_TIME_SECS = 3600;
 
@@ -240,15 +248,17 @@
     private final BpfCoordinator mBpfCoordinator;
     @NonNull
     private final RoutingCoordinatorManager mRoutingCoordinator;
+    @NonNull
+    private final IIpv4PrefixRequest mIpv4PrefixRequest;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
-    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
 
     private final String mIfaceName;
     private final int mInterfaceType;
     private final LinkProperties mLinkProperties;
     private final boolean mUsingLegacyDhcp;
     private final int mP2pLeasesSubnetPrefixLength;
+    private final boolean mIsWifiP2pDedicatedIpEnabled;
 
     private final Dependencies mDeps;
 
@@ -289,6 +299,9 @@
 
     private LinkAddress mIpv4Address;
 
+    @Nullable
+    private TetheringRequest mTetheringRequest;
+
     private final TetheringMetrics mTetheringMetrics;
     private final Handler mHandler;
 
@@ -298,7 +311,7 @@
             String ifaceName, Handler handler, int interfaceType, SharedLog log,
             INetd netd, @NonNull BpfCoordinator bpfCoordinator,
             RoutingCoordinatorManager routingCoordinatorManager, Callback callback,
-            TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
+            TetheringConfiguration config,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
         super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
         mHandler = handler;
@@ -306,6 +319,12 @@
         mNetd = netd;
         mBpfCoordinator = bpfCoordinator;
         mRoutingCoordinator = routingCoordinatorManager;
+        mIpv4PrefixRequest = new IIpv4PrefixRequest.Stub() {
+            @Override
+            public void onIpv4PrefixConflict(IpPrefix ipPrefix) throws RemoteException {
+                sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+            }
+        };
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -313,7 +332,7 @@
         mLinkProperties = new LinkProperties();
         mUsingLegacyDhcp = config.useLegacyDhcpServer();
         mP2pLeasesSubnetPrefixLength = config.getP2pLeasesSubnetPrefixLength();
-        mPrivateAddressCoordinator = addressCoordinator;
+        mIsWifiP2pDedicatedIpEnabled = config.shouldEnableWifiP2pDedicatedIp();
         mDeps = deps;
         mTetheringMetrics = tetheringMetrics;
         resetLinkProperties();
@@ -391,6 +410,17 @@
         return mInterfaceParams;
     }
 
+    @VisibleForTesting
+    public IIpv4PrefixRequest getIpv4PrefixRequest() {
+        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.
@@ -565,8 +595,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()
@@ -639,7 +669,7 @@
         // NOTE: All of configureIPv4() will be refactored out of existence
         // into calls to InterfaceController, shared with startIPv4().
         mInterfaceCtrl.clearIPv4Address();
-        mPrivateAddressCoordinator.releaseDownstream(this);
+        mRoutingCoordinator.releaseDownstream(mIpv4PrefixRequest);
         mBpfCoordinator.tetherOffloadClientClear(this);
         mIpv4Address = null;
         mStaticIpv4ServerAddr = null;
@@ -666,10 +696,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.
@@ -695,7 +725,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 == TETHERING_WIFI_P2P;
     }
 
     private LinkAddress requestIpv4Address(final int scope, final boolean useLastAddress) {
@@ -703,7 +738,14 @@
 
         if (shouldNotConfigureBluetoothInterface()) return new LinkAddress(BLUETOOTH_IFACE_ADDR);
 
-        return mPrivateAddressCoordinator.requestDownstreamAddress(this, scope, useLastAddress);
+        if (shouldUseWifiP2pDedicatedIp()) return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
+
+        if (useLastAddress) {
+            return mRoutingCoordinator.requestStickyDownstreamAddress(mInterfaceType, scope,
+                    mIpv4PrefixRequest);
+        }
+
+        return mRoutingCoordinator.requestDownstreamAddress(mIpv4PrefixRequest);
     }
 
     private boolean startIPv6() {
@@ -808,12 +850,13 @@
         }
     }
 
-    private void removeRoutesFromLocalNetwork(@NonNull final List<RouteInfo> toBeRemoved) {
-        final int removalFailures = NetdUtils.removeRoutesFromLocalNetwork(
-                mNetd, toBeRemoved);
+    private void removeRoutesFromNetworkAndLinkProperties(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);
@@ -843,14 +886,15 @@
         }
     }
 
-    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+    private void addRoutesToNetworkAndLinkProperties(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;
@@ -863,7 +907,8 @@
             ArraySet<IpPrefix> deprecatedPrefixes, ArraySet<IpPrefix> newPrefixes) {
         // [1] Remove the routes that are deprecated.
         if (!deprecatedPrefixes.isEmpty()) {
-            removeRoutesFromLocalNetwork(getLocalRoutesFor(mIfaceName, deprecatedPrefixes));
+            removeRoutesFromNetworkAndLinkProperties(LOCAL_NET_ID,
+                    getLocalRoutesFor(mIfaceName, deprecatedPrefixes));
         }
 
         // [2] Add only the routes that have not previously been added.
@@ -874,7 +919,8 @@
             }
 
             if (!addedPrefixes.isEmpty()) {
-                addRoutesToLocalNetwork(getLocalRoutesFor(mIfaceName, addedPrefixes));
+                addRoutesToNetworkAndLinkProperties(LOCAL_NET_ID,
+                        getLocalRoutesFor(mIfaceName, addedPrefixes));
             }
         }
     }
@@ -1006,6 +1052,7 @@
             switch (message.what) {
                 case CMD_TETHER_REQUESTED:
                     mLastError = TETHER_ERROR_NO_ERROR;
+                    mTetheringRequest = (TetheringRequest) message.obj;
                     switch (message.arg1) {
                         case STATE_LOCAL_ONLY:
                             maybeConfigureStaticIp((TetheringRequest) message.obj);
@@ -1077,7 +1124,8 @@
             }
 
             try {
-                NetdUtils.tetherInterface(mNetd, mIfaceName, asIpPrefix(mIpv4Address));
+                NetdUtils.tetherInterface(mNetd, LOCAL_NET_ID, mIfaceName,
+                        asIpPrefix(mIpv4Address));
             } catch (RemoteException | ServiceSpecificException | IllegalStateException e) {
                 mLog.e("Error Tethering", e);
                 mLastError = TETHER_ERROR_TETHER_IFACE_ERROR;
@@ -1099,7 +1147,7 @@
             stopIPv6();
 
             try {
-                NetdUtils.untetherInterface(mNetd, mIfaceName);
+                NetdUtils.untetherInterface(mNetd, LOCAL_NET_ID, mIfaceName);
             } catch (RemoteException | ServiceSpecificException e) {
                 mLastError = TETHER_ERROR_UNTETHER_IFACE_ERROR;
                 mLog.e("Failed to untether interface: " + e);
@@ -1148,6 +1196,7 @@
                 case CMD_SERVICE_FAILED_TO_START:
                     mLog.e("start serving fail, error: " + message.arg1);
                     transitionTo(mInitialState);
+                    break;
                 default:
                     return false;
             }
@@ -1175,14 +1224,14 @@
                 return;
             }
 
-            // Remove deprecated routes from local network.
-            removeRoutesFromLocalNetwork(
-                    Collections.singletonList(getDirectConnectedRoute(deprecatedLinkAddress)));
+            // Remove deprecated routes from downstream network.
+            removeRoutesFromNetworkAndLinkProperties(LOCAL_NET_ID,
+                    List.of(getDirectConnectedRoute(deprecatedLinkAddress)));
             mLinkProperties.removeLinkAddress(deprecatedLinkAddress);
 
-            // Add new routes to local network.
-            addRoutesToLocalNetwork(
-                    Collections.singletonList(getDirectConnectedRoute(mIpv4Address)));
+            // Add new routes to downstream network.
+            addRoutesToNetworkAndLinkProperties(LOCAL_NET_ID,
+                    List.of(getDirectConnectedRoute(mIpv4Address)));
             mLinkProperties.addLinkAddress(mIpv4Address);
 
             // Update local DNS caching server with new IPv4 address, otherwise, dnsmasq doesn't
@@ -1393,8 +1442,28 @@
         @Override
         public void enter() {
             mLastError = TETHER_ERROR_NO_ERROR;
+            // TODO: clean this up after the synchronous state machine is fully rolled out. Clean up
+            // can be directly triggered after calling IpServer.stop() inside Tethering.java.
             sendInterfaceState(STATE_UNAVAILABLE);
         }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_IPV6_TETHER_UPDATE:
+                    // sendInterfaceState(STATE_UNAVAILABLE) triggers
+                    // handleInterfaceServingStateInactive which in turn cleans up IPv6 tethering
+                    // (and calls into IpServer one more time). At this point, this is the only
+                    // message we potentially see in this state because this IpServer has already
+                    // been removed from mTetherStates before transitioning to this State; however,
+                    // handleInterfaceServiceStateInactive passes a reference.
+                    // TODO: This can be removed once SyncStateMachine is rolled out and the
+                    // teardown path is cleaned up.
+                    return true;
+                default:
+                    return false;
+            }
+        }
     }
 
     class WaitingForRestartState extends State {
diff --git a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
index 89e06da..75ab9ec 100644
--- a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
+++ b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
@@ -27,6 +27,7 @@
 import static android.system.OsConstants.ETH_P_IP;
 import static android.system.OsConstants.ETH_P_IPV6;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 import static com.android.net.module.util.NetworkStackConstants.IPV4_MIN_MTU;
 import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN;
 import static com.android.net.module.util.ip.ConntrackMonitor.ConntrackEvent;
@@ -334,7 +335,6 @@
     };
 
     // TODO: add BpfMap<TetherDownstream64Key, TetherDownstream64Value> retrieving function.
-    @VisibleForTesting
     public abstract static class Dependencies {
         /** Get handler. */
         @NonNull public abstract Handler getHandler();
@@ -585,14 +585,10 @@
             if (mHandler.hasCallbacks(mScheduledConntrackMetricsSampling)) {
                 mHandler.removeCallbacks(mScheduledConntrackMetricsSampling);
             }
-            final int currentCount = mBpfConntrackEventConsumer.getCurrentConnectionCount();
-            if (currentCount != 0) {
-                Log.wtf(TAG, "Unexpected CurrentConnectionCount: " + currentCount);
-            }
             // Avoid sending metrics when tethering is about to close.
             // This leads to a missing final sample before disconnect
             // but avoids possibly duplicating the last metric in the upload.
-            mBpfConntrackEventConsumer.clearConnectionCounters();
+            mBpfCoordinatorShim.clearConnectionCounters();
         }
         // Stop scheduled polling stats and poll the latest stats from BPF maps.
         if (mHandler.hasCallbacks(mScheduledPollingStats)) {
@@ -1091,10 +1087,6 @@
         for (final Tether4Key k : deleteDownstreamRuleKeys) {
             mBpfCoordinatorShim.tetherOffloadRuleRemove(DOWNSTREAM, k);
         }
-        if (mSupportActiveSessionsMetrics) {
-            mBpfConntrackEventConsumer.decreaseCurrentConnectionCount(
-                    deleteUpstreamRuleKeys.size());
-        }
 
         // Cleanup each upstream interface by a set which avoids duplicated work on the same
         // upstream interface. Cleaning up the same interface twice (or more) here may raise
@@ -1367,10 +1359,6 @@
 
         pw.println();
         pw.println("mSupportActiveSessionsMetrics: " + mSupportActiveSessionsMetrics);
-        pw.println("getLastMaxConnectionCount: "
-                + mBpfConntrackEventConsumer.getLastMaxConnectionCount());
-        pw.println("getCurrentConnectionCount: "
-                + mBpfConntrackEventConsumer.getCurrentConnectionCount());
     }
 
     private void dumpStats(@NonNull IndentingPrintWriter pw) {
@@ -2062,21 +2050,6 @@
     // while TCP status is established.
     @VisibleForTesting
     class BpfConntrackEventConsumer implements ConntrackEventConsumer {
-        /**
-         * Tracks the current number of tethering connections and the maximum
-         * observed since the last metrics collection. Used to provide insights
-         * into the distribution of active tethering sessions for metrics reporting.
-
-         * These variables are accessed on the handler thread, which includes:
-         *  1. ConntrackEvents signaling the addition or removal of an IPv4 rule.
-         *  2. ConntrackEvents indicating the removal of a tethering client,
-         *     triggering the removal of associated rules.
-         *  3. Removal of the last IpServer, which resets counters to handle
-         *     potential synchronization issues.
-         */
-        private int mLastMaxConnectionCount = 0;
-        private int mCurrentConnectionCount = 0;
-
         // The upstream4 and downstream4 rules are built as the following tables. Only raw ip
         // upstream interface is supported. Note that the field "lastUsed" is only updated by
         // BPF program which records the last used time for a given rule.
@@ -2210,10 +2183,6 @@
                     return;
                 }
 
-                if (mSupportActiveSessionsMetrics) {
-                    decreaseCurrentConnectionCount(1);
-                }
-
                 maybeClearLimit(upstreamIndex);
                 return;
             }
@@ -2237,40 +2206,12 @@
                         + ", downstream: " + addedDownstream + ")");
                 return;
             }
-            if (mSupportActiveSessionsMetrics && addedUpstream && addedDownstream) {
-                mCurrentConnectionCount++;
-                mLastMaxConnectionCount = Math.max(mCurrentConnectionCount,
-                        mLastMaxConnectionCount);
-            }
         }
+    }
 
-        public int getLastMaxConnectionAndResetToCurrent() {
-            final int ret = mLastMaxConnectionCount;
-            mLastMaxConnectionCount = mCurrentConnectionCount;
-            return ret;
-        }
-
-        /** For dumping current state only. */
-        public int getLastMaxConnectionCount() {
-            return mLastMaxConnectionCount;
-        }
-
-        public int getCurrentConnectionCount() {
-            return mCurrentConnectionCount;
-        }
-
-        public void decreaseCurrentConnectionCount(int count) {
-            mCurrentConnectionCount -= count;
-            if (mCurrentConnectionCount < 0) {
-                Log.wtf(TAG, "Unexpected mCurrentConnectionCount: "
-                        + mCurrentConnectionCount);
-            }
-        }
-
-        public void clearConnectionCounters() {
-            mCurrentConnectionCount = 0;
-            mLastMaxConnectionCount = 0;
-        }
+    @VisibleForTesting(visibility = PRIVATE)
+    public int getLastMaxConnectionAndResetToCurrent() {
+        return mBpfCoordinatorShim.getLastMaxConnectionAndResetToCurrent();
     }
 
     @VisibleForTesting
@@ -2611,7 +2552,7 @@
 
     private void uploadConntrackMetricsSample() {
         mDeps.sendTetheringActiveSessionsReported(
-                mBpfConntrackEventConsumer.getLastMaxConnectionAndResetToCurrent());
+                mBpfCoordinatorShim.getLastMaxConnectionAndResetToCurrent());
     }
 
     private void schedulePollingStats() {
diff --git a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
index b88b13b..a942166 100644
--- a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
+++ b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
@@ -33,9 +33,12 @@
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_PROVISIONING_FAILED;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 import static com.android.networkstack.apishim.ConstantsShim.ACTION_TETHER_UNSUPPORTED_CARRIER_UI;
 import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_NOT_EXPORTED;
 
+import android.annotation.NonNull;
+import android.app.ActivityManager;
 import android.app.AlarmManager;
 import android.app.PendingIntent;
 import android.content.BroadcastReceiver;
@@ -50,9 +53,13 @@
 import android.os.ResultReceiver;
 import android.os.SystemClock;
 import android.os.SystemProperties;
+import android.os.UserHandle;
+import android.os.UserManager;
 import android.provider.Settings;
 import android.util.SparseIntArray;
 
+import androidx.annotation.Nullable;
+
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.SharedLog;
@@ -85,7 +92,6 @@
     // Indicate tethering is not supported by carrier.
     private static final int TETHERING_PROVISIONING_CARRIER_UNSUPPORT = 1002;
 
-    private final ComponentName mSilentProvisioningService;
     private static final int MS_PER_HOUR = 60 * 60 * 1000;
     private static final int DUMP_TIMEOUT = 10_000;
 
@@ -109,9 +115,122 @@
     private boolean mNeedReRunProvisioningUi = false;
     private OnTetherProvisioningFailedListener mListener;
     private TetheringConfigurationFetcher mFetcher;
+    private final Dependencies mDeps;
+
+    @VisibleForTesting(visibility = PRIVATE)
+    static class Dependencies {
+        @NonNull
+        private final Context mContext;
+        @NonNull
+        private final SharedLog mLog;
+        private final ComponentName mSilentProvisioningService;
+
+        Dependencies(@NonNull Context context, @NonNull SharedLog log) {
+            mContext = context;
+            mLog = log;
+            mSilentProvisioningService = ComponentName.unflattenFromString(
+                    mContext.getResources().getString(R.string.config_wifi_tether_enable));
+        }
+
+        /**
+         * Run the UI-enabled tethering provisioning check.
+         * @param type tethering type from TetheringManager.TETHERING_{@code *}
+         * @param receiver to receive entitlement check result.
+         *
+         * @return the broadcast intent, or null if the current user is not allowed to
+         *         perform entitlement check.
+         */
+        @Nullable
+        protected Intent runUiTetherProvisioning(int type, final TetheringConfiguration config,
+                ResultReceiver receiver) {
+            if (DBG) mLog.i("runUiTetherProvisioning: " + type);
+
+            Intent intent = new Intent(Settings.ACTION_TETHER_PROVISIONING_UI);
+            intent.putExtra(EXTRA_ADD_TETHER_TYPE, type);
+            intent.putExtra(EXTRA_TETHER_UI_PROVISIONING_APP_NAME, config.provisioningApp);
+            intent.putExtra(EXTRA_PROVISION_CALLBACK, receiver);
+            intent.putExtra(EXTRA_TETHER_SUBID, config.activeDataSubId);
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+            // 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) {
+                // 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 UserManager userManager = userContext.getSystemService(UserManager.class);
+
+                if (userManager.isAdminUser()) {
+                    mContext.startActivityAsUser(intent, currentUser);
+                } else {
+                    mLog.e("Current user (" + currentUserId
+                            + ") is not allowed to perform entitlement check.");
+                    // If the user is not allowed to perform an entitlement check
+                    // (e.g., a non-admin user), notify the receiver immediately.
+                    // This is necessary because the entitlement check app cannot
+                    // be launched to conduct the check and deliver the results.
+                    receiver.send(TETHER_ERROR_PROVISIONING_FAILED, null);
+                    return null;
+                }
+            } else {
+                // For T- devices, there is no other admin user other than the system user.
+                mContext.startActivity(intent);
+            }
+            return intent;
+        }
+
+        /**
+         * Run no UI tethering provisioning check.
+         * @param type tethering type from TetheringManager.TETHERING_{@code *}
+         */
+        protected Intent runSilentTetherProvisioning(
+                int type, final TetheringConfiguration config, ResultReceiver receiver) {
+            if (DBG) mLog.i("runSilentTetherProvisioning: " + type);
+
+            Intent intent = new Intent();
+            intent.putExtra(EXTRA_ADD_TETHER_TYPE, type);
+            intent.putExtra(EXTRA_RUN_PROVISION, true);
+            intent.putExtra(EXTRA_TETHER_SILENT_PROVISIONING_ACTION, config.provisioningAppNoUi);
+            intent.putExtra(EXTRA_TETHER_PROVISIONING_RESPONSE, config.provisioningResponse);
+            intent.putExtra(EXTRA_PROVISION_CALLBACK, receiver);
+            intent.putExtra(EXTRA_TETHER_SUBID, config.activeDataSubId);
+            intent.setComponent(mSilentProvisioningService);
+            // Only admin user can change tethering and SilentTetherProvisioning don't need to
+            // show UI, it is fine to always start setting's background service as system user.
+            mContext.startService(intent);
+            return intent;
+        }
+
+        /**
+         * Create a PendingIntent for the provisioning recheck alarm.
+         * @param pkgName the package name of the PendingIntent.
+         */
+        PendingIntent createRecheckAlarmIntent(final String pkgName) {
+            final Intent intent = new Intent(ACTION_PROVISIONING_ALARM);
+            intent.setPackage(pkgName);
+            return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_IMMUTABLE);
+        }
+
+        /**
+         * Get the current user id.
+         */
+        int getCurrentUser() {
+            return ActivityManager.getCurrentUser();
+        }
+    }
 
     public EntitlementManager(Context ctx, Handler h, SharedLog log,
             Runnable callback) {
+        this(ctx, h, log, callback, new Dependencies(ctx, log));
+    }
+
+    @VisibleForTesting(visibility = PRIVATE)
+    EntitlementManager(Context ctx, Handler h, SharedLog log,
+            Runnable callback, @NonNull Dependencies deps) {
         mContext = ctx;
         mLog = log.forSubComponent(TAG);
         mCurrentDownstreams = new BitSet();
@@ -120,6 +239,7 @@
         mEntitlementCacheValue = new SparseIntArray();
         mPermissionChangeCallback = callback;
         mHandler = h;
+        mDeps = deps;
         if (SdkLevel.isAtLeastU()) {
             mContext.registerReceiver(mReceiver, new IntentFilter(ACTION_PROVISIONING_ALARM),
                     null, mHandler, RECEIVER_NOT_EXPORTED);
@@ -127,8 +247,6 @@
             mContext.registerReceiver(mReceiver, new IntentFilter(ACTION_PROVISIONING_ALARM),
                     null, mHandler);
         }
-        mSilentProvisioningService = ComponentName.unflattenFromString(
-                mContext.getResources().getString(R.string.config_wifi_tether_enable));
     }
 
     public void setOnTetherProvisioningFailedListener(
@@ -382,53 +500,6 @@
         }
     }
 
-    /**
-     * Run no UI tethering provisioning check.
-     * @param type tethering type from TetheringManager.TETHERING_{@code *}
-     * @param subId default data subscription ID.
-     */
-    @VisibleForTesting
-    protected Intent runSilentTetherProvisioning(
-            int type, final TetheringConfiguration config, ResultReceiver receiver) {
-        if (DBG) mLog.i("runSilentTetherProvisioning: " + type);
-
-        Intent intent = new Intent();
-        intent.putExtra(EXTRA_ADD_TETHER_TYPE, type);
-        intent.putExtra(EXTRA_RUN_PROVISION, true);
-        intent.putExtra(EXTRA_TETHER_SILENT_PROVISIONING_ACTION, config.provisioningAppNoUi);
-        intent.putExtra(EXTRA_TETHER_PROVISIONING_RESPONSE, config.provisioningResponse);
-        intent.putExtra(EXTRA_PROVISION_CALLBACK, receiver);
-        intent.putExtra(EXTRA_TETHER_SUBID, config.activeDataSubId);
-        intent.setComponent(mSilentProvisioningService);
-        // Only admin user can change tethering and SilentTetherProvisioning don't need to
-        // show UI, it is fine to always start setting's background service as system user.
-        mContext.startService(intent);
-        return intent;
-    }
-
-    /**
-     * Run the UI-enabled tethering provisioning check.
-     * @param type tethering type from TetheringManager.TETHERING_{@code *}
-     * @param subId default data subscription ID.
-     * @param receiver to receive entitlement check result.
-     */
-    @VisibleForTesting
-    protected Intent runUiTetherProvisioning(int type, final TetheringConfiguration config,
-            ResultReceiver receiver) {
-        if (DBG) mLog.i("runUiTetherProvisioning: " + type);
-
-        Intent intent = new Intent(Settings.ACTION_TETHER_PROVISIONING_UI);
-        intent.putExtra(EXTRA_ADD_TETHER_TYPE, type);
-        intent.putExtra(EXTRA_TETHER_UI_PROVISIONING_APP_NAME, config.provisioningApp);
-        intent.putExtra(EXTRA_PROVISION_CALLBACK, receiver);
-        intent.putExtra(EXTRA_TETHER_SUBID, config.activeDataSubId);
-        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-        // Only launch entitlement UI for system user. Entitlement UI should not appear for other
-        // user because only admin user is allowed to change tethering.
-        mContext.startActivity(intent);
-        return intent;
-    }
-
     private void runTetheringProvisioning(
             boolean showProvisioningUi, int downstreamType, final TetheringConfiguration config) {
         if (!config.isCarrierSupportTethering) {
@@ -442,9 +513,9 @@
         ResultReceiver receiver =
                 buildProxyReceiver(downstreamType, showProvisioningUi/* notifyFail */, null);
         if (showProvisioningUi) {
-            runUiTetherProvisioning(downstreamType, config, receiver);
+            mDeps.runUiTetherProvisioning(downstreamType, config, receiver);
         } else {
-            runSilentTetherProvisioning(downstreamType, config, receiver);
+            mDeps.runSilentTetherProvisioning(downstreamType, config, receiver);
         }
     }
 
@@ -458,20 +529,13 @@
         mContext.startActivity(intent);
     }
 
-    @VisibleForTesting
-    PendingIntent createRecheckAlarmIntent(final String pkgName) {
-        final Intent intent = new Intent(ACTION_PROVISIONING_ALARM);
-        intent.setPackage(pkgName);
-        return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_IMMUTABLE);
-    }
-
     // Not needed to check if this don't run on the handler thread because it's private.
     private void scheduleProvisioningRecheck(final TetheringConfiguration config) {
         if (mProvisioningRecheckAlarm == null) {
             final int period = config.provisioningCheckPeriod;
             if (period <= 0) return;
 
-            mProvisioningRecheckAlarm = createRecheckAlarmIntent(mContext.getPackageName());
+            mProvisioningRecheckAlarm = mDeps.createRecheckAlarmIntent(mContext.getPackageName());
             AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(
                     Context.ALARM_SERVICE);
             long triggerAtMillis = SystemClock.elapsedRealtime() + (period * MS_PER_HOUR);
@@ -697,7 +761,7 @@
             receiver.send(cacheValue, null);
         } else {
             ResultReceiver proxy = buildProxyReceiver(downstream, false/* notifyFail */, receiver);
-            runUiTetherProvisioning(downstream, config, proxy);
+            mDeps.runUiTetherProvisioning(downstream, config, proxy);
         }
     }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java b/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
deleted file mode 100644
index 528991f..0000000
--- a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
+++ /dev/null
@@ -1,477 +0,0 @@
-/*
- * 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.
- */
-package com.android.networkstack.tethering;
-
-import static android.net.NetworkCapabilities.TRANSPORT_VPN;
-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_WIFI_P2P;
-
-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.Inet4AddressUtils.prefixLengthToV4NetmaskIntHTH;
-import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
-
-import static java.util.Arrays.asList;
-
-import android.content.Context;
-import android.net.ConnectivityManager;
-import android.net.IpPrefix;
-import android.net.LinkAddress;
-import android.net.Network;
-import android.net.ip.IpServer;
-import android.util.ArrayMap;
-import android.util.ArraySet;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.util.IndentingPrintWriter;
-
-import java.net.Inet4Address;
-import java.net.InetAddress;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Random;
-import java.util.Set;
-
-/**
- * This class coordinate IP addresses conflict problem.
- *
- * Tethering downstream IP addresses may conflict with network assigned addresses. This
- * coordinator is responsible for recording all of network assigned addresses and dispatched
- * free address to downstream interfaces.
- *
- * This class is not thread-safe and should be accessed on the same tethering internal thread.
- * @hide
- */
-public class PrivateAddressCoordinator {
-    public static final int PREFIX_LENGTH = 24;
-
-    // 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
-    // when tethering is down. Instead tethering would remove all deprecated upstreams from
-    // mUpstreamPrefixMap when tethering is starting. See #maybeRemoveDeprecatedUpstreams().
-    private final ArrayMap<Network, List<IpPrefix>> mUpstreamPrefixMap;
-    private final ArraySet<IpServer> mDownstreams;
-    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
-    private static final String LEGACY_BLUETOOTH_IFACE_ADDRESS = "192.168.44.1/24";
-    private final List<IpPrefix> mTetheringPrefixes;
-    private final ConnectivityManager mConnectivityMgr;
-    private final TetheringConfiguration mConfig;
-    // keyed by downstream type(TetheringManager.TETHERING_*).
-    private final ArrayMap<AddressKey, LinkAddress> mCachedAddresses;
-    private final Random mRandom;
-
-    public PrivateAddressCoordinator(Context context, TetheringConfiguration config) {
-        mDownstreams = new ArraySet<>();
-        mUpstreamPrefixMap = new ArrayMap<>();
-        mConnectivityMgr = (ConnectivityManager) context.getSystemService(
-                Context.CONNECTIVITY_SERVICE);
-        mConfig = config;
-        mCachedAddresses = new ArrayMap<AddressKey, LinkAddress>();
-        // Reserved static addresses for bluetooth and wifi p2p.
-        mCachedAddresses.put(new AddressKey(TETHERING_BLUETOOTH, CONNECTIVITY_SCOPE_GLOBAL),
-                new LinkAddress(LEGACY_BLUETOOTH_IFACE_ADDRESS));
-        mCachedAddresses.put(new AddressKey(TETHERING_WIFI_P2P, CONNECTIVITY_SCOPE_LOCAL),
-                new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS));
-
-        mTetheringPrefixes = new ArrayList<>(Arrays.asList(new IpPrefix("192.168.0.0/16"),
-            new IpPrefix("172.16.0.0/12"), new IpPrefix("10.0.0.0/8")));
-        mRandom = new Random();
-    }
-
-    /**
-     * Record a new upstream IpPrefix which may conflict with tethering downstreams.
-     * The downstreams will be notified if a conflict is found. When updateUpstreamPrefix is called,
-     * UpstreamNetworkState must have an already populated LinkProperties.
-     */
-    public void updateUpstreamPrefix(final UpstreamNetworkState ns) {
-        // Do not support VPN as upstream. Normally, networkCapabilities is not expected to be null,
-        // but just checking to be sure.
-        if (ns.networkCapabilities != null && ns.networkCapabilities.hasTransport(TRANSPORT_VPN)) {
-            removeUpstreamPrefix(ns.network);
-            return;
-        }
-
-        final ArrayList<IpPrefix> ipv4Prefixes = getIpv4Prefixes(
-                ns.linkProperties.getAllLinkAddresses());
-        if (ipv4Prefixes.isEmpty()) {
-            removeUpstreamPrefix(ns.network);
-            return;
-        }
-
-        mUpstreamPrefixMap.put(ns.network, ipv4Prefixes);
-        handleMaybePrefixConflict(ipv4Prefixes);
-    }
-
-    private ArrayList<IpPrefix> getIpv4Prefixes(final List<LinkAddress> linkAddresses) {
-        final ArrayList<IpPrefix> list = new ArrayList<>();
-        for (LinkAddress address : linkAddresses) {
-            if (!address.isIpv4()) continue;
-
-            list.add(asIpPrefix(address));
-        }
-
-        return list;
-    }
-
-    private void handleMaybePrefixConflict(final List<IpPrefix> prefixes) {
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
-
-            for (IpPrefix source : prefixes) {
-                if (isConflictPrefix(source, target)) {
-                    downstream.sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-                    break;
-                }
-            }
-        }
-    }
-
-    /** Remove IpPrefix records corresponding to input network. */
-    public void removeUpstreamPrefix(final Network network) {
-        mUpstreamPrefixMap.remove(network);
-    }
-
-    /**
-     * Maybe remove deprecated upstream records, this would be called once tethering started without
-     * any exiting tethered downstream.
-     */
-    public void maybeRemoveDeprecatedUpstreams() {
-        if (mUpstreamPrefixMap.isEmpty()) return;
-
-        // Remove all upstreams that are no longer valid networks
-        final Set<Network> toBeRemoved = new HashSet<>(mUpstreamPrefixMap.keySet());
-        toBeRemoved.removeAll(asList(mConnectivityMgr.getAllNetworks()));
-
-        mUpstreamPrefixMap.removeAll(toBeRemoved);
-    }
-
-    /**
-     * Pick a random available address and mark its prefix as in use for the provided IpServer,
-     * returns null if there is no available address.
-     */
-    @Nullable
-    public LinkAddress requestDownstreamAddress(final IpServer ipServer, final int scope,
-            boolean useLastAddress) {
-        if (mConfig.shouldEnableWifiP2pDedicatedIp()
-                && ipServer.interfaceType() == TETHERING_WIFI_P2P) {
-            return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
-        }
-
-        final AddressKey addrKey = new AddressKey(ipServer.interfaceType(), scope);
-        // This ensures that tethering isn't started on 2 different interfaces with the same type.
-        // Once tethering could support multiple interface with the same type,
-        // TetheringSoftApCallback would need to handle it among others.
-        final LinkAddress cachedAddress = mCachedAddresses.get(addrKey);
-        if (useLastAddress && cachedAddress != null
-                && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
-            mDownstreams.add(ipServer);
-            return cachedAddress;
-        }
-
-        final int prefixIndex = getStartedPrefixIndex();
-        for (int i = 0; i < mTetheringPrefixes.size(); i++) {
-            final IpPrefix prefixRange = mTetheringPrefixes.get(
-                    (prefixIndex + i) % mTetheringPrefixes.size());
-            final LinkAddress newAddress = chooseDownstreamAddress(prefixRange);
-            if (newAddress != null) {
-                mDownstreams.add(ipServer);
-                mCachedAddresses.put(addrKey, newAddress);
-                return newAddress;
-            }
-        }
-
-        // No available address.
-        return null;
-    }
-
-    private int getStartedPrefixIndex() {
-        if (!mConfig.isRandomPrefixBaseEnabled()) return 0;
-
-        final int random = getRandomInt() & 0xffffff;
-        // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
-        // LinkAddress. To avoid complex operations in the selection logic and make the selected
-        // rate approximate consistency with that /8 is around 2^4 times of /12 and /12 is around
-        // 2^4 times of /16, we simply define a map between the value and the prefix value like
-        // this:
-        //
-        // Value 0 ~ 0xffff (65536/16777216 = 0.39%) -> 192.168.0.0/16
-        // Value 0x10000 ~ 0xfffff (983040/16777216 = 5.86%) -> 172.16.0.0/12
-        // Value 0x100000 ~ 0xffffff (15728640/16777216 = 93.7%) -> 10.0.0.0/8
-        if (random > 0xfffff) {
-            return 2;
-        } else if (random > 0xffff) {
-            return 1;
-        } else {
-            return 0;
-        }
-    }
-
-    private int getPrefixBaseAddress(final IpPrefix prefix) {
-        return inet4AddressToIntHTH((Inet4Address) prefix.getAddress());
-    }
-
-    /**
-     * Check whether input prefix conflict with upstream prefixes or in-use downstream prefixes.
-     * If yes, return one of them.
-     */
-    private IpPrefix getConflictPrefix(final IpPrefix prefix) {
-        final IpPrefix upstream = getConflictWithUpstream(prefix);
-        if (upstream != null) return upstream;
-
-        return getInUseDownstreamPrefix(prefix);
-    }
-
-    // Get the next non-conflict sub prefix. E.g: To get next sub prefix from 10.0.0.0/8, if the
-    // previously selected prefix is 10.20.42.0/24(subPrefix: 0.20.42.0) and the conflicting prefix
-    // is 10.16.0.0/20 (10.16.0.0 ~ 10.16.15.255), then the max address under subPrefix is
-    // 0.16.15.255 and the next subPrefix is 0.16.16.255/24 (0.16.15.255 + 0.0.1.0).
-    // Note: the sub address 0.0.0.255 here is fine to be any value that it will be replaced as
-    // selected random sub address later.
-    private int getNextSubPrefix(final IpPrefix conflictPrefix, final int prefixRangeMask) {
-        final int suffixMask = ~prefixLengthToV4NetmaskIntHTH(conflictPrefix.getPrefixLength());
-        // The largest offset within the prefix assignment block that still conflicts with
-        // conflictPrefix.
-        final int maxConflict =
-                (getPrefixBaseAddress(conflictPrefix) | suffixMask) & ~prefixRangeMask;
-
-        final int prefixMask = prefixLengthToV4NetmaskIntHTH(PREFIX_LENGTH);
-        // Pick a sub prefix a full prefix (1 << (32 - PREFIX_LENGTH) addresses) greater than
-        // maxConflict. This ensures that the selected prefix never overlaps with conflictPrefix.
-        // There is no need to mask the result with PREFIX_LENGTH bits because this is done by
-        // findAvailablePrefixFromRange when it constructs the prefix.
-        return maxConflict + (1 << (32 - PREFIX_LENGTH));
-    }
-
-    private LinkAddress chooseDownstreamAddress(final IpPrefix prefixRange) {
-        // The netmask of the prefix assignment block (e.g., 0xfff00000 for 172.16.0.0/12).
-        final int prefixRangeMask = prefixLengthToV4NetmaskIntHTH(prefixRange.getPrefixLength());
-
-        // The zero address in the block (e.g., 0xac100000 for 172.16.0.0/12).
-        final int baseAddress = getPrefixBaseAddress(prefixRange);
-
-        // The subnet mask corresponding to PREFIX_LENGTH.
-        final int prefixMask = prefixLengthToV4NetmaskIntHTH(PREFIX_LENGTH);
-
-        // The offset within prefixRange of a randomly-selected prefix of length PREFIX_LENGTH.
-        // This may not be the prefix of the address returned by this method:
-        // - If it is already in use, the method will return an address in another prefix.
-        // - If all prefixes within prefixRange are in use, the method will return null. For
-        // example, for a /24 prefix within 172.26.0.0/12, this will be a multiple of 256 in
-        // [0, 1048576). In other words, a random 32-bit number with mask 0x000fff00.
-        //
-        // prefixRangeMask is required to ensure no wrapping. For example, consider:
-        // - prefixRange 127.0.0.0/8
-        // - randomPrefixStart 127.255.255.0
-        // - A conflicting prefix of 127.255.254.0/23
-        // In this case without prefixRangeMask, getNextSubPrefix would return 128.0.0.0, which
-        // means the "start < end" check in findAvailablePrefixFromRange would not reject the prefix
-        // because Java doesn't have unsigned integers, so 128.0.0.0 = 0x80000000 = -2147483648
-        // is less than 127.0.0.0 = 0x7f000000 = 2130706432.
-        //
-        // Additionally, it makes debug output easier to read by making the numbers smaller.
-        final int randomInt = getRandomInt();
-        final int randomPrefixStart = randomInt & ~prefixRangeMask & prefixMask;
-
-        // A random offset within the prefix. Used to determine the local address once the prefix
-        // is selected. It does not result in an IPv4 address ending in .0, .1, or .255
-        // For a PREFIX_LENGTH of 24, this is a number between 2 and 254.
-        final int subAddress = getSanitizedSubAddr(randomInt, ~prefixMask);
-
-        // Find a prefix length PREFIX_LENGTH between randomPrefixStart and the end of the block,
-        // such that the prefix does not conflict with any upstream.
-        IpPrefix downstreamPrefix = findAvailablePrefixFromRange(
-                 randomPrefixStart, (~prefixRangeMask) + 1, baseAddress, prefixRangeMask);
-        if (downstreamPrefix != null) return getLinkAddress(downstreamPrefix, subAddress);
-
-        // If that failed, do the same, but between 0 and randomPrefixStart.
-        downstreamPrefix = findAvailablePrefixFromRange(
-                0, randomPrefixStart, baseAddress, prefixRangeMask);
-
-        return getLinkAddress(downstreamPrefix, subAddress);
-    }
-
-    private LinkAddress getLinkAddress(final IpPrefix prefix, final int subAddress) {
-        if (prefix == null) return null;
-
-        final InetAddress address = intToInet4AddressHTH(getPrefixBaseAddress(prefix) | subAddress);
-        return new LinkAddress(address, PREFIX_LENGTH);
-    }
-
-    private IpPrefix findAvailablePrefixFromRange(final int start, final int end,
-            final int baseAddress, final int prefixRangeMask) {
-        int newSubPrefix = start;
-        while (newSubPrefix < end) {
-            final InetAddress address = intToInet4AddressHTH(baseAddress | newSubPrefix);
-            final IpPrefix prefix = new IpPrefix(address, PREFIX_LENGTH);
-
-            final IpPrefix conflictPrefix = getConflictPrefix(prefix);
-
-            if (conflictPrefix == null) return prefix;
-
-            newSubPrefix = getNextSubPrefix(conflictPrefix, prefixRangeMask);
-        }
-
-        return null;
-    }
-
-    /** Get random int which could be used to generate random address. */
-    @VisibleForTesting
-    public int getRandomInt() {
-        return mRandom.nextInt();
-    }
-
-    /** Get random subAddress and avoid selecting x.x.x.0, x.x.x.1 and x.x.x.255 address. */
-    private int getSanitizedSubAddr(final int randomInt, final int subAddrMask) {
-        final int randomSubAddr = randomInt & subAddrMask;
-        // If prefix length > 30, the selecting speace would be less than 4 which may be hard to
-        // avoid 3 consecutive address.
-        if (PREFIX_LENGTH > 30) return randomSubAddr;
-
-        // TODO: maybe it is not necessary to avoid .0, .1 and .255 address because tethering
-        // address would not be conflicted. This code only works because PREFIX_LENGTH is not longer
-        // than 24
-        final int candidate = randomSubAddr & 0xff;
-        if (candidate == 0 || candidate == 1 || candidate == 255) {
-            return (randomSubAddr & 0xfffffffc) + 2;
-        }
-
-        return randomSubAddr;
-    }
-
-    /** Release downstream record for IpServer. */
-    public void releaseDownstream(final IpServer ipServer) {
-        mDownstreams.remove(ipServer);
-    }
-
-    /** Clear current upstream prefixes records. */
-    public void clearUpstreamPrefixes() {
-        mUpstreamPrefixMap.clear();
-    }
-
-    private IpPrefix getConflictWithUpstream(final IpPrefix prefix) {
-        for (int i = 0; i < mUpstreamPrefixMap.size(); i++) {
-            final List<IpPrefix> list = mUpstreamPrefixMap.valueAt(i);
-            for (IpPrefix upstream : list) {
-                if (isConflictPrefix(prefix, upstream)) return upstream;
-            }
-        }
-        return null;
-    }
-
-    private boolean isConflictWithUpstream(final IpPrefix prefix) {
-        return getConflictWithUpstream(prefix) != null;
-    }
-
-    private boolean isConflictPrefix(final IpPrefix prefix1, final IpPrefix prefix2) {
-        if (prefix2.getPrefixLength() < prefix1.getPrefixLength()) {
-            return prefix2.contains(prefix1.getAddress());
-        }
-
-        return prefix1.contains(prefix2.getAddress());
-    }
-
-    // InUse Prefixes are prefixes of mCachedAddresses which are active downstream addresses, last
-    // downstream addresses(reserved for next time) and static addresses(e.g. bluetooth, wifi p2p).
-    private IpPrefix getInUseDownstreamPrefix(final IpPrefix prefix) {
-        for (int i = 0; i < mCachedAddresses.size(); i++) {
-            final IpPrefix downstream = asIpPrefix(mCachedAddresses.valueAt(i));
-            if (isConflictPrefix(prefix, downstream)) return downstream;
-        }
-
-        // IpServer may use manually-defined address (mStaticIpv4ServerAddr) which does not include
-        // in mCachedAddresses.
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
-
-            if (isConflictPrefix(prefix, target)) return target;
-        }
-
-        return null;
-    }
-
-    @NonNull
-    private IpPrefix getDownstreamPrefix(final IpServer downstream) {
-        final LinkAddress address = downstream.getAddress();
-
-        return asIpPrefix(address);
-    }
-
-    private static class AddressKey {
-        private final int mTetheringType;
-        private final int mScope;
-
-        private AddressKey(int type, int scope) {
-            mTetheringType = type;
-            mScope = scope;
-        }
-
-        @Override
-        public int hashCode() {
-            return (mTetheringType << 16) + mScope;
-        }
-
-        @Override
-        public boolean equals(@Nullable Object obj) {
-            if (!(obj instanceof AddressKey)) return false;
-            final AddressKey other = (AddressKey) obj;
-
-            return mTetheringType == other.mTetheringType && mScope == other.mScope;
-        }
-
-        @Override
-        public String toString() {
-            return "AddressKey(" + mTetheringType + ", " + mScope + ")";
-        }
-    }
-
-    void dump(final IndentingPrintWriter pw) {
-        pw.println("mTetheringPrefixes:");
-        pw.increaseIndent();
-        for (IpPrefix prefix : mTetheringPrefixes) {
-            pw.println(prefix);
-        }
-        pw.decreaseIndent();
-
-        pw.println("mUpstreamPrefixMap:");
-        pw.increaseIndent();
-        for (int i = 0; i < mUpstreamPrefixMap.size(); i++) {
-            pw.println(mUpstreamPrefixMap.keyAt(i) + " - " + mUpstreamPrefixMap.valueAt(i));
-        }
-        pw.decreaseIndent();
-
-        pw.println("mDownstreams:");
-        pw.increaseIndent();
-        for (IpServer ipServer : mDownstreams) {
-            pw.println(ipServer.interfaceType() + " - " + ipServer.getAddress());
-        }
-        pw.decreaseIndent();
-
-        pw.println("mCachedAddresses:");
-        pw.increaseIndent();
-        for (int i = 0; i < mCachedAddresses.size(); i++) {
-            pw.println(mCachedAddresses.keyAt(i) + " - " + mCachedAddresses.valueAt(i));
-        }
-        pw.decreaseIndent();
-    }
-}
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 1938a08..a388624 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -67,6 +67,11 @@
 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.util.TetheringMessageBase.BASE_MAIN_SM;
 
 import android.app.usage.NetworkStatsManager;
@@ -105,10 +110,12 @@
 import android.net.wifi.p2p.WifiP2pInfo;
 import android.net.wifi.p2p.WifiP2pManager;
 import android.os.Binder;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteCallbackList;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
@@ -139,11 +146,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;
@@ -216,10 +225,12 @@
      * Cookie added when registering {@link android.net.TetheringManager.TetheringEventCallback}.
      */
     private static class CallbackCookie {
-        public final boolean hasListClientsPermission;
+        public final int uid;
+        public final boolean hasSystemPrivilege;
 
-        private CallbackCookie(boolean hasListClientsPermission) {
-            this.hasListClientsPermission = hasListClientsPermission;
+        private CallbackCookie(int uid, boolean hasSystemPrivilege) {
+            this.uid = uid;
+            this.hasSystemPrivilege = hasSystemPrivilege;
         }
     }
 
@@ -253,7 +264,6 @@
     private final TetheringNotificationUpdater mNotificationUpdater;
     private final UserManager mUserManager;
     private final BpfCoordinator mBpfCoordinator;
-    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
     private final TetheringMetrics mTetheringMetrics;
     private final WearableConnectionManager mWearableConnectionManager;
     private int mActiveDataSubId = INVALID_SUBSCRIPTION_ID;
@@ -264,7 +274,6 @@
     private boolean mRndisEnabled;       // track the RNDIS function enabled state
     private boolean mNcmEnabled;         // track the NCM function enabled state
     private Network mTetherUpstream;
-    private TetherStatesParcel mTetherStatesParcel;
     private boolean mDataSaverEnabled = false;
     private String mWifiP2pTetherInterface = null;
     private int mOffloadStatus = TETHER_HARDWARE_OFFLOAD_STOPPED;
@@ -359,10 +368,6 @@
         // Load tethering configuration.
         updateConfiguration();
         mConfig.readEnableSyncSM(mContext);
-        // It is OK for the configuration to be passed to the PrivateAddressCoordinator at
-        // construction time because the only part of the configuration it uses is
-        // shouldEnableWifiP2pDedicatedIp(), and currently do not support changing that.
-        mPrivateAddressCoordinator = mDeps.makePrivateAddressCoordinator(mContext, mConfig);
 
         // Must be initialized after tethering configuration is loaded because BpfCoordinator
         // constructor needs to use the configuration.
@@ -454,6 +459,10 @@
         return mSettingsObserver;
     }
 
+    boolean isTetheringWithSoftApConfigEnabled() {
+        return mDeps.isTetheringWithSoftApConfigEnabled();
+    }
+
     /**
      * Start to register callbacks.
      * Call this function when tethering is ready to handle callback events.
@@ -595,13 +604,40 @@
     // 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 && Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            return;
+        }
+
         if (enabled) {
             ensureIpServerStarted(iface);
         } else {
@@ -667,8 +703,9 @@
             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 */, null);
+            if (unfinishedRequest != null && !unfinishedRequest.equalsIgnoreUidPackage(request)) {
+                enableTetheringInternal(type, false /* disabled */,
+                        unfinishedRequest.getInterfaceName(), null);
                 mEntitlementMgr.stopProvisioningIfNeeded(type);
             }
             mActiveTetheringRequests.put(type, request);
@@ -679,7 +716,7 @@
                 mEntitlementMgr.startProvisioningIfNeeded(type,
                         request.getShouldShowEntitlementUi());
             }
-            enableTetheringInternal(type, true /* enabled */, listener);
+            enableTetheringInternal(type, true /* enabled */, request.getInterfaceName(), listener);
             mTetheringMetrics.createBuilder(type, callerPkg);
         });
     }
@@ -692,7 +729,7 @@
     void stopTetheringInternal(int type) {
         mActiveTetheringRequests.remove(type);
 
-        enableTetheringInternal(type, false /* disabled */, null);
+        enableTetheringInternal(type, false /* disabled */, null, null);
         mEntitlementMgr.stopProvisioningIfNeeded(type);
     }
 
@@ -701,7 +738,7 @@
      * schedule provisioning rechecks for the specified interface.
      */
     private void enableTetheringInternal(int type, boolean enable,
-            final IIntResultListener listener) {
+            String iface, final IIntResultListener listener) {
         int result = TETHER_ERROR_NO_ERROR;
         switch (type) {
             case TETHERING_WIFI:
@@ -720,7 +757,7 @@
                 result = setEthernetTethering(enable);
                 break;
             case TETHERING_VIRTUAL:
-                result = setVirtualMachineTethering(enable);
+                result = setVirtualMachineTethering(enable, iface);
                 break;
             default:
                 Log.w(TAG, "Invalid tether type.");
@@ -975,10 +1012,13 @@
         }
     }
 
-    private int setVirtualMachineTethering(final boolean enable) {
-        // TODO(340377643): Use bridge ifname when it's introduced, not fixed TAP ifname.
+    private int setVirtualMachineTethering(final boolean enable, String iface) {
         if (enable) {
-            mConfiguredVirtualIface = "avf_tap_fixed";
+            if (TextUtils.isEmpty(iface)) {
+                mConfiguredVirtualIface = "avf_tap_fixed";
+            } else {
+                mConfiguredVirtualIface = iface;
+            }
             enableIpServing(
                     TETHERING_VIRTUAL,
                     mConfiguredVirtualIface,
@@ -990,10 +1030,60 @@
         return TETHER_ERROR_NO_ERROR;
     }
 
+    /**
+     * 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 tether(String iface, int requestedState, final IIntResultListener listener) {
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            // 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(() -> {
+            int result = tether(iface, requestedState);
+            switch (ifaceNameToType(iface)) {
+                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;
+            }
             try {
-                listener.onResult(tether(iface, requestedState));
+                listener.onResult(result);
             } catch (RemoteException e) { }
         });
     }
@@ -1025,6 +1115,13 @@
     }
 
     void untether(String iface, final IIntResultListener listener) {
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            // 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));
@@ -1096,21 +1193,55 @@
     }
 
     // TODO: Figure out how to update for local hotspot mode interfaces.
-    private void sendTetherStateChangedBroadcast() {
+    private void notifyTetherStatesChanged() {
         if (!isTetheringSupported()) return;
 
+        sendTetherStatesChangedCallback();
+        sendTetherStatesChangedBroadcast();
+
+        int downstreamTypesMask = DOWNSTREAM_NONE;
+        for (int i = 0; i < mTetherStates.size(); i++) {
+            final TetherState tetherState = mTetherStates.valueAt(i);
+            final int type = tetherState.ipServer.interfaceType();
+            if (tetherState.lastState != IpServer.STATE_TETHERED) continue;
+            switch (type) {
+                case TETHERING_USB:
+                case TETHERING_WIFI:
+                case TETHERING_BLUETOOTH:
+                    downstreamTypesMask |= (1 << type);
+                    break;
+                default:
+                    // Do nothing.
+                    break;
+            }
+        }
+        mNotificationUpdater.onDownstreamChanged(downstreamTypesMask);
+    }
+
+    /**
+     * Builds a TetherStatesParcel for the specified CallbackCookie. SoftApConfiguration will only
+     * be included if the cookie has the same uid as the app that specified the configuration, or
+     * if the cookie has system privilege.
+     *
+     * @param cookie CallbackCookie of the receiving app.
+     * @return TetherStatesParcel with information redacted for the specified cookie.
+     */
+    private TetherStatesParcel buildTetherStatesParcel(CallbackCookie cookie) {
         final ArrayList<TetheringInterface> available = new ArrayList<>();
         final ArrayList<TetheringInterface> tethered = new ArrayList<>();
         final ArrayList<TetheringInterface> localOnly = new ArrayList<>();
         final ArrayList<TetheringInterface> errored = new ArrayList<>();
         final ArrayList<Integer> lastErrors = new ArrayList<>();
 
-        int downstreamTypesMask = DOWNSTREAM_NONE;
         for (int i = 0; i < mTetherStates.size(); i++) {
             final TetherState tetherState = mTetherStates.valueAt(i);
             final int type = tetherState.ipServer.interfaceType();
             final String iface = mTetherStates.keyAt(i);
-            final TetheringInterface tetheringIface = new TetheringInterface(type, iface);
+            final TetheringRequest request = tetherState.ipServer.getTetheringRequest();
+            final boolean includeSoftApConfig = request != null && cookie != null
+                    && (cookie.uid == request.getUid() || cookie.hasSystemPrivilege);
+            final TetheringInterface tetheringIface = new TetheringInterface(type, iface,
+                    includeSoftApConfig ? request.getSoftApConfiguration() : null);
             if (tetherState.lastError != TETHER_ERROR_NO_ERROR) {
                 errored.add(tetheringIface);
                 lastErrors.add(tetherState.lastError);
@@ -1123,41 +1254,16 @@
                     case TETHERING_USB:
                     case TETHERING_WIFI:
                     case TETHERING_BLUETOOTH:
-                        downstreamTypesMask |= (1 << type);
                         break;
                     default:
                         // Do nothing.
+                        break;
                 }
                 tethered.add(tetheringIface);
             }
         }
 
-        mTetherStatesParcel = buildTetherStatesParcel(available, localOnly, tethered, errored,
-                lastErrors);
-        reportTetherStateChanged(mTetherStatesParcel);
-
-        mContext.sendStickyBroadcastAsUser(buildStateChangeIntent(available, localOnly, tethered,
-                errored), UserHandle.ALL);
-        if (DBG) {
-            Log.d(TAG, String.format(
-                    "reportTetherStateChanged %s=[%s] %s=[%s] %s=[%s] %s=[%s]",
-                    "avail", TextUtils.join(",", available),
-                    "local_only", TextUtils.join(",", localOnly),
-                    "tether", TextUtils.join(",", tethered),
-                    "error", TextUtils.join(",", errored)));
-        }
-
-        mNotificationUpdater.onDownstreamChanged(downstreamTypesMask);
-    }
-
-    private TetherStatesParcel buildTetherStatesParcel(
-            final ArrayList<TetheringInterface> available,
-            final ArrayList<TetheringInterface> localOnly,
-            final ArrayList<TetheringInterface> tethered,
-            final ArrayList<TetheringInterface> errored,
-            final ArrayList<Integer> lastErrors) {
         final TetherStatesParcel parcel = new TetherStatesParcel();
-
         parcel.availableList = available.toArray(new TetheringInterface[0]);
         parcel.tetheredList = tethered.toArray(new TetheringInterface[0]);
         parcel.localOnlyList = localOnly.toArray(new TetheringInterface[0]);
@@ -1166,23 +1272,23 @@
         for (int i = 0; i < lastErrors.size(); i++) {
             parcel.lastErrorList[i] = lastErrors.get(i);
         }
-
         return parcel;
     }
 
-    private Intent buildStateChangeIntent(final ArrayList<TetheringInterface> available,
-            final ArrayList<TetheringInterface> localOnly,
-            final ArrayList<TetheringInterface> tethered,
-            final ArrayList<TetheringInterface> errored) {
+    private void sendTetherStatesChangedBroadcast() {
         final Intent bcast = new Intent(ACTION_TETHER_STATE_CHANGED);
         bcast.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING);
 
-        bcast.putStringArrayListExtra(EXTRA_AVAILABLE_TETHER, toIfaces(available));
-        bcast.putStringArrayListExtra(EXTRA_ACTIVE_LOCAL_ONLY, toIfaces(localOnly));
-        bcast.putStringArrayListExtra(EXTRA_ACTIVE_TETHER, toIfaces(tethered));
-        bcast.putStringArrayListExtra(EXTRA_ERRORED_TETHER, toIfaces(errored));
-
-        return bcast;
+        TetherStatesParcel parcel = buildTetherStatesParcel(null /* cookie */);
+        bcast.putStringArrayListExtra(
+                EXTRA_AVAILABLE_TETHER, toIfaces(Arrays.asList(parcel.availableList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ACTIVE_LOCAL_ONLY, toIfaces(Arrays.asList(parcel.localOnlyList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ACTIVE_TETHER, toIfaces(Arrays.asList(parcel.tetheredList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ERRORED_TETHER, toIfaces(Arrays.asList(parcel.erroredIfaceList)));
+        mContext.sendStickyBroadcastAsUser(bcast, UserHandle.ALL);
     }
 
     private class StateReceiver extends BroadcastReceiver {
@@ -2004,10 +2110,11 @@
             final UpstreamNetworkState ns = (UpstreamNetworkState) o;
             switch (arg1) {
                 case UpstreamNetworkMonitor.EVENT_ON_LINKPROPERTIES:
-                    mPrivateAddressCoordinator.updateUpstreamPrefix(ns);
+                    mRoutingCoordinator.updateUpstreamPrefix(
+                            ns.linkProperties, ns.networkCapabilities, ns.network);
                     break;
                 case UpstreamNetworkMonitor.EVENT_ON_LOST:
-                    mPrivateAddressCoordinator.removeUpstreamPrefix(ns.network);
+                    mRoutingCoordinator.removeUpstreamPrefix(ns.network);
                     break;
             }
 
@@ -2077,8 +2184,8 @@
                     return;
                 }
 
-                mPrivateAddressCoordinator.maybeRemoveDeprecatedUpstreams();
-                mUpstreamNetworkMonitor.startObserveAllNetworks();
+                mRoutingCoordinator.maybeRemoveDeprecatedUpstreams();
+                mUpstreamNetworkMonitor.startObserveUpstreamNetworks();
 
                 // TODO: De-duplicate with updateUpstreamWanted() below.
                 if (upstreamWanted()) {
@@ -2198,7 +2305,7 @@
                     case EVENT_REQUEST_CHANGE_DOWNSTREAM: {
                         final int tetheringType = message.arg1;
                         final Boolean enabled = (Boolean) message.obj;
-                        enableTetheringInternal(tetheringType, enabled, null);
+                        enableTetheringInternal(tetheringType, enabled, null, null);
                         break;
                     }
                     default:
@@ -2395,19 +2502,19 @@
 
     /** Register tethering event callback */
     void registerTetheringEventCallback(ITetheringEventCallback callback) {
-        final boolean hasListPermission =
-                hasCallingPermission(NETWORK_SETTINGS)
-                        || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
-                        || hasCallingPermission(NETWORK_STACK);
+        final int uid = mDeps.getBinderCallingUid();
+        final boolean hasSystemPrivilege = hasCallingPermission(NETWORK_SETTINGS)
+                || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
+                || hasCallingPermission(NETWORK_STACK);
         mHandler.post(() -> {
-            mTetheringEventCallbacks.register(callback, new CallbackCookie(hasListPermission));
+            CallbackCookie cookie = new CallbackCookie(uid, hasSystemPrivilege);
+            mTetheringEventCallbacks.register(callback, cookie);
             final TetheringCallbackStartedParcel parcel = new TetheringCallbackStartedParcel();
             parcel.supportedTypes = mSupportedTypeBitmap;
             parcel.upstreamNetwork = mTetherUpstream;
             parcel.config = mConfig.toStableParcelable();
-            parcel.states =
-                    mTetherStatesParcel != null ? mTetherStatesParcel : emptyTetherStatesParcel();
-            parcel.tetheredClients = hasListPermission
+            parcel.states = buildTetherStatesParcel(cookie);
+            parcel.tetheredClients = hasSystemPrivilege
                     ? mConnectedClientsTracker.getLastTetheredClients()
                     : Collections.emptyList();
             parcel.offloadStatus = mOffloadStatus;
@@ -2419,17 +2526,6 @@
         });
     }
 
-    private TetherStatesParcel emptyTetherStatesParcel() {
-        final TetherStatesParcel parcel = new TetherStatesParcel();
-        parcel.availableList = new TetheringInterface[0];
-        parcel.tetheredList = new TetheringInterface[0];
-        parcel.localOnlyList = new TetheringInterface[0];
-        parcel.erroredIfaceList = new TetheringInterface[0];
-        parcel.lastErrorList = new int[0];
-
-        return parcel;
-    }
-
     private boolean hasCallingPermission(@NonNull String permission) {
         return mContext.checkCallingOrSelfPermission(permission) == PERMISSION_GRANTED;
     }
@@ -2488,12 +2584,14 @@
         }
     }
 
-    private void reportTetherStateChanged(TetherStatesParcel states) {
+    private void sendTetherStatesChangedCallback() {
         final int length = mTetheringEventCallbacks.beginBroadcast();
         try {
             for (int i = 0; i < length; i++) {
                 try {
-                    mTetheringEventCallbacks.getBroadcastItem(i).onTetherStatesChanged(states);
+                    TetherStatesParcel parcel = buildTetherStatesParcel(
+                            (CallbackCookie) mTetheringEventCallbacks.getBroadcastCookie(i));
+                    mTetheringEventCallbacks.getBroadcastItem(i).onTetherStatesChanged(parcel);
                 } catch (RemoteException e) {
                     // Not really very much to do here.
                 }
@@ -2501,6 +2599,18 @@
         } finally {
             mTetheringEventCallbacks.finishBroadcast();
         }
+
+        if (DBG) {
+            // Use a CallbackCookie with system privilege so nothing is redacted.
+            TetherStatesParcel parcel = buildTetherStatesParcel(
+                    new CallbackCookie(Process.SYSTEM_UID, true /* hasSystemPrivilege */));
+            Log.d(TAG, String.format(
+                    "sendTetherStatesChangedCallback %s=[%s] %s=[%s] %s=[%s] %s=[%s]",
+                    "avail", TextUtils.join(",", Arrays.asList(parcel.availableList)),
+                    "local_only", TextUtils.join(",", Arrays.asList(parcel.localOnlyList)),
+                    "tether", TextUtils.join(",", Arrays.asList(parcel.tetheredList)),
+                    "error", TextUtils.join(",", Arrays.asList(parcel.erroredIfaceList))));
+        }
     }
 
     private void reportTetherClientsChanged(List<TetheredClient> clients) {
@@ -2510,7 +2620,7 @@
                 try {
                     final CallbackCookie cookie =
                             (CallbackCookie) mTetheringEventCallbacks.getBroadcastCookie(i);
-                    if (!cookie.hasListClientsPermission) continue;
+                    if (!cookie.hasSystemPrivilege) continue;
                     mTetheringEventCallbacks.getBroadcastItem(i).onTetherClientsChanged(clients);
                 } catch (RemoteException e) {
                     // Not really very much to do here.
@@ -2665,11 +2775,6 @@
 
         dumpBpf(pw);
 
-        pw.println("Private address coordinator:");
-        pw.increaseIndent();
-        mPrivateAddressCoordinator.dump(pw);
-        pw.decreaseIndent();
-
         if (mWearableConnectionManager != null) {
             pw.println("WearableConnectionManager:");
             pw.increaseIndent();
@@ -2750,7 +2855,7 @@
                     return;
             }
             mTetherMainSM.sendMessage(which, state, 0, who);
-            sendTetherStateChangedBroadcast();
+            notifyTetherStatesChanged();
         }
 
         @Override
@@ -2823,8 +2928,7 @@
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
                 new IpServer(iface, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
-                        mRoutingCoordinator, new ControlCallback(), mConfig,
-                        mPrivateAddressCoordinator, mTetheringMetrics,
+                        mRoutingCoordinator, new ControlCallback(), mConfig, mTetheringMetrics,
                         mDeps.makeIpServerDependencies()), isNcm);
         mTetherStates.put(iface, tetherState);
         tetherState.ipServer.start();
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index c9817c9..b3e9c1b 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -182,7 +182,6 @@
     private final int mP2pLeasesSubnetPrefixLength;
 
     private final boolean mEnableWearTethering;
-    private final boolean mRandomPrefixBase;
 
     private final int mUsbTetheringFunction;
     protected final ContentResolver mContentResolver;
@@ -300,8 +299,6 @@
 
         mEnableWearTethering = shouldEnableWearTethering(ctx);
 
-        mRandomPrefixBase = mDeps.isFeatureEnabled(ctx, TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION);
-
         configLog.log(toString());
     }
 
@@ -390,10 +387,6 @@
         return mEnableWearTethering;
     }
 
-    public boolean isRandomPrefixBaseEnabled() {
-        return mRandomPrefixBase;
-    }
-
     /**
      * Check whether sync SM is enabled then set it to USE_SYNC_SM. This should be called once
      * when tethering is created. Otherwise if the flag is pushed while tethering is enabled,
@@ -455,9 +448,6 @@
         pw.print("mUsbTetheringFunction: ");
         pw.println(isUsingNcm() ? "NCM" : "RNDIS");
 
-        pw.print("mRandomPrefixBase: ");
-        pw.println(mRandomPrefixBase);
-
         pw.print("USE_SYNC_SM: ");
         pw.println(USE_SYNC_SM);
     }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 5d9d349..8e17085 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -21,9 +21,11 @@
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothPan;
 import android.content.Context;
+import android.net.ConnectivityManager;
 import android.net.INetd;
 import android.net.connectivity.ConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
+import android.os.Binder;
 import android.os.Build;
 import android.os.Handler;
 import android.os.IBinder;
@@ -35,6 +37,7 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.flags.Flags;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
@@ -135,7 +138,10 @@
     public RoutingCoordinatorManager getRoutingCoordinator(Context context, SharedLog log) {
         IBinder binder;
         if (!SdkLevel.isAtLeastS()) {
-            binder = new RoutingCoordinatorService(getINetd(context, log));
+            final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+            binder =
+                    new RoutingCoordinatorService(
+                            getINetd(context, log), cm::getAllNetworks, context);
         } else {
             binder = ConnectivityInternalApiUtil.getRoutingCoordinator(context);
         }
@@ -174,14 +180,6 @@
     }
 
     /**
-     * Make PrivateAddressCoordinator to be used by Tethering.
-     */
-    public PrivateAddressCoordinator makePrivateAddressCoordinator(Context ctx,
-            TetheringConfiguration cfg) {
-        return new PrivateAddressCoordinator(ctx, cfg);
-    }
-
-    /**
      * Make BluetoothPanShim object to enable/disable bluetooth tethering.
      *
      * TODO: use BluetoothPan directly when mainline module is built with API 32.
@@ -204,4 +202,19 @@
     public WearableConnectionManager makeWearableConnectionManager(Context ctx) {
         return new WearableConnectionManager(ctx);
     }
+
+    /**
+     * Wrapper to get the binder calling uid for unit testing.
+     */
+    public int getBinderCallingUid() {
+        return Binder.getCallingUid();
+    }
+
+    /**
+     * Wrapper for tethering_with_soft_ap_config feature flag.
+     */
+    public boolean isTetheringWithSoftApConfigEnabled() {
+        return Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM
+                && Flags.tetheringWithSoftApConfig();
+    }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java b/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
index a0198cc..a29f0c2 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
@@ -85,7 +85,6 @@
     static final int ROAMING_NOTIFICATION_ID = 1003;
     @VisibleForTesting
     static final int NO_ICON_ID = 0;
-    @VisibleForTesting
     static final int DOWNSTREAM_NONE = 0;
     // Refer to TelephonyManager#getSimCarrierId for more details about carrier id.
     @VisibleForTesting
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index 454cbf1..0c44a38 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -28,6 +28,7 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNSUPPORTED;
 import static android.net.dhcp.IDhcpServer.STATUS_UNKNOWN_ERROR;
 
+import android.app.AppOpsManager;
 import android.app.Service;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
@@ -132,14 +133,18 @@
         @Override
         public void startTethering(TetheringRequestParcel request, String callerPkg,
                 String callingAttributionTag, IIntResultListener listener) {
+            boolean onlyAllowPrivileged = request.exemptFromEntitlementCheck
+                    || request.interfaceName != null;
             if (checkAndNotifyCommonError(callerPkg,
                     callingAttributionTag,
-                    request.exemptFromEntitlementCheck /* onlyAllowPrivileged */,
+                    onlyAllowPrivileged,
                     listener)) {
                 return;
             }
-            // TODO(b/216524590): Add UID/packageName of caller to TetheringRequest here
-            mTethering.startTethering(new TetheringRequest(request), callerPkg, listener);
+            TetheringRequest external = new TetheringRequest(request);
+            external.setUid(getBinderCallingUid());
+            external.setPackageName(callerPkg);
+            mTethering.startTethering(external, callerPkg, listener);
         }
 
         @Override
@@ -238,6 +243,12 @@
                 final String callingAttributionTag, final boolean onlyAllowPrivileged,
                 final IIntResultListener listener) {
             try {
+                if (!checkPackageNameMatchesUid(getBinderCallingUid(), callerPkg)) {
+                    Log.e(TAG, "Package name " + callerPkg + " does not match UID "
+                            + getBinderCallingUid());
+                    listener.onResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+                    return true;
+                }
                 if (!hasTetherChangePermission(callerPkg, callingAttributionTag,
                         onlyAllowPrivileged)) {
                     listener.onResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
@@ -256,6 +267,12 @@
 
         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);
@@ -269,6 +286,10 @@
             return false;
         }
 
+        private boolean hasNetworkSettingsPermission() {
+            return checkCallingOrSelfPermission(NETWORK_SETTINGS);
+        }
+
         private boolean hasNetworkStackPermission() {
             return checkCallingOrSelfPermission(NETWORK_STACK)
                     || checkCallingOrSelfPermission(PERMISSION_MAINLINE_NETWORK_STACK);
@@ -284,15 +305,20 @@
 
         private boolean hasTetherChangePermission(final String callerPkg,
                 final String callingAttributionTag, final boolean onlyAllowPrivileged) {
-            if (onlyAllowPrivileged && !hasNetworkStackPermission()) return false;
+            if (onlyAllowPrivileged && !hasNetworkStackPermission()
+                    && !hasNetworkSettingsPermission()) return false;
 
             if (hasTetherPrivilegedPermission()) return true;
 
+            // 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;
 
-            int uid = Binder.getCallingUid();
+            int uid = getBinderCallingUid();
 
-            // If callerPkg's uid is not same as Binder.getCallingUid(),
+            // If callerPkg's uid is not same as getBinderCallingUid(),
             // checkAndNoteWriteSettingsOperation will return false and the operation will be
             // denied.
             return mService.checkAndNoteWriteSettingsOperation(mService, uid, callerPkg,
@@ -305,6 +331,14 @@
             return mService.checkCallingOrSelfPermission(
                     ACCESS_NETWORK_STATE) == PERMISSION_GRANTED;
         }
+
+        private int getBinderCallingUid() {
+            return mService.getBinderCallingUid();
+        }
+
+        private boolean checkPackageNameMatchesUid(final int uid, final String callerPkg) {
+            return mService.checkPackageNameMatchesUid(mService, uid, callerPkg);
+        }
     }
 
     /**
@@ -322,6 +356,32 @@
     }
 
     /**
+     * Check if the package name matches the uid.
+     */
+    @VisibleForTesting
+    boolean checkPackageNameMatchesUid(@NonNull Context context, int uid,
+            @NonNull String callingPackage) {
+        try {
+            final AppOpsManager mAppOps = context.getSystemService(AppOpsManager.class);
+            if (mAppOps == null) {
+                return false;
+            }
+            mAppOps.checkPackage(uid, callingPackage);
+        } catch (SecurityException e) {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Wrapper for the Binder calling UID, used for mocks.
+     */
+    @VisibleForTesting
+    int getBinderCallingUid() {
+        return Binder.getCallingUid();
+    }
+
+    /**
      * 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/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index fc50faf..087ce44 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -62,7 +62,6 @@
 import android.net.NetworkTemplate;
 import android.os.Handler;
 import android.os.HandlerThread;
-import android.os.Looper;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
 import android.stats.connectivity.UpstreamType;
@@ -76,6 +75,7 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.HandlerUtils;
 import com.android.networkstack.tethering.UpstreamNetworkState;
 
 import java.util.ArrayList;
@@ -111,7 +111,11 @@
     private final SparseArray<NetworkTetheringReported.Builder> mBuilderMap = new SparseArray<>();
     private final SparseArray<Long> mDownstreamStartTime = new SparseArray<Long>();
     private final ArrayList<RecordUpstreamEvent> mUpstreamEventList = new ArrayList<>();
-    private final ArrayMap<UpstreamType, DataUsage> mUpstreamUsageBaseline = new ArrayMap<>();
+    // Store the last reported data usage for each upstream type to be used for calculating the
+    // usage delta. The keys are the upstream types, and the values are the tethering UID data
+    // usage for the corresponding types. Retrieve the baseline data usage when tethering is
+    // enabled, update it when the upstream changes, and clear it when tethering is disabled.
+    private final ArrayMap<UpstreamType, DataUsage> mLastReportedUpstreamUsage = new ArrayMap<>();
     private final Context mContext;
     private final Dependencies mDependencies;
     private final NetworkStatsManager mNetworkStatsManager;
@@ -157,10 +161,15 @@
 
         /**
          * @see Handler
+         *
+         * Note: This should only be called once, within the constructor, as it creates a new
+         * thread. Calling it multiple times could lead to a thread leak.
          */
         @NonNull
-        public Handler createHandler(Looper looper) {
-            return new Handler(looper);
+        public Handler createHandler() {
+            final HandlerThread thread = new HandlerThread(TAG);
+            thread.start();
+            return new Handler(thread.getLooper());
         }
     }
 
@@ -177,9 +186,7 @@
         mContext = context;
         mDependencies = dependencies;
         mNetworkStatsManager = mContext.getSystemService(NetworkStatsManager.class);
-        final HandlerThread thread = new HandlerThread(TAG);
-        thread.start();
-        mHandler = dependencies.createHandler(thread.getLooper());
+        mHandler = dependencies.createHandler();
     }
 
     @VisibleForTesting
@@ -282,22 +289,33 @@
      * Calculates the data usage difference between the current and previous usage for the
      * specified upstream type.
      *
+     * Note: This must be called before updating mCurrentUpstream when changing the upstream.
+     *
      * @return A DataUsage object containing the calculated difference in transmitted (tx) and
      *         received (rx) bytes.
      */
     private DataUsage calculateDataUsageDelta(@Nullable UpstreamType upstream) {
-        if (upstream != null && mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
-                && isUsageSupportedForUpstreamType(upstream)) {
-            final DataUsage oldUsage = mUpstreamUsageBaseline.getOrDefault(upstream, EMPTY);
-            if (oldUsage.equals(EMPTY)) {
-                Log.d(TAG, "No usage baseline for the upstream=" + upstream);
-                return EMPTY;
-            }
-            // TODO(b/352537247): Fix data usage which might be incorrect if the device uses
-            //  tethering with the same upstream for over 15 days.
-            return DataUsage.subtract(getCurrentDataUsageForUpstreamType(upstream), oldUsage);
+        if (!mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)) {
+            return EMPTY;
         }
-        return EMPTY;
+
+        if (upstream == null || !isUsageSupportedForUpstreamType(upstream)) {
+            return EMPTY;
+        }
+
+        final DataUsage oldUsage = mLastReportedUpstreamUsage.getOrDefault(upstream, EMPTY);
+        if (oldUsage.equals(EMPTY)) {
+            Log.d(TAG, "No usage baseline for the upstream=" + upstream);
+            return EMPTY;
+        }
+        // TODO(b/370724247): Fix data usage which might be incorrect if the device uses
+        //  tethering with the same upstream for over 15 days.
+        // Need to refresh the baseline usage data. If the network switches back to Wi-Fi after
+        // using cellular data (Wi-Fi -> Cellular -> Wi-Fi), the old baseline might be
+        // inaccurate, leading to incorrect delta calculations.
+        final DataUsage newUsage = getCurrentDataUsageForUpstreamType(upstream);
+        mLastReportedUpstreamUsage.put(upstream, newUsage);
+        return DataUsage.subtract(newUsage, oldUsage);
     }
 
     /**
@@ -416,7 +434,6 @@
      * @param reported a NetworkTetheringReported object containing statistics to write
      */
     private void write(@NonNull final NetworkTetheringReported reported) {
-        final byte[] upstreamEvents = reported.getUpstreamEvents().toByteArray();
         mDependencies.write(reported);
         if (DBG) {
             Log.d(
@@ -430,7 +447,7 @@
                     + ", userType: "
                     + reported.getUserType().getNumber()
                     + ", upstreamTypes: "
-                    + Arrays.toString(upstreamEvents)
+                    + Arrays.toString(reported.getUpstreamEvents().toByteArray())
                     + ", durationMillis: "
                     + reported.getDurationMillis());
         }
@@ -444,25 +461,26 @@
     }
 
     private void handleInitUpstreamUsageBaseline() {
-        if (!(mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
-                && mUpstreamUsageBaseline.isEmpty())) {
+        if (!mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)) {
+            return;
+        }
+
+        if (!mLastReportedUpstreamUsage.isEmpty()) {
+            Log.wtf(TAG, "The upstream usage baseline has been initialed.");
             return;
         }
 
         for (UpstreamType type : UpstreamType.values()) {
             if (!isUsageSupportedForUpstreamType(type)) continue;
-            mUpstreamUsageBaseline.put(type, getCurrentDataUsageForUpstreamType(type));
+            mLastReportedUpstreamUsage.put(type, getCurrentDataUsageForUpstreamType(type));
         }
     }
 
     @VisibleForTesting
     @NonNull
-    DataUsage getDataUsageFromUpstreamType(@NonNull UpstreamType type) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-        return mUpstreamUsageBaseline.getOrDefault(type, EMPTY);
+    DataUsage getLastReportedUsageFromUpstreamType(@NonNull UpstreamType type) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        return mLastReportedUpstreamUsage.getOrDefault(type, EMPTY);
     }
 
 
@@ -497,7 +515,7 @@
         mUpstreamEventList.clear();
         mCurrentUpstream = null;
         mCurrentUpStreamStartTime = 0L;
-        mUpstreamUsageBaseline.clear();
+        mLastReportedUpstreamUsage.clear();
     }
 
     private DownstreamType downstreamTypeToEnum(final int ifaceType) {
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 423b9b8..1323f28 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -70,7 +70,7 @@
 import com.android.net.module.util.structs.FragmentHeader;
 import com.android.net.module.util.structs.Ipv6Header;
 import com.android.testutils.HandlerUtils;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
@@ -158,10 +158,10 @@
     protected TetheredInterfaceRequester mTetheredInterfaceRequester;
 
     // Late initialization in initTetheringTester().
-    private TapPacketReader mUpstreamReader;
+    private PollPacketReader mUpstreamReader;
     private TestNetworkTracker mUpstreamTracker;
     private TestNetworkInterface mDownstreamIface;
-    private TapPacketReader mDownstreamReader;
+    private PollPacketReader mDownstreamReader;
     private MyTetheringEventCallback mTetheringEventCallback;
 
     public Context getContext() {
@@ -187,10 +187,10 @@
         return runAsShell(NETWORK_SETTINGS, TETHER_PRIVILEGED, () -> sTm.isTetheringSupported());
     }
 
-    protected void maybeStopTapPacketReader(final TapPacketReader tapPacketReader)
+    protected void maybeStopTapPacketReader(final PollPacketReader tapPacketReader)
             throws Exception {
         if (tapPacketReader != null) {
-            TapPacketReader reader = tapPacketReader;
+            PollPacketReader reader = tapPacketReader;
             mHandler.post(() -> reader.stop());
         }
     }
@@ -228,7 +228,7 @@
             });
         }
         if (mUpstreamReader != null) {
-            TapPacketReader reader = mUpstreamReader;
+            PollPacketReader reader = mUpstreamReader;
             mHandler.post(() -> reader.stop());
             mUpstreamReader = null;
         }
@@ -291,7 +291,7 @@
         });
     }
 
-    protected static void waitForRouterAdvertisement(TapPacketReader reader, String iface,
+    protected static void waitForRouterAdvertisement(PollPacketReader reader, String iface,
             long timeoutMs) {
         final long deadline = SystemClock.uptimeMillis() + timeoutMs;
         do {
@@ -332,15 +332,16 @@
         // seconds. See b/289881008.
         private static final int EXPANDED_TIMEOUT_MS = 30000;
 
-        MyTetheringEventCallback(String iface) {
-            mIface = new TetheringInterface(TETHERING_ETHERNET, iface);
+        MyTetheringEventCallback(int tetheringType, String iface) {
+            mIface = new TetheringInterface(tetheringType, iface);
             mExpectedUpstream = null;
             mAcceptAnyUpstream = true;
         }
 
-        MyTetheringEventCallback(String iface, @NonNull Network expectedUpstream) {
+        MyTetheringEventCallback(
+                int tetheringType, String iface, @NonNull Network expectedUpstream) {
             Objects.requireNonNull(expectedUpstream);
-            mIface = new TetheringInterface(TETHERING_ETHERNET, iface);
+            mIface = new TetheringInterface(tetheringType, iface);
             mExpectedUpstream = expectedUpstream;
             mAcceptAnyUpstream = false;
         }
@@ -392,12 +393,12 @@
         }
 
         public void awaitInterfaceTethered() throws Exception {
-            assertTrue("Ethernet not tethered after " + EXPANDED_TIMEOUT_MS + "ms",
+            assertTrue("Interface is not tethered after " + EXPANDED_TIMEOUT_MS + "ms",
                     mTetheringStartedLatch.await(EXPANDED_TIMEOUT_MS, TimeUnit.MILLISECONDS));
         }
 
         public void awaitInterfaceLocalOnly() throws Exception {
-            assertTrue("Ethernet not local-only after " + EXPANDED_TIMEOUT_MS + "ms",
+            assertTrue("Interface is not local-only after " + EXPANDED_TIMEOUT_MS + "ms",
                     mLocalOnlyStartedLatch.await(EXPANDED_TIMEOUT_MS, TimeUnit.MILLISECONDS));
         }
 
@@ -501,15 +502,17 @@
         sCallbackErrors.add(error);
     }
 
-    protected static MyTetheringEventCallback enableEthernetTethering(String iface,
+    protected static MyTetheringEventCallback enableTethering(String iface,
             TetheringRequest request, Network expectedUpstream) throws Exception {
-        // Enable ethernet tethering with null expectedUpstream means the test accept any upstream
-        // after etherent tethering started.
+        // Enable tethering with null expectedUpstream means the test accept any upstream after
+        // tethering started.
         final MyTetheringEventCallback callback;
         if (expectedUpstream != null) {
-            callback = new MyTetheringEventCallback(iface, expectedUpstream);
+            callback =
+                    new MyTetheringEventCallback(
+                            request.getTetheringType(), iface, expectedUpstream);
         } else {
-            callback = new MyTetheringEventCallback(iface);
+            callback = new MyTetheringEventCallback(request.getTetheringType(), iface);
         }
         runAsShell(NETWORK_SETTINGS, () -> {
             sTm.registerTetheringEventCallback(c -> c.run() /* executor */, callback);
@@ -521,7 +524,7 @@
         StartTetheringCallback startTetheringCallback = new StartTetheringCallback() {
             @Override
             public void onTetheringStarted() {
-                Log.d(TAG, "Ethernet tethering started");
+                Log.d(TAG, "Tethering started");
                 tetheringStartedLatch.countDown();
             }
 
@@ -530,8 +533,8 @@
                 addCallbackError("Unexpectedly got onTetheringFailed");
             }
         };
-        Log.d(TAG, "Starting Ethernet tethering");
-        runAsShell(TETHER_PRIVILEGED, () -> {
+        Log.d(TAG, "Starting tethering");
+        runAsShell(TETHER_PRIVILEGED, NETWORK_SETTINGS, () -> {
             sTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
             // Binder call is an async call. Need to hold the shell permission until tethering
             // started. This helps to avoid the test become flaky.
@@ -557,7 +560,7 @@
 
     protected static MyTetheringEventCallback enableEthernetTethering(String iface,
             Network expectedUpstream) throws Exception {
-        return enableEthernetTethering(iface,
+        return enableTethering(iface,
                 new TetheringRequest.Builder(TETHERING_ETHERNET)
                 .setShouldShowEntitlementUi(false).build(), expectedUpstream);
     }
@@ -574,13 +577,13 @@
         return nif.getIndex();
     }
 
-    protected TapPacketReader makePacketReader(final TestNetworkInterface iface) throws Exception {
+    protected PollPacketReader makePacketReader(final TestNetworkInterface iface) throws Exception {
         FileDescriptor fd = iface.getFileDescriptor().getFileDescriptor();
         return makePacketReader(fd, getMTU(iface));
     }
 
-    protected TapPacketReader makePacketReader(FileDescriptor fd, int mtu) {
-        final TapPacketReader reader = new TapPacketReader(mHandler, fd, mtu);
+    protected PollPacketReader makePacketReader(FileDescriptor fd, int mtu) {
+        final PollPacketReader reader = new PollPacketReader(mHandler, fd, mtu);
         mHandler.post(() -> reader.start());
         HandlerUtils.waitForIdle(mHandler, TIMEOUT_MS);
         return reader;
diff --git a/Tethering/tests/integration/base/android/net/TetheringTester.java b/Tethering/tests/integration/base/android/net/TetheringTester.java
index b152b4c..fb94eed 100644
--- a/Tethering/tests/integration/base/android/net/TetheringTester.java
+++ b/Tethering/tests/integration/base/android/net/TetheringTester.java
@@ -84,7 +84,7 @@
 import com.android.net.module.util.structs.RaHeader;
 import com.android.net.module.util.structs.TcpHeader;
 import com.android.net.module.util.structs.UdpHeader;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -157,14 +157,14 @@
     public static final String DHCP_HOSTNAME = "testhostname";
 
     private final ArrayMap<MacAddress, TetheredDevice> mTetheredDevices;
-    private final TapPacketReader mDownstreamReader;
-    private final TapPacketReader mUpstreamReader;
+    private final PollPacketReader mDownstreamReader;
+    private final PollPacketReader mUpstreamReader;
 
-    public TetheringTester(TapPacketReader downstream) {
+    public TetheringTester(PollPacketReader downstream) {
         this(downstream, null);
     }
 
-    public TetheringTester(TapPacketReader downstream, TapPacketReader upstream) {
+    public TetheringTester(PollPacketReader downstream, PollPacketReader upstream) {
         if (downstream == null) fail("Downstream reader could not be NULL");
 
         mDownstreamReader = downstream;
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 32b2f3e..5c8d347 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -19,7 +19,9 @@
 import static android.Manifest.permission.DUMP;
 import static android.net.InetAddresses.parseNumericAddress;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
 import static android.net.TetheringManager.TETHERING_ETHERNET;
+import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringTester.TestDnsPacket;
 import static android.net.TetheringTester.buildIcmpEchoPacketV4;
 import static android.net.TetheringTester.buildUdpPacket;
@@ -80,7 +82,7 @@
 import com.android.testutils.DeviceInfoUtils;
 import com.android.testutils.DumpTestUtils;
 import com.android.testutils.NetworkStackModuleTest;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import org.junit.After;
 import org.junit.Rule;
@@ -213,7 +215,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -236,7 +238,8 @@
             downstreamReader = makePacketReader(fd, mtu);
             tetheringEventCallback = enableEthernetTethering(downstreamIface.getInterfaceName(),
                     null /* any upstream */);
-            checkTetheredClientCallbacks(downstreamReader, tetheringEventCallback);
+            checkTetheredClientCallbacks(
+                    downstreamReader, TETHERING_ETHERNET, tetheringEventCallback);
         } finally {
             maybeStopTapPacketReader(downstreamReader);
             maybeCloseTestInterface(downstreamIface);
@@ -253,7 +256,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -267,7 +270,8 @@
             downstreamReader = makePacketReader(fd, getMTU(downstreamIface));
             tetheringEventCallback = enableEthernetTethering(downstreamIface.getInterfaceName(),
                     null /* any upstream */);
-            checkTetheredClientCallbacks(downstreamReader, tetheringEventCallback);
+            checkTetheredClientCallbacks(
+                    downstreamReader, TETHERING_ETHERNET, tetheringEventCallback);
         } finally {
             maybeStopTapPacketReader(downstreamReader);
             maybeCloseTestInterface(downstreamIface);
@@ -283,7 +287,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -302,7 +306,7 @@
 
             final String localAddr = "192.0.2.3/28";
             final String clientAddr = "192.0.2.2/28";
-            tetheringEventCallback = enableEthernetTethering(iface,
+            tetheringEventCallback = enableTethering(iface,
                     requestWithStaticIpv4(localAddr, clientAddr), null /* any upstream */);
 
             tetheringEventCallback.awaitInterfaceTethered();
@@ -357,7 +361,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -368,8 +372,7 @@
 
             final TetheringRequest request = new TetheringRequest.Builder(TETHERING_ETHERNET)
                     .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL).build();
-            tetheringEventCallback = enableEthernetTethering(iface, request,
-                    null /* any upstream */);
+            tetheringEventCallback = enableTethering(iface, request, null /* any upstream */);
             tetheringEventCallback.awaitInterfaceLocalOnly();
 
             // makePacketReader only works after tethering is started, because until then the
@@ -423,7 +426,8 @@
         // client, which is not possible in this test.
     }
 
-    private void checkTetheredClientCallbacks(final TapPacketReader packetReader,
+    private void checkTetheredClientCallbacks(final PollPacketReader packetReader,
+            final int tetheringType,
             final MyTetheringEventCallback tetheringEventCallback) throws Exception {
         // Create a fake client.
         byte[] clientMacAddr = new byte[6];
@@ -438,7 +442,7 @@
 
         // Check the MAC address.
         assertEquals(MacAddress.fromBytes(clientMacAddr), client.getMacAddress());
-        assertEquals(TETHERING_ETHERNET, client.getTetheringType());
+        assertEquals(tetheringType, client.getTetheringType());
 
         // Check the hostname.
         assertEquals(1, client.getAddresses().size());
@@ -475,8 +479,7 @@
     private void assertInvalidStaticIpv4Request(String iface, String local, String client)
             throws Exception {
         try {
-            enableEthernetTethering(iface, requestWithStaticIpv4(local, client),
-                    null /* any upstream */);
+            enableTethering(iface, requestWithStaticIpv4(local, client), null /* any upstream */);
             fail("Unexpectedly accepted invalid IPv4 configuration: " + local + ", " + client);
         } catch (IllegalArgumentException | NullPointerException expected) { }
     }
@@ -1180,4 +1183,32 @@
                 TX_UDP_PACKET_COUNT * (TX_UDP_PACKET_SIZE + IPV6_HEADER_LEN - IPV4_HEADER_MIN_LEN),
                 newEgress4.bytes - oldEgress4.bytes);
     }
+
+    @Test
+    public void testTetheringVirtual() throws Exception {
+        assumeFalse(isInterfaceForTetheringAvailable());
+        setIncludeTestInterfaces(true);
+
+        TestNetworkInterface downstreamIface = null;
+        MyTetheringEventCallback tetheringEventCallback = null;
+        PollPacketReader downstreamReader = null;
+        try {
+            downstreamIface = createTestInterface();
+            String iface = downstreamIface.getInterfaceName();
+            final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                    .setConnectivityScope(CONNECTIVITY_SCOPE_GLOBAL)
+                    .setInterfaceName(iface)
+                    .build();
+            tetheringEventCallback = enableTethering(iface, request, null /* any upstream */);
+
+            FileDescriptor fd = downstreamIface.getFileDescriptor().getFileDescriptor();
+            downstreamReader = makePacketReader(fd, getMTU(downstreamIface));
+            checkTetheredClientCallbacks(
+                    downstreamReader, TETHERING_VIRTUAL, tetheringEventCallback);
+        } finally {
+            maybeStopTapPacketReader(downstreamReader);
+            maybeCloseTestInterface(downstreamIface);
+            maybeUnregisterTetheringEventCallback(tetheringEventCallback);
+        }
+    }
 }
diff --git a/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java b/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
index ebf09ed..0f3f5bb 100644
--- a/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
@@ -43,7 +43,7 @@
 import com.android.networkstack.tethering.util.TetheringUtils;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TapPacketReaderRule;
 
 import org.junit.After;
@@ -75,7 +75,7 @@
     private InterfaceParams mUpstreamParams, mTetheredParams;
     private HandlerThread mHandlerThread;
     private Handler mHandler;
-    private TapPacketReader mUpstreamPacketReader, mTetheredPacketReader;
+    private PollPacketReader mUpstreamPacketReader, mTetheredPacketReader;
 
     private static INetd sNetd;
 
@@ -219,7 +219,7 @@
     }
 
     // TODO: change to assert.
-    private boolean waitForPacket(ByteBuffer packet, TapPacketReader reader) {
+    private boolean waitForPacket(ByteBuffer packet, PollPacketReader reader) {
         byte[] p;
 
         while ((p = reader.popPacket(PACKET_TIMEOUT_MS)) != null) {
@@ -247,7 +247,7 @@
     }
 
     private void receivePacketAndMaybeExpectForwarded(boolean expectForwarded,
-            ByteBuffer in, TapPacketReader inReader, ByteBuffer out, TapPacketReader outReader)
+            ByteBuffer in, PollPacketReader inReader, ByteBuffer out, PollPacketReader outReader)
             throws IOException {
 
         inReader.sendResponse(in);
@@ -271,13 +271,13 @@
         assertEquals(msg, expectForwarded, waitForPacket(out, outReader));
     }
 
-    private void receivePacketAndExpectForwarded(ByteBuffer in, TapPacketReader inReader,
-            ByteBuffer out, TapPacketReader outReader) throws IOException {
+    private void receivePacketAndExpectForwarded(ByteBuffer in, PollPacketReader inReader,
+            ByteBuffer out, PollPacketReader outReader) throws IOException {
         receivePacketAndMaybeExpectForwarded(true, in, inReader, out, outReader);
     }
 
-    private void receivePacketAndExpectNotForwarded(ByteBuffer in, TapPacketReader inReader,
-            ByteBuffer out, TapPacketReader outReader) throws IOException {
+    private void receivePacketAndExpectNotForwarded(ByteBuffer in, PollPacketReader inReader,
+            ByteBuffer out, PollPacketReader outReader) throws IOException {
         receivePacketAndMaybeExpectForwarded(false, in, inReader, out, outReader);
     }
 
diff --git a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
index 90ceaa1..7cc8c74 100644
--- a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
@@ -64,7 +64,7 @@
 import com.android.net.module.util.structs.PrefixInformationOption;
 import com.android.net.module.util.structs.RaHeader;
 import com.android.net.module.util.structs.RdnssOption;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TapPacketReaderRule;
 
 import org.junit.After;
@@ -93,7 +93,7 @@
     private InterfaceParams mTetheredParams;
     private HandlerThread mHandlerThread;
     private Handler mHandler;
-    private TapPacketReader mTetheredPacketReader;
+    private PollPacketReader mTetheredPacketReader;
     private RouterAdvertisementDaemon mRaDaemon;
 
     private static INetd sNetd;
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 177296a..680e81d 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -89,13 +89,10 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.RoutingCoordinatorManager;
-import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.tethering.BpfCoordinator;
-import com.android.networkstack.tethering.PrivateAddressCoordinator;
 import com.android.networkstack.tethering.TetheringConfiguration;
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
@@ -139,6 +136,7 @@
     private static final boolean DEFAULT_USING_BPF_OFFLOAD = true;
     private static final int DEFAULT_SUBNET_PREFIX_LENGTH = 0;
     private static final int P2P_SUBNET_PREFIX_LENGTH = 25;
+    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
 
     private static final InterfaceParams TEST_IFACE_PARAMS = new InterfaceParams(
             IFACE_NAME, 42 /* index */, MacAddress.ALL_ZEROS_ADDRESS, 1500 /* defaultMtu */);
@@ -174,7 +172,6 @@
     @Mock private DadProxy mDadProxy;
     @Mock private RouterAdvertisementDaemon mRaDaemon;
     @Mock private IpServer.Dependencies mDependencies;
-    @Mock private PrivateAddressCoordinator mAddressCoordinator;
     @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
@@ -196,6 +193,12 @@
 
     private void initStateMachine(int interfaceType, boolean usingLegacyDhcp,
             boolean usingBpfOffload) throws Exception {
+        initStateMachine(interfaceType, usingLegacyDhcp, usingBpfOffload,
+                false /* shouldEnableWifiP2pDedicatedIp */);
+    }
+
+    private void initStateMachine(int interfaceType, boolean usingLegacyDhcp,
+            boolean usingBpfOffload, boolean shouldEnableWifiP2pDedicatedIp) throws Exception {
         when(mDependencies.getDadProxy(any(), any())).thenReturn(mDadProxy);
         when(mDependencies.getRouterAdvertisementDaemon(any())).thenReturn(mRaDaemon);
         when(mDependencies.getInterfaceParams(IFACE_NAME)).thenReturn(TEST_IFACE_PARAMS);
@@ -213,6 +216,8 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(usingBpfOffload);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(usingLegacyDhcp);
         when(mTetherConfig.getP2pLeasesSubnetPrefixLength()).thenReturn(P2P_SUBNET_PREFIX_LENGTH);
+        when(mTetherConfig.shouldEnableWifiP2pDedicatedIp())
+                .thenReturn(shouldEnableWifiP2pDedicatedIp);
         when(mBpfCoordinator.isUsingBpfOffload()).thenReturn(usingBpfOffload);
         mIpServer = createIpServer(interfaceType);
         mIpServer.start();
@@ -252,9 +257,9 @@
             verify(mBpfCoordinator).updateIpv6UpstreamInterface(
                     mIpServer, interfaceParams.index, upstreamPrefixes);
         }
-        reset(mNetd, mBpfCoordinator, mCallback, mAddressCoordinator);
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(mTestAddress);
+        reset(mNetd, mBpfCoordinator, mCallback, mRoutingCoordinatorManager);
+        when(mRoutingCoordinatorManager.requestStickyDownstreamAddress(anyInt(), anyInt(),
+                any())).thenReturn(mTestAddress);
     }
 
     @SuppressWarnings("DoNotCall") // Ignore warning for synchronous to call to Thread.run()
@@ -275,8 +280,9 @@
     @Before public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         when(mSharedLog.forSubComponent(anyString())).thenReturn(mSharedLog);
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(mTestAddress);
+        when(mRoutingCoordinatorManager.requestStickyDownstreamAddress(anyInt(), anyInt(),
+                any())).thenReturn(mTestAddress);
+        when(mRoutingCoordinatorManager.requestDownstreamAddress(any())).thenReturn(mTestAddress);
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(DEFAULT_USING_BPF_OFFLOAD);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(false /* default value */);
 
@@ -288,7 +294,7 @@
         mLooper = new TestLooper();
         mHandler = new Handler(mLooper.getLooper());
         return new IpServer(IFACE_NAME, mHandler, interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mRoutingCoordinatorManager, mCallback, mTetherConfig, mAddressCoordinator,
+                mRoutingCoordinatorManager, mCallback, mTetherConfig,
                 mTetheringMetrics, mDependencies);
 
     }
@@ -340,10 +346,14 @@
         initStateMachine(TETHERING_BLUETOOTH);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         if (isAtLeastT()) {
-            inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                    eq(CONNECTIVITY_SCOPE_GLOBAL), eq(true));
+            inOrder.verify(mRoutingCoordinatorManager)
+                    .requestStickyDownstreamAddress(
+                            eq(TETHERING_BLUETOOTH),
+                            eq(CONNECTIVITY_SCOPE_GLOBAL),
+                            any());
+            inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
             inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                     IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         }
@@ -364,7 +374,7 @@
         initTetheredStateMachine(TETHERING_BLUETOOTH, null);
 
         dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
+        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);
@@ -375,7 +385,7 @@
                     argThat(cfg -> assertContainsFlag(cfg.flags, IF_STATE_DOWN)));
         }
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg -> cfg.flags.length == 0));
-        inOrder.verify(mAddressCoordinator).releaseDownstream(any());
+        inOrder.verify(mRoutingCoordinatorManager).releaseDownstream(any());
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_AVAILABLE, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
@@ -383,7 +393,7 @@
         verify(mTetheringMetrics).updateErrorCode(eq(TETHERING_BLUETOOTH),
                 eq(TETHER_ERROR_NO_ERROR));
         verify(mTetheringMetrics).sendReport(eq(TETHERING_BLUETOOTH));
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
@@ -391,9 +401,10 @@
         initStateMachine(TETHERING_USB);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_GLOBAL), eq(true));
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_GLOBAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                 IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
@@ -405,17 +416,18 @@
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), mLinkPropertiesCaptor.capture());
         assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
-    public void canBeTetheredAsWifiP2p() throws Exception {
+    public void canBeTetheredAsWifiP2p_NotUsingDedicatedIp() throws Exception {
         initStateMachine(TETHERING_WIFI_P2P);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true));
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_LOCAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                   IFACE_NAME.equals(cfg.ifName) && assertNotContainsFlag(cfg.flags, IF_STATE_UP)));
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
@@ -427,7 +439,35 @@
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), mLinkPropertiesCaptor.capture());
         assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
+    }
+
+    @Test
+    public void canBeTetheredAsWifiP2p_UsingDedicatedIp() throws Exception {
+        initStateMachine(TETHERING_WIFI_P2P, false /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD,
+                true /* shouldEnableWifiP2pDedicatedIp */);
+
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        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.
+        inOrder.verify(mRoutingCoordinatorManager, never())
+                .requestStickyDownstreamAddress(anyInt(), anyInt(), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
+        inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
+                IFACE_NAME.equals(cfg.ifName) && assertNotContainsFlag(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());
+        inOrder.verify(mCallback).updateInterfaceState(
+                mIpServer, STATE_LOCAL_ONLY, TETHER_ERROR_NO_ERROR);
+        inOrder.verify(mCallback).updateLinkProperties(
+                eq(mIpServer), mLinkPropertiesCaptor.capture());
+        assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
+        assertEquals(List.of(new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS)),
+                mLinkPropertiesCaptor.getValue().getLinkAddresses());
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
@@ -533,15 +573,9 @@
         initTetheredStateMachine(TETHERING_BLUETOOTH, UPSTREAM_IFACE);
 
         clearInvocations(
-                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
+                mNetd, mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
         dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
-        InOrder inOrder =
-                inOrder(
-                        mNetd,
-                        mCallback,
-                        mAddressCoordinator,
-                        mBpfCoordinator,
-                        mRoutingCoordinatorManager);
+        InOrder inOrder = inOrder(mNetd, mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mRoutingCoordinatorManager)
                 .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
@@ -556,15 +590,14 @@
         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(mAddressCoordinator).releaseDownstream(any());
+        inOrder.verify(mRoutingCoordinatorManager).releaseDownstream(any());
         inOrder.verify(mBpfCoordinator).tetherOffloadClientClear(mIpServer);
         inOrder.verify(mBpfCoordinator).removeIpServer(mIpServer);
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_AVAILABLE, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), any(LinkProperties.class));
-        verifyNoMoreInteractions(
-                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager, mBpfCoordinator);
     }
 
     @Test
@@ -701,9 +734,10 @@
 
         final ArgumentCaptor<LinkProperties> lpCaptor =
                 ArgumentCaptor.forClass(LinkProperties.class);
-        InOrder inOrder = inOrder(mNetd, mCallback, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true));
+        InOrder inOrder = inOrder(mNetd, mCallback, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_LOCAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         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),
@@ -711,18 +745,18 @@
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_LOCAL_ONLY, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(eq(mIpServer), lpCaptor.capture());
-        verifyNoMoreInteractions(mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mCallback, mRoutingCoordinatorManager);
 
         // Simulate the DHCP server receives DHCPDECLINE on MirrorLink and then signals
         // onNewPrefixRequest callback.
         final LinkAddress newAddress = new LinkAddress("192.168.100.125/24");
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(newAddress);
+        when(mRoutingCoordinatorManager.requestDownstreamAddress(any())).thenReturn(newAddress);
         eventCallbacks.onNewPrefixRequest(new IpPrefix("192.168.42.0/24"));
         mLooper.dispatchAll();
 
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(false));
+        inOrder.verify(mRoutingCoordinatorManager, never())
+                .requestStickyDownstreamAddress(anyInt(), anyInt(), any());
+        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(any());
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mCallback).updateLinkProperties(eq(mIpServer), lpCaptor.capture());
         verifyNoMoreInteractions(mCallback);
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 5d22977..dd10cc3 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
@@ -2032,7 +2032,7 @@
         final BpfCoordinator coordinator = makeBpfCoordinator();
         initBpfCoordinatorForRule4(coordinator);
         resetNetdAndBpfMaps();
-        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Prepare add/delete rule events.
         final ArrayList<ConntrackEvent> addRuleEvents = new ArrayList<>();
@@ -2049,49 +2049,44 @@
         // Add rules, verify counter increases.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(addRuleEvents.get(i));
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? i + 1 : 0);
+            assertEquals(supportActiveSessionsMetrics ? i + 1 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Add the same events again should not increase the counter because
         // all events are already exist.
         for (final ConntrackEvent event : addRuleEvents) {
             mConsumer.accept(event);
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+            assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Verify removing non-existent items won't change the counters.
         for (int i = 5; i < 8; i++) {
             mConsumer.accept(new TestConntrackEvent.Builder().setMsgType(
                     IPCTNL_MSG_CT_DELETE).setProto(IPPROTO_TCP).setRemotePort(i).build());
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+            assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Verify remove the rules decrease the counter.
         // Note the max counter returns the max, so it returns the count before deleting.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(delRuleEvents.get(i));
-            assertEquals(supportActiveSessionsMetrics ? 4 - i : 0,
-                    mConsumer.getCurrentConnectionCount());
-            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
-                    mConsumer.getLastMaxConnectionCount());
-            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
-                    mConsumer.getLastMaxConnectionAndResetToCurrent());
         }
+        // The maximum number of rules observed is still 5.
+        assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // After the reset, the maximum number of rules observed is 0.
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Verify remove these rules again doesn't decrease the counter.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(delRuleEvents.get(i));
-            assertConsumerCountersEquals(0);
+            assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
         }
     }
 
-    // Helper method to assert all counter values inside consumer.
-    private void assertConsumerCountersEquals(int expectedCount) {
-        assertEquals(expectedCount, mConsumer.getCurrentConnectionCount());
-        assertEquals(expectedCount, mConsumer.getLastMaxConnectionCount());
-        assertEquals(expectedCount, mConsumer.getLastMaxConnectionAndResetToCurrent());
-    }
-
     @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
     // BPF IPv4 forwarding only supports on S+.
     @IgnoreUpTo(Build.VERSION_CODES.R)
@@ -2121,7 +2116,7 @@
         coordinator.tetherOffloadClientAdd(mIpServer, clientB);
         assertClientInfoExists(mIpServer, clientA);
         assertClientInfoExists(mIpServer, clientB);
-        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Add some rules for both clients.
         final int addr1RuleCount = 5;
@@ -2145,31 +2140,24 @@
                     .build());
         }
 
-        assertConsumerCountersEquals(
-                supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0);
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Remove 1 client. Since the 1st poll will return the LastMaxCounter and
-        // update it to the current, the max counter will be kept at 1st poll, while
-        // the current counter reflect the rule decreasing.
+        // update it to the current, the max counter will be kept at 1st poll.
         coordinator.tetherOffloadClientRemove(mIpServer, clientA);
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // And the counter be updated at 2nd poll.
         assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getCurrentConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionAndResetToCurrent());
-        // And all counters be updated at 2nd poll.
-        assertConsumerCountersEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0);
+                coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Remove other client.
         coordinator.tetherOffloadClientRemove(mIpServer, clientB);
-        assertEquals(0, mConsumer.getCurrentConnectionCount());
         assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionAndResetToCurrent());
-        // All counters reach zero at 2nd poll.
-        assertConsumerCountersEquals(0);
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // Verify the counter reach zero at 2nd poll.
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
     }
 
     @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
@@ -2191,7 +2179,7 @@
         final BpfCoordinator coordinator = makeBpfCoordinator();
         initBpfCoordinatorForRule4(coordinator);
         resetNetdAndBpfMaps();
-        assertConsumerCountersEquals(0);
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Prepare the counter value.
         for (int i = 0; i < 5; i++) {
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 c2e1617..16ebbbb 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
@@ -71,16 +71,19 @@
 import android.os.ResultReceiver;
 import android.os.SystemProperties;
 import android.os.UserHandle;
+import android.os.UserManager;
 import android.os.test.TestLooper;
 import android.provider.DeviceConfig;
 import android.provider.Settings;
 import android.telephony.CarrierConfigManager;
 
+import androidx.annotation.NonNull;
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.ArrayTrackRecord;
 import com.android.net.module.util.SharedLog;
 import com.android.testutils.DevSdkIgnoreRule;
 
@@ -114,6 +117,7 @@
     @Mock private EntitlementManager
             .OnTetherProvisioningFailedListener mTetherProvisioningFailedListener;
     @Mock private AlarmManager mAlarmManager;
+    @Mock private UserManager mUserManager;
     @Mock private PendingIntent mAlarmIntent;
 
     @Rule
@@ -126,9 +130,10 @@
     private MockContext mMockContext;
     private Runnable mPermissionChangeCallback;
 
-    private WrappedEntitlementManager mEnMgr;
+    private EntitlementManager mEnMgr;
     private TetheringConfiguration mConfig;
     private MockitoSession mMockingSession;
+    private TestDependencies mDeps;
 
     private class MockContext extends BroadcastInterceptingContext {
         MockContext(Context base) {
@@ -143,19 +148,30 @@
         @Override
         public Object getSystemService(String name) {
             if (Context.ALARM_SERVICE.equals(name)) return mAlarmManager;
+            if (Context.USER_SERVICE.equals(name)) return mUserManager;
 
             return super.getSystemService(name);
         }
+
+        @Override
+        public String getSystemServiceName(Class<?> serviceClass) {
+            if (UserManager.class.equals(serviceClass)) return Context.USER_SERVICE;
+            return super.getSystemServiceName(serviceClass);
+        }
+
+        @Override
+        public Context createContextAsUser(UserHandle user, int flags) {
+            return mMockContext; // Return self for easier test injection.
+        }
     }
 
-    public class WrappedEntitlementManager extends EntitlementManager {
+    class TestDependencies extends EntitlementManager.Dependencies {
         public int fakeEntitlementResult = TETHER_ERROR_ENTITLEMENT_UNKNOWN;
         public int uiProvisionCount = 0;
         public int silentProvisionCount = 0;
-
-        public WrappedEntitlementManager(Context ctx, Handler h, SharedLog log,
-                Runnable callback) {
-            super(ctx, h, log, callback);
+        TestDependencies(@NonNull Context context,
+                @NonNull SharedLog log) {
+            super(context, log);
         }
 
         public void reset() {
@@ -168,9 +184,12 @@
         protected Intent runUiTetherProvisioning(int type,
                 final TetheringConfiguration config, final ResultReceiver receiver) {
             Intent intent = super.runUiTetherProvisioning(type, config, receiver);
-            assertUiTetherProvisioningIntent(type, config, receiver, intent);
-            uiProvisionCount++;
-            receiver.send(fakeEntitlementResult, null);
+            if (intent != null) {
+                assertUiTetherProvisioningIntent(type, config, receiver, intent);
+                uiProvisionCount++;
+                // If the intent is null, the result is sent by the underlying method.
+                receiver.send(fakeEntitlementResult, null);
+            }
             return intent;
         }
 
@@ -195,7 +214,7 @@
             Intent intent = super.runSilentTetherProvisioning(type, config, receiver);
             assertSilentTetherProvisioning(type, config, intent);
             silentProvisionCount++;
-            addDownstreamMapping(type, fakeEntitlementResult);
+            mEnMgr.addDownstreamMapping(type, fakeEntitlementResult);
             return intent;
         }
 
@@ -217,6 +236,13 @@
             assertEquals(TEST_PACKAGE_NAME, pkgName);
             return mAlarmIntent;
         }
+
+        @Override
+        int getCurrentUser() {
+            // The result is not used, just override to bypass the need of accessing
+            // the static method.
+            return 0;
+        }
     }
 
     @Before
@@ -253,11 +279,13 @@
                 false);
         when(mResources.getString(R.string.config_wifi_tether_enable)).thenReturn("");
         when(mLog.forSubComponent(anyString())).thenReturn(mLog);
+        doReturn(true).when(mUserManager).isAdminUser();
 
         mMockContext = new MockContext(mContext);
+        mDeps = new TestDependencies(mMockContext, mLog);
         mPermissionChangeCallback = spy(() -> { });
-        mEnMgr = new WrappedEntitlementManager(mMockContext, new Handler(mLooper.getLooper()), mLog,
-                mPermissionChangeCallback);
+        mEnMgr = new EntitlementManager(mMockContext, new Handler(mLooper.getLooper()), mLog,
+                mPermissionChangeCallback, mDeps);
         mEnMgr.setOnTetherProvisioningFailedListener(mTetherProvisioningFailedListener);
         mConfig = new FakeTetheringConfiguration(mMockContext, mLog, INVALID_SUBSCRIPTION_ID);
         mEnMgr.setTetheringConfigurationFetcher(() -> {
@@ -320,102 +348,46 @@
     @Test
     public void testRequestLastEntitlementCacheValue() throws Exception {
         // 1. Entitlement check is not required.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        ResultReceiver receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
 
         setupForRequiredProvisioning();
         // 2. No cache value and don't need to run entitlement check.
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_ENTITLEMENT_UNKNOWN, false);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 3. No cache value and ui entitlement check is needed.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, true);
+        assertEquals(1, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 4. Cache value is TETHER_ERROR_PROVISIONING_FAILED and don't need to run entitlement
         // check.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, false);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 5. Cache value is TETHER_ERROR_PROVISIONING_FAILED and ui entitlement check is needed.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
+        assertEquals(1, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 6. Cache value is TETHER_ERROR_NO_ERROR.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_NO_ERROR, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_NO_ERROR, true);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 7. Test get value for other downstream type.
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_USB, receiver, false);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_ENTITLEMENT_UNKNOWN, false);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 8. Test get value for invalid downstream type.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_ENTITLEMENT_UNKNOWN, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI_P2P, receiver, true);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI_P2P, TETHER_ERROR_ENTITLEMENT_UNKNOWN, true);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
     }
 
     private void assertPermissionChangeCallback(InOrder inOrder) {
@@ -431,7 +403,7 @@
         final InOrder inOrder = inOrder(mPermissionChangeCallback);
         setupForRequiredProvisioning();
         mEnMgr.notifyUpstream(true);
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
         mLooper.dispatchAll();
         // Permitted: true -> false
@@ -443,7 +415,7 @@
         // Permitted: false -> false
         assertNoPermissionChange(inOrder);
 
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
         mLooper.dispatchAll();
         // Permitted: false -> true
@@ -456,21 +428,21 @@
         final InOrder inOrder = inOrder(mPermissionChangeCallback);
         setupForRequiredProvisioning();
         mEnMgr.notifyUpstream(true);
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
         mLooper.dispatchAll();
         // Permitted: true -> false
         assertPermissionChangeCallback(inOrder);
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
 
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
         mLooper.dispatchAll();
         // Permitted: false -> false
         assertNoPermissionChange(inOrder);
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
 
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.startProvisioningIfNeeded(TETHERING_BLUETOOTH, true);
         mLooper.dispatchAll();
         // Permitted: false -> false
@@ -483,14 +455,14 @@
         final InOrder inOrder = inOrder(mPermissionChangeCallback);
         setupForRequiredProvisioning();
         mEnMgr.notifyUpstream(true);
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
         mLooper.dispatchAll();
         // Permitted: true -> true
         assertNoPermissionChange(inOrder);
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
 
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
         mLooper.dispatchAll();
         // Permitted: true -> true
@@ -519,89 +491,89 @@
         final InOrder inOrder = inOrder(mPermissionChangeCallback);
         setupForRequiredProvisioning();
         // 1. start ui provisioning, upstream is mobile
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
         mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(1, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         // Permitted: true -> true
         assertNoPermissionChange(inOrder);
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 2. start no-ui provisioning
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, false);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        assertEquals(1, mEnMgr.silentProvisionCount);
+        assertEquals(0, mDeps.uiProvisionCount);
+        assertEquals(1, mDeps.silentProvisionCount);
         // Permitted: true -> true
         assertNoPermissionChange(inOrder);
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 3. tear down mobile, then start ui provisioning
         mEnMgr.notifyUpstream(false);
         mLooper.dispatchAll();
         mEnMgr.startProvisioningIfNeeded(TETHERING_BLUETOOTH, true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(0, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         assertNoPermissionChange(inOrder);
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 4. switch upstream back to mobile
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(1, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         // Permitted: true -> true
         assertNoPermissionChange(inOrder);
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 5. tear down mobile, then switch SIM
         mEnMgr.notifyUpstream(false);
         mLooper.dispatchAll();
         mEnMgr.reevaluateSimCardProvisioning(mConfig);
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(0, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         assertNoPermissionChange(inOrder);
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 6. switch upstream back to mobile again
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        assertEquals(3, mEnMgr.silentProvisionCount);
+        assertEquals(0, mDeps.uiProvisionCount);
+        assertEquals(3, mDeps.silentProvisionCount);
         // Permitted: true -> false
         assertPermissionChangeCallback(inOrder);
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 7. start ui provisioning, upstream is mobile, downstream is ethernet
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.startProvisioningIfNeeded(TETHERING_ETHERNET, true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(1, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         // Permitted: false -> true
         assertPermissionChangeCallback(inOrder);
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
-        mEnMgr.reset();
+        mDeps.reset();
 
         // 8. downstream is invalid
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI_P2P, true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        assertEquals(0, mEnMgr.silentProvisionCount);
+        assertEquals(0, mDeps.uiProvisionCount);
+        assertEquals(0, mDeps.silentProvisionCount);
         assertNoPermissionChange(inOrder);
-        mEnMgr.reset();
+        mDeps.reset();
     }
 
     @Test
@@ -609,18 +581,74 @@
         setupForRequiredProvisioning();
         verify(mTetherProvisioningFailedListener, times(0))
                 .onTetherProvisioningFailed(TETHERING_WIFI, FAILED_TETHERING_REASON);
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
+        assertEquals(1, mDeps.uiProvisionCount);
         verify(mTetherProvisioningFailedListener, times(1))
                 .onTetherProvisioningFailed(TETHERING_WIFI, FAILED_TETHERING_REASON);
     }
 
     @Test
-    public void testsetExemptedDownstreamType() throws Exception {
+    public void testUiProvisioningMultiUser() {
+        doTestUiProvisioningMultiUser(true, 1);
+        doTestUiProvisioningMultiUser(false, 1);
+    }
+
+    private static class TestableResultReceiver extends ResultReceiver {
+        private static final long DEFAULT_TIMEOUT_MS = 200L;
+        private final ArrayTrackRecord<Integer>.ReadHead mHistory =
+                new ArrayTrackRecord<Integer>().newReadHead();
+
+        TestableResultReceiver(Handler handler) {
+            super(handler);
+        }
+
+        @Override
+        protected void onReceiveResult(int resultCode, Bundle resultData) {
+            mHistory.add(resultCode);
+        }
+
+        void expectResult(int resultCode) {
+            final int event = mHistory.poll(DEFAULT_TIMEOUT_MS, it -> true);
+            assertEquals(resultCode, event);
+        }
+    }
+
+    void assertLatestEntitlementResult(int downstreamType, int expectedCode,
+            boolean showEntitlementUi) {
+        final TestableResultReceiver receiver = new TestableResultReceiver(null);
+        mEnMgr.requestLatestTetheringEntitlementResult(downstreamType, receiver, showEntitlementUi);
+        mLooper.dispatchAll();
+        receiver.expectResult(expectedCode);
+    }
+
+    private void doTestUiProvisioningMultiUser(boolean isAdminUser, int expectedUiProvisionCount) {
+        setupForRequiredProvisioning();
+        doReturn(isAdminUser).when(mUserManager).isAdminUser();
+
+        mDeps.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mEnMgr.notifyUpstream(true);
+        mLooper.dispatchAll();
+        mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
+        mLooper.dispatchAll();
+        assertEquals(expectedUiProvisionCount, mDeps.uiProvisionCount);
+        if (expectedUiProvisionCount == 0) { // Failed to launch entitlement UI.
+            assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_PROVISIONING_FAILED, false);
+            verify(mTetherProvisioningFailedListener).onTetherProvisioningFailed(TETHERING_USB,
+                    FAILED_TETHERING_REASON);
+        } else {
+            assertLatestEntitlementResult(TETHERING_USB, TETHER_ERROR_NO_ERROR, false);
+            verify(mTetherProvisioningFailedListener, never()).onTetherProvisioningFailed(anyInt(),
+                    anyString());
+        }
+    }
+
+    @Test
+    public void testSetExemptedDownstreamType() {
         setupForRequiredProvisioning();
         // Cellular upstream is not permitted when no entitlement result.
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
@@ -631,7 +659,7 @@
         assertTrue(mEnMgr.isCellularUpstreamPermitted());
 
         // If second downstream run entitlement check fail, cellular upstream is not permitted.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
         mEnMgr.startProvisioningIfNeeded(TETHERING_USB, true);
@@ -639,7 +667,7 @@
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
 
         // When second downstream is down, exempted downstream can use cellular upstream.
-        assertEquals(1, mEnMgr.uiProvisionCount);
+        assertEquals(1, mDeps.uiProvisionCount);
         verify(mTetherProvisioningFailedListener).onTetherProvisioningFailed(TETHERING_USB,
                 FAILED_TETHERING_REASON);
         mEnMgr.stopProvisioningIfNeeded(TETHERING_USB);
@@ -660,7 +688,7 @@
         setupForRequiredProvisioning();
         assertFalse(mEnMgr.isCellularUpstreamPermitted());
 
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         mEnMgr.notifyUpstream(true);
         mLooper.dispatchAll();
         mEnMgr.startProvisioningIfNeeded(TETHERING_WIFI, true);
@@ -682,17 +710,10 @@
             throws Exception {
         setupCarrierConfig(false);
         setupForRequiredProvisioning();
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
-        ResultReceiver receiver = new ResultReceiver(null) {
-            @Override
-            protected void onReceiveResult(int resultCode, Bundle resultData) {
-                assertEquals(TETHER_ERROR_PROVISIONING_FAILED, resultCode);
-            }
-        };
-        mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
-        mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        assertLatestEntitlementResult(TETHERING_WIFI, TETHER_ERROR_PROVISIONING_FAILED, false);
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
     }
 
     @Test
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 3c07580..7fcc5f1 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/MockTetheringService.java
@@ -32,6 +32,8 @@
 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 int mMockCallingUid;
 
     @Override
     public IBinder onBind(Intent intent) {
@@ -61,6 +63,17 @@
         return super.checkCallingOrSelfPermission(permission);
     }
 
+    @Override
+    boolean checkPackageNameMatchesUid(@NonNull Context context, int uid,
+            @NonNull String callingPackage) {
+        return mMockedPackageUids.getOrDefault(callingPackage, 0) == uid;
+    }
+
+    @Override
+    int getBinderCallingUid() {
+        return mMockCallingUid;
+    }
+
     public Tethering getTethering() {
         return mTethering;
     }
@@ -91,5 +104,19 @@
                 mMockedPermissions.put(permission, granted);
             }
         }
+
+        /**
+         * Mock a package name matching a uid.
+         */
+        public void setPackageNameUid(String packageName, int uid) {
+            mMockedPackageUids.put(packageName, uid);
+        }
+
+        /**
+         * Mock a package name matching a uid.
+         */
+        public void setCallingUid(int uid) {
+            mMockCallingUid = uid;
+        }
     }
 }
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 2298a1a..f9e3a6a 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -16,7 +16,6 @@
 package com.android.networkstack.tethering;
 
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
-import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -25,13 +24,20 @@
 import static android.net.TetheringManager.TETHERING_USB;
 import static android.net.TetheringManager.TETHERING_WIFI;
 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;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.never;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
@@ -45,10 +51,14 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.ip.IpServer;
+import android.os.IBinder;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.IIpv4PrefixRequest;
+import com.android.net.module.util.PrivateAddressCoordinator;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -70,7 +80,7 @@
     @Mock private IpServer mWifiP2pIpServer;
     @Mock private Context mContext;
     @Mock private ConnectivityManager mConnectivityMgr;
-    @Mock private TetheringConfiguration mConfig;
+    @Mock private PrivateAddressCoordinator.Dependencies mDeps;
 
     private PrivateAddressCoordinator mPrivateAddressCoordinator;
     private final LinkAddress mBluetoothAddress = new LinkAddress("192.168.44.1/24");
@@ -90,12 +100,26 @@
             new IpPrefix("172.16.0.0/12"),
             new IpPrefix("10.0.0.0/8")));
 
+    private void setUpIpServer(IpServer ipServer, int interfaceType) throws Exception {
+        when(ipServer.interfaceType()).thenReturn(interfaceType);
+        final IIpv4PrefixRequest request = mock(IIpv4PrefixRequest.class);
+        when(ipServer.getIpv4PrefixRequest()).thenReturn(request);
+        when(request.asBinder()).thenReturn(mock(IBinder.class));
+        doAnswer(
+                        invocation -> {
+                            ipServer.sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+                            return null;
+                        })
+                .when(request)
+                .onIpv4PrefixConflict(any());
+    }
+
     private void setUpIpServers() throws Exception {
-        when(mUsbIpServer.interfaceType()).thenReturn(TETHERING_USB);
-        when(mEthernetIpServer.interfaceType()).thenReturn(TETHERING_ETHERNET);
-        when(mHotspotIpServer.interfaceType()).thenReturn(TETHERING_WIFI);
-        when(mLocalHotspotIpServer.interfaceType()).thenReturn(TETHERING_WIFI);
-        when(mWifiP2pIpServer.interfaceType()).thenReturn(TETHERING_WIFI_P2P);
+        setUpIpServer(mUsbIpServer, TETHERING_USB);
+        setUpIpServer(mEthernetIpServer, TETHERING_ETHERNET);
+        setUpIpServer(mHotspotIpServer, TETHERING_WIFI);
+        setUpIpServer(mLocalHotspotIpServer, TETHERING_WIFI);
+        setUpIpServer(mWifiP2pIpServer, TETHERING_WIFI_P2P);
     }
 
     @Before
@@ -103,74 +127,58 @@
         MockitoAnnotations.initMocks(this);
 
         when(mContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(mConnectivityMgr);
+        when(mContext.getSystemService(ConnectivityManager.class)).thenReturn(mConnectivityMgr);
         when(mConnectivityMgr.getAllNetworks()).thenReturn(mAllNetworks);
-        when(mConfig.shouldEnableWifiP2pDedicatedIp()).thenReturn(false);
-        when(mConfig.isRandomPrefixBaseEnabled()).thenReturn(false);
         setUpIpServers();
-        mPrivateAddressCoordinator = spy(new PrivateAddressCoordinator(mContext, mConfig));
+        mPrivateAddressCoordinator =
+                spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
     }
 
-    private LinkAddress requestDownstreamAddress(final IpServer ipServer, int scope,
-            boolean useLastAddress) {
-        final LinkAddress address = mPrivateAddressCoordinator.requestDownstreamAddress(
-                ipServer, scope, useLastAddress);
+    private LinkAddress requestStickyDownstreamAddress(final IpServer ipServer, int scope)
+            throws Exception {
+        final LinkAddress address =
+                mPrivateAddressCoordinator.requestStickyDownstreamAddress(
+                        ipServer.interfaceType(), scope, ipServer.getIpv4PrefixRequest());
         when(ipServer.getAddress()).thenReturn(address);
         return address;
     }
 
+    private LinkAddress requestDownstreamAddress(final IpServer ipServer) throws Exception {
+        final LinkAddress address =
+                mPrivateAddressCoordinator.requestDownstreamAddress(
+                        ipServer.getIpv4PrefixRequest());
+        when(ipServer.getAddress()).thenReturn(address);
+        return address;
+    }
+
+    private void releaseDownstream(final IpServer ipServer) {
+        mPrivateAddressCoordinator.releaseDownstream(ipServer.getIpv4PrefixRequest());
+    }
+
+    private void updateUpstreamPrefix(UpstreamNetworkState ns) {
+        mPrivateAddressCoordinator.updateUpstreamPrefix(
+                ns.linkProperties, ns.networkCapabilities, ns.network);
+    }
+
     @Test
     public void testRequestDownstreamAddressWithoutUsingLastAddress() throws Exception {
         final IpPrefix bluetoothPrefix = asIpPrefix(mBluetoothAddress);
-        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix hotspotPrefix = asIpPrefix(address);
         assertNotEquals(hotspotPrefix, bluetoothPrefix);
 
-        final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix newHotspotPrefix = asIpPrefix(newAddress);
         assertNotEquals(hotspotPrefix, newHotspotPrefix);
         assertNotEquals(bluetoothPrefix, newHotspotPrefix);
 
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(usbPrefix, bluetoothPrefix);
         assertNotEquals(usbPrefix, newHotspotPrefix);
 
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
-    }
-
-    @Test
-    public void testSanitizedAddress() throws Exception {
-        int fakeSubAddr = 0x2b00; // 43.0.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        LinkAddress actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.43.2/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2d01; // 45.1.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.45.2/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2eff; // 46.255.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.46.254/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2f05; // 47.5.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.47.5/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
     }
 
     @Test
@@ -178,63 +186,54 @@
         // - Test bluetooth prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(mBluetoothAddress.getAddress().getAddress()));
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress hotspotAddress = requestStickyDownstreamAddress(mHotspotIpServer,
+                CONNECTIVITY_SCOPE_GLOBAL);
         final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
         assertNotEquals(asIpPrefix(mBluetoothAddress), hotspotPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mHotspotIpServer);
 
         // - Test previous enabled hotspot prefix(cached prefix) is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(hotspotAddress.getAddress().getAddress()));
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(asIpPrefix(mBluetoothAddress), usbPrefix);
         assertNotEquals(hotspotPrefix, usbPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mUsbIpServer);
 
         // - Test wifi p2p prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(mLegacyWifiP2pAddress.getAddress().getAddress()));
-        final LinkAddress etherAddress = requestDownstreamAddress(mEthernetIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress etherAddress = requestDownstreamAddress(mEthernetIpServer);
         final IpPrefix etherPrefix = asIpPrefix(etherAddress);
         assertNotEquals(asIpPrefix(mLegacyWifiP2pAddress), etherPrefix);
         assertNotEquals(asIpPrefix(mBluetoothAddress), etherPrefix);
         assertNotEquals(hotspotPrefix, etherPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mEthernetIpServer);
+        releaseDownstream(mEthernetIpServer);
     }
 
     @Test
     public void testRequestLastDownstreamAddress() throws Exception {
-        final int fakeHotspotSubAddr = 0x2b05; // 43.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong wifi prefix: ", new LinkAddress("192.168.43.5/24"), hotspotAddress);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong wifi prefix: ", new LinkAddress("192.168.45.5/24"), usbAddress);
+        final LinkAddress usbAddress =
+                requestStickyDownstreamAddress(mUsbIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
 
-        final int newFakeSubAddr = 0x3c05;
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
-
-        final LinkAddress newHotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress newHotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertEquals(hotspotAddress, newHotspotAddress);
-        final LinkAddress newUsbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress newUsbAddress =
+                requestStickyDownstreamAddress(mUsbIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertEquals(usbAddress, newUsbAddress);
 
         final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
                 new LinkAddress("192.168.88.23/16"), null,
                 makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
+        updateUpstreamPrefix(wifiUpstream);
         verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
         verify(mUsbIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
     }
@@ -261,269 +260,35 @@
     }
 
     @Test
-    public void testNoConflictUpstreamPrefix() throws Exception {
-        final int fakeHotspotSubAddr = 0x2b05; // 43.5
-        final IpPrefix predefinedPrefix = new IpPrefix("192.168.43.0/24");
-        // Force always get subAddress "43.5" for conflict testing.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
-        // - Enable hotspot with prefix 192.168.43.0/24
-        final LinkAddress hotspotAddr = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddr);
-        assertEquals("Wrong wifi prefix: ", predefinedPrefix, hotspotPrefix);
-        // - test mobile network with null NetworkCapabilities. Ideally this should not happen
-        // because NetworkCapabilities update should always happen before LinkProperties update
-        // and the UpstreamNetworkState update, just make sure no crash in this case.
-        final UpstreamNetworkState noCapUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), null, null);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(noCapUpstream);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - test mobile upstream with no address.
-        final UpstreamNetworkState noAddress = buildUpstreamNetworkState(mMobileNetwork,
-                null, null, makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(noCapUpstream);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v6 only mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v6OnlyMobile = buildUpstreamNetworkState(mMobileNetwork,
-                null, new LinkAddress("2001:db8::/64"),
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v6OnlyMobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mMobileNetwork);
-        // - Update v4 only mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v4OnlyMobile = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyMobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v4v6 mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v4v6Mobile = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), new LinkAddress("2001:db8::/64"),
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4v6Mobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v6 only wifi network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v6OnlyWifi = buildUpstreamNetworkState(mWifiNetwork,
-                null, new LinkAddress("2001:db8::/64"), makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v6OnlyWifi);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mWifiNetwork);
-        // - Update vpn network, it conflict with hotspot prefix but VPN networks are ignored.
-        final UpstreamNetworkState v4OnlyVpn = buildUpstreamNetworkState(mVpnNetwork,
-                new LinkAddress("192.168.43.5/24"), null, makeNetworkCapabilities(TRANSPORT_VPN));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyVpn);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v4 only wifi network, it conflict with hotspot prefix.
-        final UpstreamNetworkState v4OnlyWifi = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.43.5/24"), null, makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyWifi);
-        verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        reset(mHotspotIpServer);
-        // - Restart hotspot again and its prefix is different previous.
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        final LinkAddress hotspotAddr2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix hotspotPrefix2 = asIpPrefix(hotspotAddr2);
-        assertNotEquals(hotspotPrefix, hotspotPrefix2);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyWifi);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Usb tethering can be enabled and its prefix is different with conflict one.
-        final LinkAddress usbAddr = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix usbPrefix = asIpPrefix(usbAddr);
-        assertNotEquals(predefinedPrefix, usbPrefix);
-        assertNotEquals(hotspotPrefix2, usbPrefix);
-        // - Disable wifi upstream, then wifi's prefix can be selected again.
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mWifiNetwork);
-        final LinkAddress ethAddr = requestDownstreamAddress(mEthernetIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix ethPrefix = asIpPrefix(ethAddr);
-        assertEquals(predefinedPrefix, ethPrefix);
+    public void testChooseDownstreamAddress_noUpstreamConflicts() throws Exception {
+        LinkAddress address = new LinkAddress("192.168.42.42/24");
+        UpstreamNetworkState ns = buildUpstreamNetworkState(mMobileNetwork, address, null, null);
+        updateUpstreamPrefix(ns);
+        // try to look for a /24 in upstream that does not conflict with upstream -> impossible.
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(asIpPrefix(address)));
+
+        IpPrefix prefix = new IpPrefix("192.168.0.0/16");
+        LinkAddress chosenAddress = mPrivateAddressCoordinator.chooseDownstreamAddress(prefix);
+        assertNotNull(chosenAddress);
+        assertTrue(prefix.containsPrefix(asIpPrefix(chosenAddress)));
     }
 
     @Test
-    public void testChooseAvailablePrefix() throws Exception {
-        final int randomAddress = 0x8605; // 134.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomAddress);
-        final LinkAddress addr0 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        // Check whether return address is prefix 192.168.0.0/16 + subAddress 0.0.134.5.
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.134.5/24"), addr0);
-        final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.134.13/26"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
-
-        // Check whether return address is next prefix of 192.168.134.0/24.
-        final LinkAddress addr1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.135.5/24"), addr1);
-        final UpstreamNetworkState wifiUpstream2 = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.149.16/19"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream2);
-
-
-        // The conflict range is 128 ~ 159, so the address is 192.168.160.5/24.
-        final LinkAddress addr2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.160.5/24"), addr2);
-        final UpstreamNetworkState mobileUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("192.168.129.53/18"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        // Update another conflict upstream which is covered by the previous one (but not the first
-        // one) and verify whether this would affect the result.
-        final UpstreamNetworkState mobileUpstream2 = buildUpstreamNetworkState(mMobileNetwork2,
-                new LinkAddress("192.168.170.7/19"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream2);
-
-        // The conflict range are 128 ~ 159 and 159 ~ 191, so the address is 192.168.192.5/24.
-        final LinkAddress addr3 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.192.5/24"), addr3);
-        final UpstreamNetworkState mobileUpstream3 = buildUpstreamNetworkState(mMobileNetwork3,
-                new LinkAddress("192.168.188.133/17"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream3);
-
-        // Conflict range: 128 ~ 255. The next available address is 192.168.0.5 because
-        // 192.168.134/24 ~ 192.168.255.255/24 is not available.
-        final LinkAddress addr4 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.0.5/24"), addr4);
-        final UpstreamNetworkState mobileUpstream4 = buildUpstreamNetworkState(mMobileNetwork4,
-                new LinkAddress("192.168.3.59/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream4);
-
-        // Conflict ranges: 128 ~ 255 and 0 ~ 7, so the address is 192.168.8.5/24.
-        final LinkAddress addr5 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.8.5/24"), addr5);
-        final UpstreamNetworkState mobileUpstream5 = buildUpstreamNetworkState(mMobileNetwork5,
-                new LinkAddress("192.168.68.43/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream5);
-
-        // Update an upstream that does *not* conflict, check whether return the same address
-        // 192.168.5/24.
-        final LinkAddress addr6 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.8.5/24"), addr6);
-        final UpstreamNetworkState mobileUpstream6 = buildUpstreamNetworkState(mMobileNetwork6,
-                new LinkAddress("192.168.10.97/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream6);
-
-        // Conflict ranges: 0 ~ 15 and 128 ~ 255, so the address is 192.168.16.5/24.
-        final LinkAddress addr7 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.16.5/24"), addr7);
-        final UpstreamNetworkState mobileUpstream7 = buildUpstreamNetworkState(mMobileNetwork6,
-                new LinkAddress("192.168.0.0/17"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream7);
-
-        // Choose prefix from next range(172.16.0.0/12) when no available prefix in 192.168.0.0/16.
-        final LinkAddress addr8 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.134.5/24"), addr8);
-    }
-
-    @Test
-    public void testChoosePrefixFromDifferentRanges() throws Exception {
-        final int randomAddress = 0x1f2b2a; // 31.43.42
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomAddress);
-        final LinkAddress classC1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        // Check whether return address is prefix 192.168.0.0/16 + subAddress 0.0.43.42.
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.43.42/24"), classC1);
-        final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.88.23/17"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // Check whether return address is next address of prefix 192.168.128.0/17.
-        final LinkAddress classC2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.128.42/24"), classC2);
-        final UpstreamNetworkState mobileUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("192.1.2.3/8"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // Check whether return address is under prefix 172.16.0.0/12.
-        final LinkAddress classB1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.31.43.42/24"), classB1);
-        final UpstreamNetworkState mobileUpstream2 = buildUpstreamNetworkState(mMobileNetwork2,
-                new LinkAddress("172.28.123.100/14"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream2);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // 172.28.0.0 ~ 172.31.255.255 is not available.
-        // Check whether return address is next address of prefix 172.16.0.0/14.
-        final LinkAddress classB2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.0.42/24"), classB2);
-
-        // Check whether new downstream is next address of address 172.16.0.42/24.
-        final LinkAddress classB3 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.1.42/24"), classB3);
-        final UpstreamNetworkState mobileUpstream3 = buildUpstreamNetworkState(mMobileNetwork3,
-                new LinkAddress("172.16.0.1/24"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream3);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verify(mUsbIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-
-        // Check whether return address is next address of prefix 172.16.1.42/24.
-        final LinkAddress classB4 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.2.42/24"), classB4);
-        final UpstreamNetworkState mobileUpstream4 = buildUpstreamNetworkState(mMobileNetwork4,
-                new LinkAddress("172.16.0.1/13"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream4);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verifyNotifyConflictAndRelease(mUsbIpServer);
-
-        // Check whether return address is next address of prefix 172.16.0.1/13.
-        final LinkAddress classB5 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.24.0.42/24"), classB5);
-        // Check whether return address is next address of prefix 172.24.0.42/24.
-        final LinkAddress classB6 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.24.1.42/24"), classB6);
-        final UpstreamNetworkState mobileUpstream5 = buildUpstreamNetworkState(mMobileNetwork5,
-                new LinkAddress("172.24.0.1/12"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream5);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verifyNotifyConflictAndRelease(mUsbIpServer);
-
-        // Check whether return address is prefix 10.0.0.0/8 + subAddress 0.31.43.42.
-        final LinkAddress classA1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("10.31.43.42/24"), classA1);
-        // Check whether new downstream is next address of address 10.31.43.42/24.
-        final LinkAddress classA2 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("10.31.44.42/24"), classA2);
+    public void testChooseDownstreamAddress_excludesWellKnownPrefixes() throws Exception {
+        IpPrefix prefix = new IpPrefix("192.168.0.0/24");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
+        prefix = new IpPrefix("192.168.100.0/24");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
+        prefix = new IpPrefix("10.3.0.0/16");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
     }
 
     private void verifyNotifyConflictAndRelease(final IpServer ipServer) throws Exception {
-        verify(ipServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.releaseDownstream(ipServer);
+        verify(ipServer).sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+        releaseDownstream(ipServer);
+        final int interfaceType = ipServer.interfaceType();
         reset(ipServer);
-        setUpIpServers();
+        setUpIpServer(ipServer, interfaceType);
     }
 
     private int getSubAddress(final byte... ipv4Address) {
@@ -534,50 +299,33 @@
     }
 
     private void assertReseveredWifiP2pPrefix() throws Exception {
-        LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        LinkAddress address =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         final IpPrefix hotspotPrefix = asIpPrefix(address);
         final IpPrefix legacyWifiP2pPrefix = asIpPrefix(mLegacyWifiP2pAddress);
         assertNotEquals(legacyWifiP2pPrefix, hotspotPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-    }
-
-    @Test
-    public void testEnableLegacyWifiP2PAddress() throws Exception {
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
-                getSubAddress(mLegacyWifiP2pAddress.getAddress().getAddress()));
-        // No matter #shouldEnableWifiP2pDedicatedIp() is enabled or not, legacy wifi p2p prefix
-        // is resevered.
-        assertReseveredWifiP2pPrefix();
-
-        when(mConfig.shouldEnableWifiP2pDedicatedIp()).thenReturn(true);
-        assertReseveredWifiP2pPrefix();
-
-        // If #shouldEnableWifiP2pDedicatedIp() is enabled, wifi P2P gets the configured address.
-        LinkAddress address = requestDownstreamAddress(mWifiP2pIpServer,
-                CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
-        assertEquals(mLegacyWifiP2pAddress, address);
-        mPrivateAddressCoordinator.releaseDownstream(mWifiP2pIpServer);
+        releaseDownstream(mHotspotIpServer);
     }
 
     @Test
     public void testEnableSapAndLohsConcurrently() throws Exception {
-        // 0x2b05 -> 43.5, 0x8605 -> 134.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(0x2b05, 0x8605);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
+        assertNotNull(hotspotAddress);
 
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong hotspot prefix: ", new LinkAddress("192.168.43.5/24"), hotspotAddress);
+        final LinkAddress localHotspotAddress =
+                requestStickyDownstreamAddress(mLocalHotspotIpServer, CONNECTIVITY_SCOPE_LOCAL);
+        assertNotNull(localHotspotAddress);
 
-        final LinkAddress localHotspotAddress = requestDownstreamAddress(mLocalHotspotIpServer,
-                CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
-        assertEquals("Wrong local hotspot prefix: ", new LinkAddress("192.168.134.5/24"),
-                localHotspotAddress);
+        final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
+        final IpPrefix localHotspotPrefix = asIpPrefix(localHotspotAddress);
+        assertFalse(hotspotPrefix.containsPrefix(localHotspotPrefix));
+        assertFalse(localHotspotPrefix.containsPrefix(hotspotPrefix));
     }
 
     @Test
     public void testStartedPrefixRange() throws Exception {
-        when(mConfig.isRandomPrefixBaseEnabled()).thenReturn(true);
+        when(mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)).thenReturn(true);
 
         startedPrefixBaseTest("192.168.0.0/16", 0);
 
@@ -602,10 +350,10 @@
 
     private void startedPrefixBaseTest(final String expected, final int randomIntForPrefixBase)
             throws Exception {
-        mPrivateAddressCoordinator = spy(new PrivateAddressCoordinator(mContext, mConfig));
+        mPrivateAddressCoordinator =
+                spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomIntForPrefixBase);
-        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix prefixBase = new IpPrefix(expected);
         assertTrue(address + " is not part of " + prefixBase,
                 prefixBase.containsPrefix(asIpPrefix(address)));
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/TetheringServiceTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
index c0d7ad4..cc80251 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
@@ -17,9 +17,11 @@
 package com.android.networkstack.tethering;
 
 import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+import static android.Manifest.permission.NETWORK_SETTINGS;
 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_VIRTUAL;
 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;
@@ -33,12 +35,16 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import android.app.AppOpsManager;
 import android.app.UiAutomation;
 import android.content.Intent;
 import android.net.IIntResultListener;
@@ -79,7 +85,9 @@
 public final class TetheringServiceTest {
     private static final String TEST_IFACE_NAME = "test_wlan0";
     private static final String TEST_CALLER_PKG = "com.android.shell";
+    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";
     @Mock private ITetheringEventCallback mITetheringEventCallback;
     @Rule public ServiceTestRule mServiceTestRule;
     private Tethering mTethering;
@@ -87,6 +95,7 @@
     private MockTetheringConnector mMockConnector;
     private ITetheringConnector mTetheringConnector;
     private UiAutomation mUiAutomation;
+    @Mock private AppOpsManager mAppOps;
 
     private class TestTetheringResult extends IIntResultListener.Stub {
         private int mResult = -1; // Default value that does not match any result code.
@@ -128,6 +137,10 @@
         mTetheringConnector = ITetheringConnector.Stub.asInterface(mMockConnector.getIBinder());
         final MockTetheringService service = mMockConnector.getService();
         mTethering = service.getTethering();
+        mMockConnector.setCallingUid(TEST_CALLER_UID);
+        mMockConnector.setPackageNameUid(TEST_CALLER_PKG, TEST_CALLER_UID);
+        doThrow(new SecurityException()).when(mAppOps).checkPackage(anyInt(),
+                eq(TEST_WRONG_PACKAGE));
     }
 
     @After
@@ -141,27 +154,43 @@
     }
 
     private void runAsNoPermission(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, new String[0]);
+        runTetheringCall(test, true /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                new String[0]);
     }
 
     private void runAsTetherPrivileged(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, TETHER_PRIVILEGED);
+        runTetheringCall(test, true /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                TETHER_PRIVILEGED);
     }
 
     private void runAsAccessNetworkState(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, ACCESS_NETWORK_STATE);
+        runTetheringCall(test, true /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                ACCESS_NETWORK_STATE);
     }
 
     private void runAsWriteSettings(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, true /* isTetheringAllowed */, WRITE_SETTINGS);
+        runTetheringCall(test, true /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                WRITE_SETTINGS);
+    }
+
+    private void runAsWriteSettingsWhenWriteSettingsAllowed(
+            final TestTetheringCall test) throws Exception {
+        runTetheringCall(test, true /* isTetheringAllowed */, true /* isWriteSettingsAllowed */,
+                WRITE_SETTINGS);
     }
 
     private void runAsTetheringDisallowed(final TestTetheringCall test) throws Exception {
-        runTetheringCall(test, false /* isTetheringAllowed */, TETHER_PRIVILEGED);
+        runTetheringCall(test, false /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                TETHER_PRIVILEGED);
+    }
+
+    private void runAsNetworkSettings(final TestTetheringCall test) throws Exception {
+        runTetheringCall(test, true /* isTetheringAllowed */, false /* isWriteSettingsAllowed */,
+                NETWORK_SETTINGS, TETHER_PRIVILEGED);
     }
 
     private void runTetheringCall(final TestTetheringCall test, boolean isTetheringAllowed,
-            String... permissions) throws Exception {
+            boolean isWriteSettingsAllowed, 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);
@@ -171,6 +200,8 @@
         try {
             when(mTethering.isTetheringSupported()).thenReturn(true);
             when(mTethering.isTetheringAllowed()).thenReturn(isTetheringAllowed);
+            when(mTethering.isTetheringWithSoftApConfigEnabled())
+                    .thenReturn(!isWriteSettingsAllowed);
             test.runTetheringCall(new TestTetheringResult());
         } finally {
             mUiAutomation.dropShellPermissionIdentity();
@@ -196,7 +227,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.tether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -207,7 +238,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.tether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runTether(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -235,7 +275,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.untether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -246,7 +286,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.untether(TEST_IFACE_NAME, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runUnTether(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -280,7 +329,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.setUsbTethering(true /* enable */, TEST_CALLER_PKG,
                     TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -291,7 +340,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.setUsbTethering(true /* enable */, TEST_CALLER_PKG,
+                    TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runSetUsbTethering(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -324,7 +382,16 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        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();
         });
@@ -335,7 +402,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runStartTethering(result, request);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -350,6 +426,32 @@
         });
     }
 
+    @Test
+    public void testStartTetheringWithInterfaceSucceeds() throws Exception {
+        final TetheringRequestParcel request = new TetheringRequestParcel();
+        request.tetheringType = TETHERING_VIRTUAL;
+        request.interfaceName = "avf_tap_fixed";
+
+        runAsNetworkSettings((result) -> {
+            runStartTethering(result, request);
+            verifyNoMoreInteractionsForTethering();
+        });
+    }
+
+    @Test
+    public void testStartTetheringNoNetworkStackPermissionWithInterfaceFails() throws Exception {
+        final TetheringRequestParcel request = new TetheringRequestParcel();
+        request.tetheringType = TETHERING_VIRTUAL;
+        request.interfaceName = "avf_tap_fixed";
+
+        runAsTetherPrivileged((result) -> {
+            mTetheringConnector.startTethering(request, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+    }
+
     private void runStartTetheringAndVerifyNoPermission(final TestTetheringResult result)
             throws Exception {
         final TetheringRequestParcel request = new TetheringRequestParcel();
@@ -394,7 +496,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.stopTethering(TETHERING_WIFI, TEST_CALLER_PKG,
                     TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -405,7 +507,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.stopTethering(TETHERING_WIFI, TEST_CALLER_PKG,
+                    TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runStopTethering(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -434,30 +545,47 @@
     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).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
 
         runAsTetherPrivileged((none) -> {
             runRequestLatestTetheringEntitlementResult();
             verifyNoMoreInteractionsForTethering();
         });
 
+        runAsTetherPrivileged((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, TEST_WRONG_PACKAGE, TEST_ATTRIBUTION_TAG);
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
         runAsWriteSettings((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((none) -> {
             runRequestLatestTetheringEntitlementResult();
+            verify(mTethering).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();
         });
     }
@@ -541,7 +669,7 @@
     public void testStopAllTethering() throws Exception {
         runAsNoPermission((result) -> {
             mTetheringConnector.stopAllTethering(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -552,7 +680,15 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.stopAllTethering(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runStopAllTethering(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
@@ -578,7 +714,7 @@
         runAsNoPermission((result) -> {
             mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
                     result);
-            verify(mTethering).isTetherProvisioningRequired();
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -589,7 +725,16 @@
         });
 
         runAsWriteSettings((result) -> {
+            mTetheringConnector.isTetheringSupported(TEST_CALLER_PKG, TEST_ATTRIBUTION_TAG,
+                    result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsWriteSettingsWhenWriteSettingsAllowed((result) -> {
             runIsTetheringSupported(result);
+            verify(mTethering).isTetheringWithSoftApConfigEnabled();
             verify(mTethering).isTetherProvisioningRequired();
             verifyNoMoreInteractionsForTethering();
         });
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 6ba5d48..97758cf 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -17,7 +17,10 @@
 package com.android.networkstack.tethering;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.Manifest.permission.NETWORK_STACK;
 import static android.content.pm.PackageManager.GET_ACTIVITIES;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 import static android.hardware.usb.UsbManager.USB_CONFIGURED;
 import static android.hardware.usb.UsbManager.USB_CONNECTED;
 import static android.hardware.usb.UsbManager.USB_FUNCTION_NCM;
@@ -33,6 +36,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
 import static android.net.RouteInfo.RTN_UNICAST;
 import static android.net.TetheringManager.ACTION_TETHER_STATE_CHANGED;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -43,6 +47,7 @@
 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.TETHER_ERROR_IFACE_CFG_ERROR;
@@ -96,6 +101,7 @@
 import static org.mockito.Matchers.anyString;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
@@ -124,6 +130,7 @@
 import android.content.res.Resources;
 import android.database.ContentObserver;
 import android.hardware.usb.UsbManager;
+import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
 import android.net.EthernetManager;
 import android.net.EthernetManager.TetheredInterfaceCallback;
@@ -162,6 +169,7 @@
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
+import android.net.wifi.WifiSsid;
 import android.net.wifi.p2p.WifiP2pGroup;
 import android.net.wifi.p2p.WifiP2pInfo;
 import android.net.wifi.p2p.WifiP2pManager;
@@ -188,9 +196,12 @@
 
 import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.internal.util.test.FakeSettingsProvider;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.PrivateAddressCoordinator;
 import com.android.net.module.util.RoutingCoordinatorManager;
+import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -219,6 +230,7 @@
 import java.io.PrintWriter;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -244,6 +256,7 @@
     private static final String TEST_P2P_IFNAME = "test_p2p-p2p0-0";
     private static final String TEST_NCM_IFNAME = "test_ncm0";
     private static final String TEST_ETH_IFNAME = "test_eth0";
+    private static final String TEST_VIRT_IFNAME = "test_virt0";
     private static final String TEST_BT_IFNAME = "test_pan0";
     private static final String TETHERING_NAME = "Tethering";
     private static final String[] PROVISIONING_APP_NAME = {"some", "app"};
@@ -253,8 +266,10 @@
     private static final String TEST_WIFI_REGEX = "test_wlan\\d";
     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;
@@ -291,7 +306,7 @@
     @Mock private BluetoothPanShim mBluetoothPanShim;
     @Mock private TetheredInterfaceRequestShim mTetheredInterfaceRequestShim;
     @Mock private TetheringMetrics mTetheringMetrics;
-    @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
+    @Mock private PrivateAddressCoordinator.Dependencies mPrivateAddressCoordinatorDependencies;
 
     private final MockIpServerDependencies mIpServerDependencies =
             spy(new MockIpServerDependencies());
@@ -315,15 +330,16 @@
     private TetheringConfiguration mConfig;
     private EntitlementManager mEntitleMgr;
     private OffloadController mOffloadCtrl;
-    private PrivateAddressCoordinator mPrivateAddressCoordinator;
     private SoftApCallback mSoftApCallback;
     private SoftApCallback mLocalOnlyHotspotCallback;
     private UpstreamNetworkMonitor mUpstreamNetworkMonitor;
     private UpstreamNetworkMonitor.EventListener mEventListener;
     private TetheredInterfaceCallbackShim mTetheredInterfaceCallbackShim;
+    private RoutingCoordinatorManager mRoutingCoordinatorManager;
 
     private TestConnectivityManager mCm;
     private boolean mForceEthernetServiceUnavailable = false;
+    private int mBinderCallingUid = TEST_CALLER_UID;
 
     private class TestContext extends BroadcastInterceptingContext {
         TestContext(Context base) {
@@ -374,6 +390,7 @@
         @Override
         public String getSystemServiceName(Class<?> serviceClass) {
             if (TelephonyManager.class.equals(serviceClass)) return Context.TELEPHONY_SERVICE;
+            if (ConnectivityManager.class.equals(serviceClass)) return Context.CONNECTIVITY_SERVICE;
             return super.getSystemServiceName(serviceClass);
         }
     }
@@ -403,11 +420,12 @@
                             || ifName.equals(TEST_P2P_IFNAME)
                             || ifName.equals(TEST_NCM_IFNAME)
                             || ifName.equals(TEST_ETH_IFNAME)
-                            || ifName.equals(TEST_BT_IFNAME));
+                            || ifName.equals(TEST_BT_IFNAME)
+                            || ifName.equals(TEST_VIRT_IFNAME));
             final String[] ifaces = new String[] {
                     TEST_RNDIS_IFNAME, TEST_WLAN_IFNAME, TEST_WLAN2_IFNAME, TEST_WIFI_IFNAME,
                     TEST_MOBILE_IFNAME, TEST_DUN_IFNAME, TEST_P2P_IFNAME, TEST_NCM_IFNAME,
-                    TEST_ETH_IFNAME};
+                    TEST_ETH_IFNAME, TEST_VIRT_IFNAME};
             return new InterfaceParams(ifName,
                     CollectionUtils.indexOf(ifaces, ifName) + IFINDEX_OFFSET,
                     MacAddress.ALL_ZEROS_ADDRESS);
@@ -485,8 +503,16 @@
         }
 
         @Override
-        public RoutingCoordinatorManager getRoutingCoordinator(final Context context,
-                SharedLog log) {
+        public RoutingCoordinatorManager getRoutingCoordinator(
+                final Context context, SharedLog log) {
+            ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+            when(mPrivateAddressCoordinatorDependencies.isFeatureEnabled(anyString()))
+                    .thenReturn(false);
+            RoutingCoordinatorService service = new RoutingCoordinatorService(
+                    getINetd(context, log),
+                            cm::getAllNetworks,
+                            mPrivateAddressCoordinatorDependencies);
+            mRoutingCoordinatorManager = spy(new RoutingCoordinatorManager(context, service));
             return mRoutingCoordinatorManager;
         }
 
@@ -533,13 +559,6 @@
         }
 
         @Override
-        public PrivateAddressCoordinator makePrivateAddressCoordinator(Context ctx,
-                TetheringConfiguration cfg) {
-            mPrivateAddressCoordinator = super.makePrivateAddressCoordinator(ctx, cfg);
-            return mPrivateAddressCoordinator;
-        }
-
-        @Override
         public BluetoothPanShim makeBluetoothPanShim(BluetoothPan pan) {
             try {
                 when(mBluetoothPanShim.requestTetheredInterface(
@@ -549,6 +568,11 @@
             }
             return mBluetoothPanShim;
         }
+
+        @Override
+        public int getBinderCallingUid() {
+            return mBinderCallingUid;
+        }
     }
 
     private static LinkProperties buildUpstreamLinkProperties(String interfaceName,
@@ -654,7 +678,8 @@
         when(mNetd.interfaceGetList())
                 .thenReturn(new String[] {
                         TEST_MOBILE_IFNAME, TEST_WLAN_IFNAME, TEST_WLAN2_IFNAME, TEST_RNDIS_IFNAME,
-                        TEST_P2P_IFNAME, TEST_NCM_IFNAME, TEST_ETH_IFNAME, TEST_BT_IFNAME});
+                        TEST_P2P_IFNAME, TEST_NCM_IFNAME, TEST_ETH_IFNAME, TEST_BT_IFNAME,
+                        TEST_VIRT_IFNAME});
         when(mResources.getString(R.string.config_wifi_tether_enable)).thenReturn("");
         mInterfaceConfiguration = new InterfaceConfigurationParcel();
         mInterfaceConfiguration.flags = new String[0];
@@ -679,6 +704,7 @@
                 new IntentFilter(ACTION_TETHER_STATE_CHANGED));
 
         mCm = spy(new TestConnectivityManager(mServiceContext, mock(IConnectivityManager.class)));
+        when(mCm.getAllNetworks()).thenReturn(new Network[] {});
 
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
@@ -744,12 +770,12 @@
     }
 
     private TetheringRequest createTetheringRequest(final int type) {
-        return createTetheringRequest(type, null, null, false, CONNECTIVITY_SCOPE_GLOBAL);
+        return createTetheringRequest(type, null, null, false, CONNECTIVITY_SCOPE_GLOBAL, null);
     }
 
     private TetheringRequest createTetheringRequest(final int type,
             final LinkAddress localIPv4Address, final LinkAddress staticClientAddress,
-            final boolean exempt, final int scope) {
+            final boolean exempt, final int scope, final String interfaceName) {
         TetheringRequest.Builder builder = new TetheringRequest.Builder(type)
                 .setExemptFromEntitlementCheck(exempt)
                 .setConnectivityScope(scope)
@@ -757,7 +783,13 @@
         if (localIPv4Address != null && staticClientAddress != null) {
             builder.setStaticIpv4Addresses(localIPv4Address, staticClientAddress);
         }
-        return builder.build();
+        if (interfaceName != null) {
+            builder.setInterfaceName(interfaceName);
+        }
+        TetheringRequest request = builder.build();
+        request.setUid(TEST_CALLER_UID);
+        request.setPackageName(TEST_CALLER_PKG);
+        return request;
     }
 
     @NonNull
@@ -862,6 +894,9 @@
             assertTrue(TestConnectivityManager.looksLikeDefaultRequest(reqCaptor.getValue()));
         }
 
+        // Ignore calls to {@link ConnectivityManager#getallNetworks}.
+        verify(mCm, atLeast(0)).getAllNetworks();
+
         // The default network request is only ever filed once.
         verifyNoMoreInteractions(mCm);
     }
@@ -1001,7 +1036,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);
@@ -1229,7 +1264,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.
@@ -1828,7 +1863,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(),
@@ -2325,7 +2360,7 @@
         // 2. Enable wifi tethering.
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
         tetherState = callback.pollTetherStatesChanged();
@@ -2370,6 +2405,105 @@
     }
 
     @Test
+    public void testSoftApConfigInTetheringEventCallback() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastV());
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_DENIED);
+        when(mContext.checkCallingOrSelfPermission(NETWORK_STACK))
+                .thenReturn(PERMISSION_DENIED);
+        when(mContext.checkCallingOrSelfPermission(PERMISSION_MAINLINE_NETWORK_STACK))
+                .thenReturn(PERMISSION_DENIED);
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        TestTetheringEventCallback differentCallback = new TestTetheringEventCallback();
+        TestTetheringEventCallback settingsCallback = new TestTetheringEventCallback();
+        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();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        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());
+        // Register callback with different UID
+        mBinderCallingUid = TEST_CALLER_UID + 1;
+        mTethering.registerTetheringEventCallback(differentCallback);
+        mLooper.dispatchAll();
+        differentCallback.expectTetheredClientChanged(Collections.emptyList());
+        differentCallback.expectUpstreamChanged(NULL_NETWORK);
+        differentCallback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+        // Register Settings callback
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_GRANTED);
+        mTethering.registerTetheringEventCallback(settingsCallback);
+        mLooper.dispatchAll();
+        settingsCallback.expectTetheredClientChanged(Collections.emptyList());
+        settingsCallback.expectUpstreamChanged(NULL_NETWORK);
+        settingsCallback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        assertTetherStatesNotNullButEmpty(differentCallback.pollTetherStatesChanged());
+        assertTetherStatesNotNullButEmpty(settingsCallback.pollTetherStatesChanged());
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
+        mLooper.dispatchAll();
+        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},
+                differentCallback.pollTetherStatesChanged().tetheredList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                settingsCallback.pollTetherStatesChanged().tetheredList);
+        callback.expectUpstreamChanged(upstreamState.network);
+        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},
+                callback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                differentCallback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                settingsCallback.pollTetherStatesChanged().availableList);
+        mLooper.dispatchAll();
+        callback.expectUpstreamChanged(NULL_NETWORK);
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        callback.assertNoCallback();
+    }
+
+    @Test
     public void testReportFailCallbackIfOffloadNotSupported() throws Exception {
         initTetheringOnTestThread();
         final UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
@@ -2457,7 +2591,7 @@
         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);
 
@@ -2656,10 +2790,21 @@
         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,
-                  serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL),
+                  serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL, null),
                   TEST_CALLER_PKG, thirdResult);
         mLooper.dispatchAll();
         thirdResult.assertHasResult();
@@ -2690,7 +2835,7 @@
         final ArgumentCaptor<DhcpServingParamsParcel> dhcpParamsCaptor =
                 ArgumentCaptor.forClass(DhcpServingParamsParcel.class);
         mTethering.startTethering(createTetheringRequest(TETHERING_USB,
-                  serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL),
+                  serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL, null),
                   TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
         verify(mUsbManager, times(1)).setCurrentFunctions(UsbManager.FUNCTION_NCM);
@@ -2819,7 +2964,7 @@
         setupForRequiredProvisioning();
         final TetheringRequest wifiNotExemptRequest =
                 createTetheringRequest(TETHERING_WIFI, null, null, false,
-                        CONNECTIVITY_SCOPE_GLOBAL);
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiNotExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
         verify(mEntitleMgr).startProvisioningIfNeeded(TETHERING_WIFI, false);
@@ -2833,7 +2978,7 @@
         setupForRequiredProvisioning();
         final TetheringRequest wifiExemptRequest =
                 createTetheringRequest(TETHERING_WIFI, null, null, true,
-                        CONNECTIVITY_SCOPE_GLOBAL);
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
         verify(mEntitleMgr, never()).startProvisioningIfNeeded(TETHERING_WIFI, false);
@@ -3627,7 +3772,7 @@
         verifyInterfaceServingModeStarted(TEST_P2P_IFNAME);
         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));
@@ -3656,4 +3801,18 @@
 
     // TODO: Test that a request for hotspot mode doesn't interfere with an
     // already operating tethering mode interface.
+
+    @Test
+    public void testVirtualTetheringWithInterfaceName() throws Exception {
+        initTetheringOnTestThread();
+        final TetheringRequest virtualTetheringRequest =
+                createTetheringRequest(TETHERING_VIRTUAL, null, null, false,
+                        CONNECTIVITY_SCOPE_GLOBAL, TEST_VIRT_IFNAME);
+        assertEquals(TEST_VIRT_IFNAME, virtualTetheringRequest.getInterfaceName());
+        mTethering.startTethering(virtualTetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+        assertContains(Arrays.asList(mTethering.getTetheredIfaces()), TEST_VIRT_IFNAME);
+        mTethering.stopTethering(TETHERING_VIRTUAL);
+        mLooper.dispatchAll();
+    }
 }
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/metrics/TetheringMetricsTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
index 34689bc..6b646ec 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
@@ -87,13 +87,13 @@
 import android.util.ArrayMap;
 
 import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.networkstack.tethering.UpstreamNetworkState;
 import com.android.networkstack.tethering.metrics.TetheringMetrics.DataUsage;
 import com.android.networkstack.tethering.metrics.TetheringMetrics.Dependencies;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
 import org.junit.After;
@@ -104,7 +104,8 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
-@RunWith(AndroidJUnit4.class)
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 public final class TetheringMetricsTest {
     @Rule public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
@@ -158,7 +159,7 @@
         mThread = new HandlerThread("TetheringMetricsTest");
         mThread.start();
         mHandler = new Handler(mThread.getLooper());
-        doReturn(mHandler).when(mDeps).createHandler(any());
+        doReturn(mHandler).when(mDeps).createHandler();
         // Set up the usage for upstream types.
         mMockUpstreamUsageBaseline.put(UT_CELLULAR, new DataUsage(100L, 200L));
         mMockUpstreamUsageBaseline.put(UT_WIFI, new DataUsage(400L, 800L));
@@ -498,7 +499,7 @@
     private void verifyEmptyUsageForAllUpstreamTypes() {
         mHandler.post(() -> {
             for (UpstreamType type : UpstreamType.values()) {
-                assertEquals(EMPTY, mTetheringMetrics.getDataUsageFromUpstreamType(type));
+                assertEquals(EMPTY, mTetheringMetrics.getLastReportedUsageFromUpstreamType(type));
             }
         });
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
@@ -555,7 +556,8 @@
 
         mHandler.post(() -> {
             for (UpstreamType type : UpstreamType.values()) {
-                final DataUsage dataUsage = mTetheringMetrics.getDataUsageFromUpstreamType(type);
+                final DataUsage dataUsage =
+                        mTetheringMetrics.getLastReportedUsageFromUpstreamType(type);
                 if (TetheringMetrics.isUsageSupportedForUpstreamType(type)) {
                     assertEquals(mMockUpstreamUsageBaseline.get(type), dataUsage);
                 } else {
@@ -610,12 +612,21 @@
         incrementCurrentTime(cellDuration);
         updateUpstreamDataUsage(UT_CELLULAR, cellUsageDiff);
 
+        // Change the upstream back to Wi-FI and update the data usage
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
+        final long wifiDuration2 = 50 * SECOND_IN_MILLIS;
+        final long wifiUsageDiff2 = 1000L;
+        incrementCurrentTime(wifiDuration2);
+        updateUpstreamDataUsage(UT_WIFI, wifiUsageDiff2);
+
         // Stop tethering and verify that the data usage is uploaded.
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
         UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
         addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration, wifiUsageDiff, wifiUsageDiff);
         addUpstreamEvent(upstreamEvents, UT_BLUETOOTH, bluetoothDuration, btUsageDiff, btUsageDiff);
         addUpstreamEvent(upstreamEvents, UT_CELLULAR, cellDuration, cellUsageDiff, cellUsageDiff);
+        addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration2, wifiUsageDiff2, wifiUsageDiff2);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
                 UserType.USER_SETTINGS, upstreamEvents,
                 currentTimeMillis() - wifiTetheringStartTime);
diff --git a/DnsResolver/Android.bp b/bpf/dns_helper/Android.bp
similarity index 100%
rename from DnsResolver/Android.bp
rename to bpf/dns_helper/Android.bp
diff --git a/DnsResolver/DnsBpfHelper.cpp b/bpf/dns_helper/DnsBpfHelper.cpp
similarity index 100%
rename from DnsResolver/DnsBpfHelper.cpp
rename to bpf/dns_helper/DnsBpfHelper.cpp
diff --git a/DnsResolver/DnsBpfHelper.h b/bpf/dns_helper/DnsBpfHelper.h
similarity index 100%
rename from DnsResolver/DnsBpfHelper.h
rename to bpf/dns_helper/DnsBpfHelper.h
diff --git a/DnsResolver/DnsBpfHelperTest.cpp b/bpf/dns_helper/DnsBpfHelperTest.cpp
similarity index 100%
rename from DnsResolver/DnsBpfHelperTest.cpp
rename to bpf/dns_helper/DnsBpfHelperTest.cpp
diff --git a/DnsResolver/DnsHelper.cpp b/bpf/dns_helper/DnsHelper.cpp
similarity index 100%
rename from DnsResolver/DnsHelper.cpp
rename to bpf/dns_helper/DnsHelper.cpp
diff --git a/DnsResolver/include/DnsHelperPublic.h b/bpf/dns_helper/include/DnsHelperPublic.h
similarity index 100%
rename from DnsResolver/include/DnsHelperPublic.h
rename to bpf/dns_helper/include/DnsHelperPublic.h
diff --git a/DnsResolver/libcom.android.tethering.dns_helper.map.txt b/bpf/dns_helper/libcom.android.tethering.dns_helper.map.txt
similarity index 100%
rename from DnsResolver/libcom.android.tethering.dns_helper.map.txt
rename to bpf/dns_helper/libcom.android.tethering.dns_helper.map.txt
diff --git a/bpf/headers/Android.bp b/bpf/headers/Android.bp
index d55584a..aaf8d8d 100644
--- a/bpf/headers/Android.bp
+++ b/bpf/headers/Android.bp
@@ -48,11 +48,10 @@
         "BpfMapTest.cpp",
         "BpfRingbufTest.cpp",
     ],
-    defaults: ["bpf_defaults"],
+    defaults: ["bpf_cc_defaults"],
     cflags: [
-        "-Wall",
-        "-Werror",
-        "-Wno-error=unused-variable",
+        "-Wno-unused-variable",
+        "-Wno-sign-compare",
     ],
     header_libs: ["bpf_headers"],
     static_libs: ["libgmock"],
diff --git a/bpf/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 81be37d..e6cef89 100644
--- a/bpf/headers/include/bpf/BpfClassic.h
+++ b/bpf/headers/include/bpf/BpfClassic.h
@@ -63,6 +63,10 @@
 #define BPF_LOAD_SKB_PROTOCOL \
 	BPF_STMT(BPF_LD | BPF_H | BPF_ABS, (__u32)SKF_AD_OFF + SKF_AD_PROTOCOL)
 
+// loads skb->pkt_type (0..7: see uapi/linux/if_packet.h PACKET_* constants)
+#define BPF_LOAD_SKB_PKTTYPE \
+	BPF_STMT(BPF_LD | BPF_B | BPF_ABS, (__u32)SKF_AD_OFF + SKF_AD_PKTTYPE)
+
 // 8-bit load relative to start of link layer (mac/ethernet) header.
 #define BPF_LOAD_MAC_RELATIVE_U8(ofs) \
 	BPF_STMT(BPF_LD | BPF_B | BPF_ABS, (__u32)SKF_LL_OFF + (ofs))
@@ -160,6 +164,9 @@
 #define BPF_LOAD_NETX_RELATIVE_ICMP_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
 #define BPF_LOAD_NETX_RELATIVE_ICMP_CODE BPF_LOAD_NETX_RELATIVE_L4_U8(1)
 
+// IGMP start with u8 type
+#define BPF_LOAD_NETX_RELATIVE_IGMP_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
+
 // 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)
 
diff --git a/bpf/headers/include/bpf/BpfRingbuf.h b/bpf/headers/include/bpf/BpfRingbuf.h
index 4bcd259..5fe4ef7 100644
--- a/bpf/headers/include/bpf/BpfRingbuf.h
+++ b/bpf/headers/include/bpf/BpfRingbuf.h
@@ -99,6 +99,7 @@
   // 32-bit kernel will just ignore the high-order bits.
   std::atomic_uint64_t* mConsumerPos = nullptr;
   std::atomic_uint32_t* mProducerPos = nullptr;
+  std::atomic_uint32_t* mLength = nullptr;
 
   // In order to guarantee atomic access in a 32 bit userspace environment, atomic_uint64_t is used
   // in addition to std::atomic<T>::is_always_lock_free that guarantees that read / write operations
@@ -247,7 +248,8 @@
     //   u32 len;
     //   u32 pg_off;
     // };
-    uint32_t length = *reinterpret_cast<volatile uint32_t*>(start_ptr);
+    mLength = reinterpret_cast<decltype(mLength)>(start_ptr);
+    uint32_t length = mLength->load(std::memory_order_acquire);
 
     // If the sample isn't committed, we're caught up with the producer.
     if (length & BPF_RINGBUF_BUSY_BIT) return count;
diff --git a/bpf/headers/include/bpf/BpfUtils.h b/bpf/headers/include/bpf/BpfUtils.h
index 9dd5822..9e8b2c7 100644
--- a/bpf/headers/include/bpf/BpfUtils.h
+++ b/bpf/headers/include/bpf/BpfUtils.h
@@ -63,9 +63,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 417a5c4..68bc607 100644
--- a/bpf/headers/include/bpf/KernelUtils.h
+++ b/bpf/headers/include/bpf/KernelUtils.h
@@ -55,11 +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
+           isKernelVersion(5,  4) ||  // first supported in Android R, min for W
            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,  6) ||  // first supported in Android V
+           isKernelVersion(6, 12);    // first supported in Android W
 }
 
 // Figure out the bitness of userspace.
diff --git a/bpf/headers/include/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
index ac5ffda..d817cc3 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -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
@@ -231,16 +231,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),                                                       \
@@ -269,7 +277,7 @@
     DEFINE_BPF_MAP_BASE(the_map, RINGBUF, 0, 0, size_bytes, usr, grp, md,      \
                         selinux, pindir, share, KVER_5_8, 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 +325,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 +367,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, \
@@ -403,6 +411,8 @@
 static unsigned long long (*bpf_get_smp_processor_id)(void) = (void*) BPF_FUNC_get_smp_processor_id;
 static long (*bpf_get_stackid)(void* ctx, void* map, uint64_t flags) = (void*) BPF_FUNC_get_stackid;
 static long (*bpf_get_current_comm)(void* buf, uint32_t buf_size) = (void*) BPF_FUNC_get_current_comm;
+// bpf_sk_fullsock requires 5.1+ kernel
+static struct bpf_sock* (*bpf_sk_fullsock)(struct bpf_sock* sk) = (void*) BPF_FUNC_sk_fullsock;
 
 // GPL only:
 static int (*bpf_trace_printk)(const char* fmt, int fmt_size, ...) = (void*) BPF_FUNC_trace_printk;
diff --git a/bpf/headers/include/bpf_map_def.h b/bpf/headers/include/bpf_map_def.h
index 2d6736c..d67da48 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");
@@ -155,7 +159,7 @@
     enum bpf_map_type type;
     unsigned int key_size;
     unsigned int value_size;
-    unsigned int max_entries;
+    int max_entries;  // negative means BPF_F_NO_PREALLOC, but *might* not work with S
     unsigned int map_flags;
 
     // The following are not supported by the Android bpfloader:
diff --git a/bpf/loader/Android.bp b/bpf/loader/Android.bp
index b8c0ce7..b08913a 100644
--- a/bpf/loader/Android.bp
+++ b/bpf/loader/Android.bp
@@ -33,12 +33,7 @@
 cc_binary {
     name: "netbpfload",
 
-    defaults: ["bpf_defaults"],
-    cflags: [
-        "-Wall",
-        "-Werror",
-        "-Wthread-safety",
-    ],
+    defaults: ["bpf_cc_defaults"],
     sanitize: {
         integer_overflow: true,
     },
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 69f1cb5..53c7d49 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -59,6 +59,9 @@
 #include "bpf/BpfUtils.h"
 #include "bpf_map_def.h"
 
+// The following matches bpf_helpers.h, which is only for inclusion in bpf code
+#define BPFLOADER_MAINLINE_VERSION 42u
+
 using android::base::EndsWith;
 using android::base::GetIntProperty;
 using android::base::GetProperty;
@@ -215,7 +218,7 @@
  * is the name of the program, and tracepoint is the type.
  *
  * However, be aware that you should not be directly using the SECTION() macro.
- * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE/CRITICAL macros.
+ * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE macros.
  *
  * Programs shipped inside the tethering apex should be limited to networking stuff,
  * as KPROBE, PERF_EVENT, TRACEPOINT are dangerous to use from mainline updatable code,
@@ -613,9 +616,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.
@@ -791,7 +791,7 @@
               .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));
@@ -1105,30 +1105,22 @@
     return 0;
 }
 
-int loadProg(const char* const elfPath, bool* const isCritical, const unsigned int bpfloader_ver,
+int loadProg(const char* const elfPath, const unsigned int bpfloader_ver,
              const char* const prefix) {
     vector<char> license;
-    vector<char> critical;
     vector<codeSection> cs;
     vector<unique_fd> mapFds;
     int ret;
 
-    if (!isCritical) return -1;
-    *isCritical = false;
-
     ifstream elfFile(elfPath, ios::in | ios::binary);
     if (!elfFile.is_open()) return -1;
 
-    ret = readSectionByName("critical", elfFile, critical);
-    *isCritical = !ret;
-
     ret = readSectionByName("license", elfFile, license);
     if (ret) {
         ALOGE("Couldn't find license in %s", elfPath);
         return ret;
     } else {
-        ALOGD("Loading %s%s ELF object %s with license %s",
-              *isCritical ? "critical for " : "optional", *isCritical ? (char*)critical.data() : "",
+        ALOGD("Loading ELF object %s with license %s",
               elfPath, (char*)license.data());
     }
 
@@ -1162,7 +1154,10 @@
         ALOGV("map_fd found at %d is %d in %s", i, mapFds[i].get(), elfPath);
 
     ret = readCodeSections(elfFile, cs);
-    if (ret == -ENOENT) return 0;  // no programs defined in this .o
+    // 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)
+        return 0;
     if (ret) {
         ALOGE("Couldn't read all code sections in %s", elfPath);
         return ret;
@@ -1230,10 +1225,9 @@
             string progPath(location.dir);
             progPath += s;
 
-            bool critical;
-            int ret = loadProg(progPath.c_str(), &critical, bpfloader_ver, location.prefix);
+            int ret = loadProg(progPath.c_str(), bpfloader_ver, location.prefix);
             if (ret) {
-                if (critical) retVal = ret;
+                retVal = ret;
                 ALOGE("Failed to load object: %s, ret: %s", progPath.c_str(), std::strerror(-ret));
             } else {
                 ALOGD("Loaded object: %s", progPath.c_str());
@@ -1291,6 +1285,8 @@
 
 #define APEX_MOUNT_POINT "/apex/com.android.tethering"
 const char * const platformBpfLoader = "/system/bin/bpfloader";
+const char *const uprobestatsBpfLoader =
+    "/apex/com.android.uprobestats/bin/uprobestatsbpfload";
 
 static int logTetheringApexVersion(void) {
     char * found_blockdev = NULL;
@@ -1410,17 +1406,15 @@
     //
     // 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
+    // apex init .XXrc parsing code uses for XX filtering, and that code
+    // (now) similarly uses __ANDROID_API_FUTURE__ for non 'REL' codenames.
+    const int api_level = unreleased ? __ANDROID_API_FUTURE__ : android_get_device_api_level();
+    const bool isAtLeastT = (api_level >= __ANDROID_API_T__);
+    const bool isAtLeastU = (api_level >= __ANDROID_API_U__);
+    const bool isAtLeastV = (api_level >= __ANDROID_API_V__);
+    const bool isAtLeast25Q2 = (api_level > __ANDROID_API_V__);  // TODO: fix >
 
-    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");
@@ -1428,15 +1422,15 @@
     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 = 42u;    // [42] BPFLOADER_MAINLINE_VERSION
+    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_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,
+          bpfloader_ver, argv[0], android_get_device_api_level(), api_level,
           kernelVersion(), describeArch(), getuid(),
           has_platform_bpfloader_rc, has_platform_netbpfload_rc);
 
@@ -1476,6 +1470,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)) {
@@ -1499,13 +1500,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; \
         }
 
@@ -1515,6 +1516,7 @@
         REQUIRE(5, 15, 136)
         REQUIRE(6, 1, 57)
         REQUIRE(6, 6, 0)
+        REQUIRE(6, 12, 0)
 
 #undef REQUIRE
 
@@ -1545,15 +1547,14 @@
      *
      * Additionally the 32-bit kernel jit support is poor,
      * and 32-bit userspace on 64-bit kernel bpf ringbuffer compatibility is broken.
+     * Note, however, that TV and Wear devices will continue to support 32-bit userspace
+     * on ARM64.
      */
     if (isUserspace32bit() && isAtLeastKernelVersion(6, 2, 0)) {
         // Stuff won't work reliably, but...
-        if (isTV()) {
-            // exempt TVs... they don't really need functional advanced networking
-            ALOGW("[TV] 32-bit userspace unsupported on 6.2+ kernels.");
-        } else if (isWear() && isArm()) {
-            // exempt Arm Wear devices (arm32 ABI is far less problematic than x86-32)
-            ALOGW("[Arm Wear] 32-bit userspace unsupported on 6.2+ kernels.");
+        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()) {
             // also exempt Arm devices upgrading with major kernel rev from T-
             // might possibly be better for them to run with a newer kernel...
@@ -1567,8 +1568,8 @@
         }
     }
 
-    // Note: 6.6 is highest version supported by Android V (sdk=35), so this is for sdk=36+
-    if (isUserspace32bit() && isAtLeastKernelVersion(6, 7, 0)) {
+    // On handheld, 6.6 is highest version supported by Android V (sdk=35), so this is for sdk=36+
+    if (!isArm() && isUserspace32bit() && isAtLeastKernelVersion(6, 7, 0)) {
         ALOGE("64-bit userspace required on 6.7+ kernels.");
         return 1;
     }
@@ -1660,7 +1661,16 @@
     }
 
     // unreachable before U QPR3
-    ALOGI("done, transferring control to platform bpfloader.");
+    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.");
+    }
 
     // platform BpfLoader *needs* to run as root
     const char * args[] = { platformBpfLoader, NULL, };
diff --git a/bpf/loader/initrc-doc/README.txt b/bpf/loader/initrc-doc/README.txt
index 42e1fc2..2b22326 100644
--- a/bpf/loader/initrc-doc/README.txt
+++ b/bpf/loader/initrc-doc/README.txt
@@ -1,20 +1,42 @@
 This directory contains comment stripped versions of
   //system/bpf/bpfloader/bpfloader.rc
-from previous versions of Android.
+or
+  //packages/modules/Connectivity/bpf/loader/netbpfload.rc
+(as appropriate) from previous versions of Android.
 
 Generated via:
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android11-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android12-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android13-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android14-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/main:bpfloader/bpfloader.rc;              ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android11-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android12-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android13-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android14-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+  git cat-file -p remotes/aosp/android14-qpr2-release:netbpfload/netbpfload.rc | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2-24Q1.rc
+  git cat-file -p remotes/aosp/android14-qpr3-release:netbpfload/netbpfload.rc | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR3-24Q2.rc
+  git cat-file -p remotes/aosp/android15-release:netbpfload/netbpfload.rc      | egrep -v '^ *#' > bpfloader-sdk35-15-V-24Q3.rc
+  git cat-file -p remotes/aosp/main:bpf/loader/netbpfload.rc                   | egrep -v '^ *#' > bpfloader-sdk35-15-V-QPR1-24Q4.rc
+
+see also:
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android11-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android12-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android13-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-qpr1-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-qpr2-release/bpfloader/ (rc file is gone in QPR2)
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android14-qpr2-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android14-qpr3-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android15-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android15-qpr1-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/main/netbpfload/netbpfload.rc
+or:
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q1-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q2-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q3-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q4-release/bpf/loader/netbpfload.rc
 
 this is entirely equivalent to:
   (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/sc-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/tm-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/udc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
-  (cd /android1/system/bpf && git cat-file -p remotes/goog/main:bpfloader/bpfloader.rc;    ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
 
 it is also equivalent to:
   (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-qpr-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
@@ -29,34 +51,66 @@
 
 Key takeaways:
 
-= R bpfloader:
+= R bpfloader (platform)
   - CHOWN + SYS_ADMIN
   - asynchronous startup
   - platform only
   - proc file setup handled by initrc
 
-= S bpfloader
+= S bpfloader (platform)
   - adds NET_ADMIN
   - synchronous startup
   - platform + mainline tethering offload
 
-= T bpfloader
+= T bpfloader (platform)
   - platform + mainline networking (including tethering offload)
   - supported btf for maps via exec of btfloader
 
-= U bpfloader
+= U bpfloader (platform)
   - proc file setup moved into bpfloader binary
   - explicitly specified user and groups:
     group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
     user root
 
-= U QPR2 bpfloader
+= U QPR2 [24Q1] bpfloader (platform netbpfload -> platform bpfloader)
   - drops support of btf for maps
   - invocation of /system/bin/netbpfload binary, which after handling *all*
     networking bpf related things executes the platform /system/bin/bpfloader
     which handles non-networking bpf.
+  - Note: this does not (by itself) call into apex NetBpfLoad
+
+= U QPR3 [24Q2] bpfloader (platform netbpfload -> apex netbpfload -> platform bpfloader)
+  - platform NetBpfload *always* execs into apex NetBpfLoad,
+  - shipped with mainline tethering apex that includes NetBpfLoad binary.
+
+= V [24Q3] bpfloader (apex netbpfload -> platform bpfloader)
+  - no significant changes, though it does hard require the apex NetBpfLoad
+    by virtue of the platform NetBpfLoad no longer being present.
+    ie. the apex must override the platform 'bpfloader' service for 35+:
+    the V FRC M-2024-08+ tethering apex does this.
+
+= V QPR1 [24Q4] bpfloader (apex netbpfload -> platform bpfloader)
+  - made netd start earlier (previously happened in parallel to zygote)
+  - renamed and moved the trigger out of netbpload.rc into
+    //system/core/rootdir/init.rc
+  - the new sequence is:
+      trigger post-fs-data        (logd available, starts apexd)
+      trigger load-bpf-programs   (does: exec_start bpfloader)
+      trigger bpf-progs-loaded    (does: start netd)
+      trigger zygote-start
+  - this is more or less irrelevant from the point of view of the bpfloader,
+    but it does mean netd init could fail and abort the boot earlier,
+    before 'A/B update_verifier marks a successful boot'.
+    Though note that due to netd being started asynchronously, it is racy.
 
 Note that there is now a copy of 'netbpfload' provided by the tethering apex
 mainline module at /apex/com.android.tethering/bin/netbpfload, which due
 to the use of execve("/system/bin/bpfloader") relies on T+ selinux which was
 added for btf map support (specifically the ability to exec the "btfloader").
+
+= mainline tethering apex M-2024-08+ overrides the platform service for V+
+  thus loading mainline (ie. networking) bpf programs from mainline 'NetBpfLoad'
+  and platform ones from platform 'bpfloader'.
+
+= mainline tethering apex M-2024-09+ changes T+ behaviour (U QPR3+ unaffected)
+  netd -> netd_updatable.so -> ctl.start=mdnsd_netbpfload -> load net bpf programs
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2-24Q1.rc
similarity index 100%
copy from bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
copy to bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2-24Q1.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3-24Q2.rc
similarity index 100%
rename from bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3-24Q2.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
deleted file mode 100644
index 8f3f462..0000000
--- a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
+++ /dev/null
@@ -1,11 +0,0 @@
-on load_bpf_programs
-    exec_start bpfloader
-
-service bpfloader /system/bin/netbpfload
-    capabilities CHOWN SYS_ADMIN NET_ADMIN
-    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
-    user root
-    rlimit memlock 1073741824 1073741824
-    oneshot
-    reboot_on_failure reboot,bpfloader-failed
-    updatable
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-24Q3.rc
similarity index 100%
rename from bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk35-15-V-24Q3.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc
new file mode 100644
index 0000000..e2639ac
--- /dev/null
+++ b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc
@@ -0,0 +1,5 @@
+service bpfloader /system/bin/false
+    user root
+    oneshot
+    reboot_on_failure reboot,netbpfload-missing
+    updatable
diff --git a/bpf/loader/netbpfload.rc b/bpf/loader/netbpfload.rc
index e1af47f..10bfbb2 100644
--- a/bpf/loader/netbpfload.rc
+++ b/bpf/loader/netbpfload.rc
@@ -1,22 +1,3 @@
-# zygote-start is what officially starts netd (see //system/core/rootdir/init.rc)
-# However, on some hardware it's started from post-fs-data as well, which is just
-# a tad earlier.  There's no benefit to that though, since on 4.9+ P+ devices netd
-# will just block until bpfloader finishes and sets the bpf.progs_loaded property.
-#
-# It is important that we start bpfloader after:
-#   - /sys/fs/bpf is already mounted,
-#   - apex (incl. rollback) is initialized (so that in the future we can load bpf
-#     programs shipped as part of apex mainline modules)
-#   - logd is ready for us to log stuff
-#
-# At the same time we want to be as early as possible to reduce races and thus
-# failures (before memory is fragmented, and cpu is busy running tons of other
-# stuff) and we absolutely want to be before netd and the system boot slot is
-# considered to have booted successfully.
-#
-on load_bpf_programs
-    exec_start bpfloader
-
 # 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/Android.bp b/bpf/netd/Android.bp
index fe4d999..473c8c9 100644
--- a/bpf/netd/Android.bp
+++ b/bpf/netd/Android.bp
@@ -82,7 +82,6 @@
         "libcutils",
         "liblog",
         "libnetdutils",
-        "libprocessgroup",
     ],
     compile_multilib: "both",
     multilib: {
diff --git a/bpf/netd/BpfBaseTest.cpp b/bpf/netd/BpfBaseTest.cpp
index 34dfbb4..4b8a04e 100644
--- a/bpf/netd/BpfBaseTest.cpp
+++ b/bpf/netd/BpfBaseTest.cpp
@@ -29,7 +29,6 @@
 #include <gtest/gtest.h>
 
 #include <cutils/qtaguid.h>
-#include <processgroup/processgroup.h>
 
 #include <android-base/stringprintf.h>
 #include <android-base/strings.h>
@@ -54,13 +53,6 @@
     BpfBasicTest() {}
 };
 
-TEST_F(BpfBasicTest, TestCgroupMounted) {
-    std::string cg2_path;
-    ASSERT_EQ(true, CgroupGetControllerPath(CGROUPV2_HIERARCHY_NAME, &cg2_path));
-    ASSERT_EQ(0, access(cg2_path.c_str(), R_OK));
-    ASSERT_EQ(0, access((cg2_path + "/cgroup.controllers").c_str(), R_OK));
-}
-
 TEST_F(BpfBasicTest, TestTagSocket) {
     BpfMap<uint64_t, UidTagValue> cookieTagMap(COOKIE_TAG_MAP_PATH);
     ASSERT_TRUE(cookieTagMap.isValid());
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 9682545..bcd0cba 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -70,6 +70,13 @@
     return netdutils::status::ok;
 }
 
+// Checks if the device is running on release version of Android 25Q2 or newer.
+static bool isAtLeast25Q2() {
+    return android_get_device_api_level() >= 36 ||
+           (android_get_device_api_level() == 35 &&
+            modules::sdklevel::detail::IsAtLeastPreReleaseCodename("Baklava"));
+}
+
 static Status initPrograms(const char* cg2_path) {
     if (!cg2_path) return Status("cg2_path is NULL");
 
@@ -91,12 +98,23 @@
         return Status("U+ platform with cg2_path != /sys/fs/cgroup is unsupported");
     }
 
+    // V bumps the kernel requirement up to 4.19
+    if (modules::sdklevel::IsAtLeastV() && !bpf::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() && !bpf::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()) {
         const int err = errno;
         ALOGE("Failed to open the cgroup directory: %s", strerror(err));
         return statusFromErrno(err, "Open the cgroup directory 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));
@@ -114,19 +132,28 @@
                                     cg_fd, BPF_CGROUP_INET_SOCK_CREATE));
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
+                                    cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_INET4_CONNECT));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT6_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_INET6_CONNECT));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_RECVMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP4_RECVMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_RECVMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP6_RECVMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_SENDMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP4_SENDMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_SENDMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP6_SENDMSG));
+        // 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)) {
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_INET4_CONNECT));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT6_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_INET6_CONNECT));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_RECVMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP4_RECVMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_RECVMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP6_RECVMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_SENDMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP4_SENDMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_SENDMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP6_SENDMSG));
+        }
 
         if (bpf::isAtLeastKernelVersion(5, 4, 0)) {
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_GETSOCKOPT_PROG_PATH,
@@ -134,19 +161,12 @@
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_SETSOCKOPT_PROG_PATH,
                                         cg_fd, BPF_CGROUP_SETSOCKOPT));
         }
-
-        if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
-                                        cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
-        }
     }
 
     if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind4_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND4_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET4_BIND));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind6_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND6_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET6_BIND));
 
         // This should trivially pass, since we just attached up above,
@@ -158,22 +178,26 @@
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
-        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();
+        // 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 (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 (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
-        }
     }
 
     return netdutils::status::ok;
@@ -203,7 +227,7 @@
     }
 }
 
-Status BpfHandler::init(const char* cg2_path) {
+static inline void waitForBpf() {
     // Note: netd *can* be restarted, so this might get called a second time after boot is complete
     // at which point we don't need to (and shouldn't) wait for (more importantly start) loading bpf
 
@@ -231,6 +255,21 @@
     }
 
     ALOGI("BPF programs are loaded");
+}
+
+Status BpfHandler::init(const char* cg2_path) {
+    // This wait is effectively a no-op on U QPR3+ devices (as netd starts
+    // *after* the synchronous 'exec_start bpfloader' which calls NetBpfLoad)
+    // but checking for U QPR3 is hard.
+    //
+    // Waiting should not be required on U QPR3+ devices,
+    // ...
+    //
+    // ...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();
 
     RETURN_IF_NOT_OK(initPrograms(cg2_path));
     RETURN_IF_NOT_OK(initMaps());
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index dc1f56d..20d194c 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -47,8 +47,8 @@
         "com.android.tethering",
     ],
     visibility: [
+        "//packages/modules/Connectivity/bpf/dns_helper",
         "//packages/modules/Connectivity/bpf/netd",
-        "//packages/modules/Connectivity/DnsResolver",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service/native/libs/libclat",
         "//packages/modules/Connectivity/Tethering",
@@ -64,12 +64,6 @@
 // bpf kernel programs
 //
 bpf {
-    name: "block.o",
-    srcs: ["block.c"],
-    sub_dir: "net_shared",
-}
-
-bpf {
     name: "dscpPolicy.o",
     srcs: ["dscpPolicy.c"],
     sub_dir: "net_shared",
diff --git a/bpf/progs/block.c b/bpf/progs/block.c
deleted file mode 100644
index 0e2dba9..0000000
--- a/bpf/progs/block.c
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// The resulting .o needs to load on Android T+
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-
-#include "bpf_net_helpers.h"
-
-DEFINE_BPF_MAP_GRW(blocked_ports_map, ARRAY, int, uint64_t,
-        1024 /* 64K ports -> 1024 u64s */, AID_SYSTEM)
-
-static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
-    if (!ctx->user_port) return BPF_ALLOW;
-
-    switch (ctx->protocol) {
-        case IPPROTO_TCP:
-        case IPPROTO_MPTCP:
-        case IPPROTO_UDP:
-        case IPPROTO_UDPLITE:
-        case IPPROTO_DCCP:
-        case IPPROTO_SCTP:
-            break;
-        default:
-            return BPF_ALLOW; // unknown protocols are allowed
-    }
-
-    int key = ctx->user_port >> 6;
-    int shift = ctx->user_port & 63;
-
-    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
-    // Lookup should never fail in reality, but if it does return here to keep the
-    // BPF verifier happy.
-    if (!val) return BPF_ALLOW;
-
-    if ((*val >> shift) & 1) return BPF_DISALLOW;
-    return BPF_ALLOW;
-}
-
-// the program need to be accessible/loadable by netd (from netd updatable plugin)
-#define DEFINE_NETD_RO_BPF_PROG(SECTION_NAME, the_prog, min_kver) \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog, min_kver, KVER_INF,  \
-                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
-                        "", "netd_readonly/", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
-
-DEFINE_NETD_RO_BPF_PROG("bind4/block_port", bind4_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-DEFINE_NETD_RO_BPF_PROG("bind6/block_port", bind6_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-LICENSE("Apache 2.0");
-CRITICAL("ConnectivityNative");
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index 4248a46..9244350 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -46,14 +46,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) \
@@ -69,6 +69,8 @@
 // TODO: consider whether we can merge some of these maps
 // for example it might be possible to merge 2 or 3 of:
 //   uid_counterset_map + uid_owner_map + uid_permission_map
+DEFINE_BPF_MAP_NO_NETD(blocked_ports_map, ARRAY, int, uint64_t,
+                       1024 /* 64K ports -> 1024 u64s */)
 DEFINE_BPF_MAP_RW_NETD(cookie_tag_map, HASH, uint64_t, UidTagValue, COOKIE_UID_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(uid_counterset_map, HASH, uint32_t, uint8_t, UID_COUNTERSET_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(app_uid_stats_map, HASH, uint32_t, StatsValue, APP_STATS_MAP_SIZE)
@@ -90,7 +92,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,
@@ -643,8 +645,8 @@
     return (get_app_permissions() & BPF_PERMISSION_INTERNET) ? BPF_ALLOW : BPF_DISALLOW;
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
-                            inet_socket_release, KVER_5_10)
+DEFINE_NETD_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
+                          inet_socket_release, KVER_5_10)
 (struct bpf_sock* sk) {
     uint64_t cookie = bpf_get_sk_cookie(sk);
     if (cookie) bpf_cookie_tag_map_delete_elem(&cookie);
@@ -670,32 +672,69 @@
     return BPF_ALLOW;
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_14)
+static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
+    if (!ctx->user_port) return BPF_ALLOW;
+
+    switch (ctx->protocol) {
+        case IPPROTO_TCP:
+        case IPPROTO_MPTCP:
+        case IPPROTO_UDP:
+        case IPPROTO_UDPLITE:
+        case IPPROTO_DCCP:
+        case IPPROTO_SCTP:
+            break;
+        default:
+            return BPF_ALLOW; // unknown protocols are allowed
+    }
+
+    int key = ctx->user_port >> 6;
+    int shift = ctx->user_port & 63;
+
+    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
+    // Lookup should never fail in reality, but if it does return here to keep the
+    // BPF verifier happy.
+    if (!val) return BPF_ALLOW;
+
+    if ((*val >> shift) & 1) return BPF_DISALLOW;
+    return BPF_ALLOW;
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind4/inet4_bind", AID_ROOT, AID_ROOT, inet4_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind6/inet6_bind", AID_ROOT, AID_ROOT, inet6_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
+DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_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_14)
+DEFINE_NETD_V_BPF_PROG_KVER("connect6/inet6_connect", AID_ROOT, AID_ROOT, 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_14)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg4/udp4_recvmsg", AID_ROOT, AID_ROOT, 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_14)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg6/udp6_recvmsg", AID_ROOT, AID_ROOT, 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_14)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg4/udp4_sendmsg", AID_ROOT, AID_ROOT, 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_14)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg6/udp6_sendmsg", AID_ROOT, AID_ROOT, udp6_sendmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
diff --git a/bpf/progs/netd.h b/bpf/progs/netd.h
index 4877a4b..be7c311 100644
--- a/bpf/progs/netd.h
+++ b/bpf/progs/netd.h
@@ -157,6 +157,8 @@
 
 #define CGROUP_INET_CREATE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsock_inet_create"
 #define CGROUP_INET_RELEASE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsockrelease_inet_release"
+#define CGROUP_BIND4_PROG_PATH BPF_NETD_PATH "prog_netd_bind4_inet4_bind"
+#define CGROUP_BIND6_PROG_PATH BPF_NETD_PATH "prog_netd_bind6_inet6_bind"
 #define CGROUP_CONNECT4_PROG_PATH BPF_NETD_PATH "prog_netd_connect4_inet4_connect"
 #define CGROUP_CONNECT6_PROG_PATH BPF_NETD_PATH "prog_netd_connect6_inet6_connect"
 #define CGROUP_UDP4_RECVMSG_PROG_PATH BPF_NETD_PATH "prog_netd_recvmsg4_udp4_recvmsg"
diff --git a/bpf/progs/offload.c b/bpf/progs/offload.c
index 7e1184d..631908a 100644
--- a/bpf/progs/offload.c
+++ b/bpf/progs/offload.c
@@ -85,9 +85,8 @@
 
     // Since the program never writes via DPA (direct packet access) auto-pull/unclone logic does
     // not trigger and thus we need to manually make sure we can read packet headers via DPA.
-    // Note: this is a blind best effort pull, which may fail or pull less - this doesn't matter.
     // It has to be done early cause it will invalidate any skb->data/data_end derived pointers.
-    try_make_writable(skb, l2_header_size + IP6_HLEN + TCP_HLEN);
+    if (bpf_skb_pull_data(skb, l2_header_size + IP6_HLEN)) return TC_ACT_PIPE;
 
     void* data = (void*)(long)skb->data;
     const void* data_end = (void*)(long)skb->data_end;
@@ -110,6 +109,14 @@
     // If hardware offload is running and programming flows based on conntrack entries,
     // try not to interfere with it.
     if (ip6->nexthdr == IPPROTO_TCP) {
+        // don't need to check return code, as it's effectively checked in the next 'if' below
+        bpf_skb_pull_data(skb, l2_header_size + IP6_HLEN + TCP_HLEN);
+
+        data = (void*)(long)skb->data;
+        data_end = (void*)(long)skb->data_end;
+        eth = is_ethernet ? data : NULL;  // used iff is_ethernet
+        ip6 = is_ethernet ? (void*)(eth + 1) : data;
+
         struct tcphdr* tcph = (void*)(ip6 + 1);
 
         // Make sure we can get at the tcp header
diff --git a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
index 73cef89..a31445a 100644
--- a/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
+++ b/bpf/syscall_wrappers/include/BpfSyscallWrappers.h
@@ -16,24 +16,20 @@
 
 #pragma once
 
+#include <android-base/unique_fd.h>
 #include <stdlib.h>
 #include <unistd.h>
 #include <linux/bpf.h>
 #include <linux/unistd.h>
 #include <sys/file.h>
 
-#ifdef BPF_FD_JUST_USE_INT
-  #define BPF_FD_TYPE int
-  #define BPF_FD_TO_U32(x) static_cast<__u32>(x)
-#else
-  #include <android-base/unique_fd.h>
-  #define BPF_FD_TYPE base::unique_fd&
-  #define BPF_FD_TO_U32(x) static_cast<__u32>((x).get())
-#endif
 
 namespace android {
 namespace bpf {
 
+using ::android::base::borrowed_fd;
+using ::android::base::unique_fd;
+
 inline uint64_t ptr_to_u64(const void * const x) {
     return (uint64_t)(uintptr_t)x;
 }
@@ -69,58 +65,59 @@
 //   'inner_map_fd' is basically a template specifying {map_type, key_size, value_size, max_entries, map_flags}
 //   of the inner map type (and possibly only key_size/value_size actually matter?).
 inline int createOuterMap(bpf_map_type map_type, uint32_t key_size, uint32_t value_size,
-                          uint32_t max_entries, uint32_t map_flags, const BPF_FD_TYPE inner_map_fd) {
+                          uint32_t max_entries, uint32_t map_flags,
+                          const borrowed_fd& inner_map_fd) {
     return bpf(BPF_MAP_CREATE, {
                                        .map_type = map_type,
                                        .key_size = key_size,
                                        .value_size = value_size,
                                        .max_entries = max_entries,
                                        .map_flags = map_flags,
-                                       .inner_map_fd = BPF_FD_TO_U32(inner_map_fd),
+                                       .inner_map_fd = static_cast<__u32>(inner_map_fd.get()),
                                });
 }
 
-inline int writeToMapEntry(const BPF_FD_TYPE map_fd, const void* key, const void* value,
+inline int writeToMapEntry(const borrowed_fd& map_fd, const void* key, const void* value,
                            uint64_t flags) {
     return bpf(BPF_MAP_UPDATE_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                             .value = ptr_to_u64(value),
                                             .flags = flags,
                                     });
 }
 
-inline int findMapEntry(const BPF_FD_TYPE map_fd, const void* key, void* value) {
+inline int findMapEntry(const borrowed_fd& map_fd, const void* key, void* value) {
     return bpf(BPF_MAP_LOOKUP_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                             .value = ptr_to_u64(value),
                                     });
 }
 
-inline int deleteMapEntry(const BPF_FD_TYPE map_fd, const void* key) {
+inline int deleteMapEntry(const borrowed_fd& map_fd, const void* key) {
     return bpf(BPF_MAP_DELETE_ELEM, {
-                                            .map_fd = BPF_FD_TO_U32(map_fd),
+                                            .map_fd = static_cast<__u32>(map_fd.get()),
                                             .key = ptr_to_u64(key),
                                     });
 }
 
-inline int getNextMapKey(const BPF_FD_TYPE map_fd, const void* key, void* next_key) {
+inline int getNextMapKey(const borrowed_fd& map_fd, const void* key, void* next_key) {
     return bpf(BPF_MAP_GET_NEXT_KEY, {
-                                             .map_fd = BPF_FD_TO_U32(map_fd),
+                                             .map_fd = static_cast<__u32>(map_fd.get()),
                                              .key = ptr_to_u64(key),
                                              .next_key = ptr_to_u64(next_key),
                                      });
 }
 
-inline int getFirstMapKey(const BPF_FD_TYPE map_fd, void* firstKey) {
+inline int getFirstMapKey(const borrowed_fd& map_fd, void* firstKey) {
     return getNextMapKey(map_fd, NULL, firstKey);
 }
 
-inline int bpfFdPin(const BPF_FD_TYPE map_fd, const char* pathname) {
+inline int bpfFdPin(const borrowed_fd& map_fd, const char* pathname) {
     return bpf(BPF_OBJ_PIN, {
                                     .pathname = ptr_to_u64(pathname),
-                                    .bpf_fd = BPF_FD_TO_U32(map_fd),
+                                    .bpf_fd = static_cast<__u32>(map_fd.get()),
                             });
 }
 
@@ -131,22 +128,15 @@
                             });
 }
 
-int bpfGetFdMapId(const BPF_FD_TYPE map_fd);
+int bpfGetFdMapId(const borrowed_fd& map_fd);
 
 inline int bpfLock(int fd, short type) {
     if (fd < 0) return fd;  // pass any errors straight through
 #ifdef BPF_MAP_LOCKLESS_FOR_TEST
     return fd;
 #endif
-#ifdef BPF_FD_JUST_USE_INT
     int mapId = bpfGetFdMapId(fd);
     int saved_errno = errno;
-#else
-    base::unique_fd ufd(fd);
-    int mapId = bpfGetFdMapId(ufd);
-    int saved_errno = errno;
-    (void)ufd.release();
-#endif
     // 4.14+ required to fetch map id, but we don't want to call isAtLeastKernelVersion
     if (mapId == -1 && saved_errno == EINVAL) return fd;
     if (mapId <= 0) abort();  // should not be possible
@@ -193,37 +183,35 @@
 }
 
 inline bool usableProgram(const char* pathname) {
-    int fd = retrieveProgram(pathname);
-    bool ok = (fd >= 0);
-    if (ok) close(fd);
-    return ok;
+    unique_fd fd(retrieveProgram(pathname));
+    return fd.ok();
 }
 
-inline int attachProgram(bpf_attach_type type, const BPF_FD_TYPE prog_fd,
-                         const BPF_FD_TYPE cg_fd, uint32_t flags = 0) {
+inline int attachProgram(bpf_attach_type type, const borrowed_fd& prog_fd,
+                         const borrowed_fd& cg_fd, uint32_t flags = 0) {
     return bpf(BPF_PROG_ATTACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
-                                        .attach_bpf_fd = BPF_FD_TO_U32(prog_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
+                                        .attach_bpf_fd = static_cast<__u32>(prog_fd.get()),
                                         .attach_type = type,
                                         .attach_flags = flags,
                                 });
 }
 
-inline int detachProgram(bpf_attach_type type, const BPF_FD_TYPE cg_fd) {
+inline int detachProgram(bpf_attach_type type, const borrowed_fd& cg_fd) {
     return bpf(BPF_PROG_DETACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
                                         .attach_type = type,
                                 });
 }
 
-inline int queryProgram(const BPF_FD_TYPE cg_fd,
+inline int queryProgram(const borrowed_fd& cg_fd,
                         enum bpf_attach_type attach_type,
                         __u32 query_flags = 0,
                         __u32 attach_flags = 0) {
     int prog_id = -1;  // equivalent to an array of one integer.
     bpf_attr arg = {
             .query = {
-                    .target_fd = BPF_FD_TO_U32(cg_fd),
+                    .target_fd = static_cast<__u32>(cg_fd.get()),
                     .attach_type = attach_type,
                     .query_flags = query_flags,
                     .attach_flags = attach_flags,
@@ -237,21 +225,21 @@
     return prog_id;  // return actual id
 }
 
-inline int detachSingleProgram(bpf_attach_type type, const BPF_FD_TYPE prog_fd,
-                               const BPF_FD_TYPE cg_fd) {
+inline int detachSingleProgram(bpf_attach_type type, const borrowed_fd& prog_fd,
+                               const borrowed_fd& cg_fd) {
     return bpf(BPF_PROG_DETACH, {
-                                        .target_fd = BPF_FD_TO_U32(cg_fd),
-                                        .attach_bpf_fd = BPF_FD_TO_U32(prog_fd),
+                                        .target_fd = static_cast<__u32>(cg_fd.get()),
+                                        .attach_bpf_fd = static_cast<__u32>(prog_fd.get()),
                                         .attach_type = type,
                                 });
 }
 
 // Available in 4.12 and later kernels.
-inline int runProgram(const BPF_FD_TYPE prog_fd, const void* data,
+inline int runProgram(const borrowed_fd& prog_fd, const void* data,
                       const uint32_t data_size) {
     return bpf(BPF_PROG_RUN, {
                                      .test = {
-                                             .prog_fd = BPF_FD_TO_U32(prog_fd),
+                                             .prog_fd = static_cast<__u32>(prog_fd.get()),
                                              .data_size_in = data_size,
                                              .data_in = ptr_to_u64(data),
                                      },
@@ -265,10 +253,10 @@
 // supported/returned by the running kernel.  We do this by checking it is fully
 // within the bounds of the struct size as reported by the kernel.
 #define DEFINE_BPF_GET_FD(TYPE, NAME, FIELD) \
-inline int bpfGetFd ## NAME(const BPF_FD_TYPE fd) { \
+inline int bpfGetFd ## NAME(const borrowed_fd& fd) { \
     struct bpf_ ## TYPE ## _info info = {}; \
     union bpf_attr attr = { .info = { \
-        .bpf_fd = BPF_FD_TO_U32(fd), \
+        .bpf_fd = static_cast<__u32>(fd.get()), \
         .info_len = sizeof(info), \
         .info = ptr_to_u64(&info), \
     }}; \
@@ -283,19 +271,16 @@
 
 // All 7 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 BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MapId, id)                // int bpfGetFdMapId(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, KeySize, key_size)        // int bpfGetFdKeySize(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, ValueSize, value_size)    // int bpfGetFdValueSize(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MaxEntries, max_entries)  // int bpfGetFdMaxEntries(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(map, MapFlags, map_flags)      // int bpfGetFdMapFlags(const BPF_FD_TYPE map_fd)
-DEFINE_BPF_GET_FD(prog, ProgId, id)              // int bpfGetFdProgId(const BPF_FD_TYPE prog_fd)
+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)
+DEFINE_BPF_GET_FD(map, KeySize, key_size)        // int bpfGetFdKeySize(const borrowed_fd& map_fd)
+DEFINE_BPF_GET_FD(map, ValueSize, value_size)    // int bpfGetFdValueSize(const borrowed_fd& map_fd)
+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)
 
 #undef DEFINE_BPF_GET_FD
 
 }  // namespace bpf
 }  // namespace android
 
-#undef BPF_FD_TO_U32
-#undef BPF_FD_TYPE
-#undef BPF_FD_JUST_USE_INT
diff --git a/bpf/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
index f3c6907..0b5b7be 100644
--- a/bpf/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -82,13 +82,13 @@
 
 // Provided by *current* mainline module for T+ devices
 static const set<string> MAINLINE_FOR_T_PLUS = {
-    SHARED "map_block_blocked_ports_map",
     SHARED "map_clatd_clat_egress4_map",
     SHARED "map_clatd_clat_ingress6_map",
     SHARED "map_dscpPolicy_ipv4_dscp_policies_map",
     SHARED "map_dscpPolicy_ipv6_dscp_policies_map",
     SHARED "map_dscpPolicy_socket_policy_cache_map",
     NETD "map_netd_app_uid_stats_map",
+    NETD "map_netd_blocked_ports_map",
     NETD "map_netd_configuration_map",
     NETD "map_netd_cookie_tag_map",
     NETD "map_netd_data_saver_enabled_map",
@@ -119,8 +119,13 @@
 
 // Provided by *current* mainline module for T+ devices with 5.4+ kernels
 static const set<string> MAINLINE_FOR_T_4_19_PLUS = {
-    NETD_RO "prog_block_bind4_block_port",
-    NETD_RO "prog_block_bind6_block_port",
+    NETD "prog_netd_bind4_inet4_bind",
+    NETD "prog_netd_bind6_inet6_bind",
+};
+
+// Provided by *current* mainline module for T+ devices with 5.10+ kernels
+static const set<string> MAINLINE_FOR_T_5_10_PLUS = {
+    NETD "prog_netd_cgroupsockrelease_inet_release",
 };
 
 // Provided by *current* mainline module for T+ devices with 5.15+ kernels
@@ -154,11 +159,6 @@
     NETD "prog_netd_setsockopt_prog",
 };
 
-// Provided by *current* mainline module for V+ devices with 5.10+ kernels
-static const set<string> MAINLINE_FOR_V_5_10_PLUS = {
-    NETD "prog_netd_cgroupsockrelease_inet_release",
-};
-
 static void addAll(set<string>& a, const set<string>& b) {
     a.insert(b.begin(), b.end());
 }
@@ -196,6 +196,7 @@
     DO_EXPECT(IsAtLeastT(), MAINLINE_FOR_T_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
+    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_T_5_10_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
 
     // U requires Linux Kernel 4.14+, but nothing (as yet) added or removed in U.
@@ -207,7 +208,6 @@
     if (IsAtLeastV()) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
     DO_EXPECT(IsAtLeastV(), MAINLINE_FOR_V_PLUS);
     DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_V_5_4_PLUS);
-    DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_V_5_10_PLUS);
 
     for (const auto& file : mustExist) {
         EXPECT_EQ(0, access(file.c_str(), R_OK)) << file << " does not exist";
diff --git a/clatd/main.c b/clatd/main.c
index f888041..7aa1671 100644
--- a/clatd/main.c
+++ b/clatd/main.c
@@ -37,7 +37,7 @@
 /* function: stop_loop
  * signal handler: stop the event loop
  */
-static void stop_loop() { running = 0; };
+static void stop_loop(__attribute__((unused)) int unused) { running = 0; };
 
 /* function: print_help
  * in case the user is running this on the command line
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 4c6d8ba..60a827b 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -41,7 +41,7 @@
 }
 
 flag {
-  name: "tethering_request_with_soft_ap_config"
+  name: "tethering_with_soft_ap_config"
   is_exported: true
   namespace: "android_core_networking"
   description: "The flag controls the access for the parcelable TetheringRequest with getSoftApConfiguration/setSoftApConfiguration API"
@@ -156,3 +156,12 @@
   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
+}
diff --git a/common/networksecurity_flags.aconfig b/common/networksecurity_flags.aconfig
index 6438ba4..4a83af4 100644
--- a/common/networksecurity_flags.aconfig
+++ b/common/networksecurity_flags.aconfig
@@ -8,3 +8,12 @@
     bug: "319829948"
     is_fixed_read_only: true
 }
+
+flag {
+    name: "certificate_transparency_job"
+    is_exported: true
+    namespace: "network_security"
+    description: "Enable daily job service for certificate transparency instead of flags listener"
+    bug: "319829948"
+    is_fixed_read_only: true
+}
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index c11c6c0..8cc2bb4 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -26,3 +26,30 @@
     description: "Controls whether the Android Thread setting max power of channel feature is enabled"
     bug: "346686506"
 }
+
+flag {
+    name: "epskc_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether the Android Thread Ephemeral Key feature is enabled"
+    bug: "348323500"
+}
+
+flag {
+    name: "set_nat64_configuration_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether the setConfiguration API of NAT64 feature is enabled"
+    bug: "368456504"
+}
+
+flag {
+    name: "thread_mobile_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether Thread support for mobile devices is enabled"
+    bug: "368867060"
+}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 7551b92..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",
@@ -172,6 +173,10 @@
         // Tests using hidden APIs
         "//cts/tests/netlegacy22.api",
         "//cts/tests/tests/app.usage", // NetworkUsageStatsTest
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
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/api/system-current.txt b/framework-t/api/system-current.txt
index 9f26bcf..5f8f0e3 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -500,14 +500,23 @@
 
   @FlaggedApi("com.android.net.thread.flags.configuration_enabled") public final class ThreadConfiguration implements android.os.Parcelable {
     method public int describeContents();
-    method public boolean isDhcpv6PdEnabled();
     method public boolean isNat64Enabled();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.ThreadConfiguration> CREATOR;
   }
 
+  @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public static final class ThreadConfiguration.Builder {
+    ctor @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public ThreadConfiguration.Builder();
+    ctor @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public ThreadConfiguration.Builder(@NonNull android.net.thread.ThreadConfiguration);
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @NonNull public android.net.thread.ThreadConfiguration build();
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @NonNull public android.net.thread.ThreadConfiguration.Builder setNat64Enabled(boolean);
+  }
+
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController {
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void activateEphemeralKeyMode(@NonNull java.time.Duration, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method public void createRandomizedDataset(@NonNull String, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<android.net.thread.ActiveOperationalDataset,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void deactivateEphemeralKeyMode(@NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @NonNull public java.time.Duration getMaxEphemeralKeyLifetime();
     method public int getThreadVersion();
     method public static boolean isAttached(int);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void join(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
@@ -517,6 +526,7 @@
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @FlaggedApi("com.android.net.thread.flags.channel_max_powers_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setChannelMaxPowers(@NonNull @Size(min=1) android.util.SparseIntArray, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @RequiresPermission(android.Manifest.permission.THREAD_NETWORK_PRIVILEGED) public void setConfiguration(@NonNull android.net.thread.ThreadConfiguration, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setEnabled(boolean, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @FlaggedApi("com.android.net.thread.flags.configuration_enabled") @RequiresPermission(android.Manifest.permission.THREAD_NETWORK_PRIVILEGED) public void unregisterConfigurationCallback(@NonNull java.util.function.Consumer<android.net.thread.ThreadConfiguration>);
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
@@ -526,6 +536,9 @@
     field public static final int DEVICE_ROLE_LEADER = 4; // 0x4
     field public static final int DEVICE_ROLE_ROUTER = 3; // 0x3
     field public static final int DEVICE_ROLE_STOPPED = 0; // 0x0
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_DISABLED = 0; // 0x0
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_ENABLED = 1; // 0x1
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_IN_USE = 2; // 0x2
     field public static final int MAX_POWER_CHANNEL_DISABLED = -2147483648; // 0x80000000
     field public static final int STATE_DISABLED = 0; // 0x0
     field public static final int STATE_DISABLING = 2; // 0x2
@@ -540,6 +553,7 @@
 
   public static interface ThreadNetworkController.StateCallback {
     method public void onDeviceRoleChanged(int);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public default void onEphemeralKeyStateChanged(int, @Nullable String, @Nullable java.time.Instant);
     method public default void onPartitionIdChanged(long);
     method public default void onThreadEnableStateChanged(int);
   }
diff --git a/framework-t/src/android/net/INetworkStatsService.aidl b/framework-t/src/android/net/INetworkStatsService.aidl
index 01ac106..b459a13 100644
--- a/framework-t/src/android/net/INetworkStatsService.aidl
+++ b/framework-t/src/android/net/INetworkStatsService.aidl
@@ -21,10 +21,11 @@
 import android.net.Network;
 import android.net.NetworkStateSnapshot;
 import android.net.NetworkStats;
-import android.net.NetworkStatsHistory;
 import android.net.NetworkTemplate;
 import android.net.UnderlyingNetworkInfo;
 import android.net.netstats.IUsageCallback;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProvider;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.os.IBinder;
@@ -78,16 +79,13 @@
     void unregisterUsageRequest(in DataUsageRequest request);
 
     /** Get the uid stats information since boot */
-    NetworkStats getTypelessUidStats(int uid);
+    StatsResult getUidStats(int uid);
 
     /** Get the iface stats information since boot */
-    NetworkStats getTypelessIfaceStats(String iface);
+    StatsResult getIfaceStats(String iface);
 
     /** Get the total network stats information since boot */
-    NetworkStats getTypelessTotalStats();
-
-    /** Get the uid stats information (with specified type) since boot */
-    long getUidStats(int uid, int type);
+    StatsResult getTotalStats();
 
     /** Registers a network stats provider */
     INetworkStatsProviderCallback registerNetworkStatsProvider(String tag,
@@ -107,4 +105,7 @@
 
      /** Clear TrafficStats rate-limit caches. */
      void clearTrafficStatsRateLimitCaches();
+
+     /** Get rate-limit cache config. */
+     TrafficStatsRateLimitCacheConfig getRateLimitCacheConfig();
 }
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 3b6a69b..868033a 100644
--- a/framework-t/src/android/net/TrafficStats.java
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -17,8 +17,12 @@
 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;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
@@ -29,20 +33,26 @@
 import android.compat.annotation.UnsupportedAppUsage;
 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.Iterator;
-import java.util.Objects;
-
+import java.util.function.LongSupplier;
 
 /**
  * Class that provides network traffic statistics. These statistics include
@@ -177,10 +187,51 @@
     /** @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;
         if (sStatsService == null) {
             throw new IllegalStateException("TrafficStats not initialized, uid="
                     + Binder.getCallingUid());
@@ -188,6 +239,43 @@
         return sStatsService;
     }
 
+    /** @hide */
+    private static int getMyUid() {
+        return android.os.Process.myUid();
+    }
+
+    /**
+     * Set the network stats service for testing, or null to reset.
+     *
+     * @hide
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public static void setServiceForTest(INetworkStatsService statsService) {
+        sStatsServiceForTest = statsService;
+    }
+
+    /**
+     * 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.
@@ -228,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
@@ -242,8 +416,8 @@
 
     private static class SocketTagger extends dalvik.system.SocketTagger {
 
-        // TODO: set to false
-        private static final boolean LOGD = true;
+        // Enable log with `setprop log.tag.TrafficStats DEBUG` and restart the module.
+        private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
 
         SocketTagger() {
         }
@@ -450,7 +624,7 @@
      */
     @Deprecated
     public static void setThreadStatsUidSelf() {
-        setThreadStatsUid(android.os.Process.myUid());
+        setThreadStatsUid(getMyUid());
     }
 
     /**
@@ -591,7 +765,7 @@
      * @param operationCount Number of operations to increment count by.
      */
     public static void incrementOperationCount(int tag, int operationCount) {
-        final int uid = android.os.Process.myUid();
+        final int uid = getMyUid();
         try {
             getStatsService().incrementOperationCount(uid, tag, operationCount);
         } catch (RemoteException e) {
@@ -710,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) {
@@ -959,45 +1141,76 @@
 
     /** @hide */
     public static long getUidStats(int uid, int type) {
-        if (!isEntryValueTypeValid(type)
-                || android.os.Process.myUid() != uid) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats 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().getTypelessUidStats(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 getValueForTypeFromFirstEntry(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) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
-        try {
-            stats = getStatsService().getTypelessTotalStats();
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-        return getValueForTypeFromFirstEntry(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) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
-        try {
-            stats = getStatsService().getTypelessIfaceStats(iface);
-        } catch (RemoteException e) {
-            throw e.rethrowFromSystemServer();
-        }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return fetchStats(maybeGetRateLimitIfaceCache(), iface,
+                () -> getStatsService().getIfaceStats(iface), type);
     }
 
     /**
@@ -1094,7 +1307,7 @@
      */
     private static NetworkStats getDataLayerSnapshotForUid(Context context) {
         // TODO: take snapshot locally, since proc file is now visible
-        final int uid = android.os.Process.myUid();
+        final int uid = getMyUid();
         try {
             return getStatsService().getDataLayerSnapshotForUid(uid);
         } catch (RemoteException e) {
@@ -1127,18 +1340,18 @@
     public static final int TYPE_TX_PACKETS = 3;
 
     /** @hide */
-    private static long getEntryValueForType(@NonNull NetworkStats.Entry entry, int type) {
-        Objects.requireNonNull(entry);
+    private static long getEntryValueForType(@Nullable StatsResult stats, int type) {
+        if (stats == null) return UNSUPPORTED;
         if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
         switch (type) {
             case TYPE_RX_BYTES:
-                return entry.getRxBytes();
+                return stats.rxBytes;
             case TYPE_RX_PACKETS:
-                return entry.getRxPackets();
+                return stats.rxPackets;
             case TYPE_TX_BYTES:
-                return entry.getTxBytes();
+                return stats.txBytes;
             case TYPE_TX_PACKETS:
-                return entry.getTxPackets();
+                return stats.txPackets;
             default:
                 throw new IllegalStateException("Bug: Invalid type: "
                         + type + " should not reach here.");
@@ -1157,13 +1370,5 @@
                 return false;
         }
     }
-
-    /** @hide */
-    public static long getValueForTypeFromFirstEntry(@NonNull NetworkStats stats, int type) {
-        Objects.requireNonNull(stats);
-        Iterator<NetworkStats.Entry> iter = stats.iterator();
-        if (!iter.hasNext()) return UNSUPPORTED;
-        return getEntryValueForType(iter.next(), type);
-    }
 }
 
diff --git a/framework-t/src/android/net/netstats/StatsResult.aidl b/framework-t/src/android/net/netstats/StatsResult.aidl
new file mode 100644
index 0000000..3f09566
--- /dev/null
+++ b/framework-t/src/android/net/netstats/StatsResult.aidl
@@ -0,0 +1,31 @@
+/**
+ * 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.netstats;
+
+/**
+ * A lightweight class to pass result of TrafficStats#get{Total|Iface|Uid}Stats.
+ *
+ * @hide
+ */
+@JavaDerive(equals=true, toString=true)
+@JavaOnlyImmutable
+parcelable StatsResult {
+    long rxBytes;
+    long rxPackets;
+    long txBytes;
+    long txPackets;
+}
\ No newline at end of file
diff --git a/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl b/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl
new file mode 100644
index 0000000..cdf0b7c
--- /dev/null
+++ b/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl
@@ -0,0 +1,42 @@
+/**
+ * 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.netstats;
+
+/**
+ * Configuration for the TrafficStats rate limit cache.
+ *
+ * @hide
+ */
+@JavaDerive(equals=true, toString=true)
+@JavaOnlyImmutable
+parcelable TrafficStatsRateLimitCacheConfig {
+
+    /**
+     * Whether the cache is enabled for V+ device or target Sdk V+ apps.
+     */
+    boolean isCacheEnabled;
+
+    /**
+     * The duration for which cache entries are valid, in milliseconds.
+     */
+    int expiryDurationMs;
+
+    /**
+     * The maximum number of entries to store in the cache.
+     */
+    int maxEntries;
+}
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 0334e11..a1c6a15 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -64,6 +64,7 @@
         ":net-utils-framework-common-srcs",
         ":framework-connectivity-api-shared-srcs",
         ":framework-networksecurity-sources",
+        ":statslog-framework-connectivity-java-gen",
     ],
     aidl: {
         generate_get_transaction_name: true,
@@ -74,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: [
@@ -104,6 +106,7 @@
         "androidx.annotation_annotation",
         "app-compat-annotations",
         "framework-connectivity-t.stubs.module_lib",
+        "framework-statsd.stubs.module_lib",
         "unsupportedappusage",
     ],
     apex_available: [
@@ -141,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",
@@ -156,6 +160,7 @@
 java_defaults {
     name: "CronetJavaDefaults",
     srcs: [":httpclient_api_sources"],
+    static_libs: ["com.android.net.http.flags-aconfig-java"],
     libs: [
         "androidx.annotation_annotation",
     ],
@@ -187,6 +192,10 @@
         // Tests using hidden APIs
         "//cts/tests/netlegacy22.api",
         "//cts/tests/tests/app.usage", // NetworkUsageStatsTest
+
+        // TODO: b/374174952 Remove it when VCN CTS is moved to Connectivity/
+        "//cts/tests/tests/vcn",
+
         "//external/sl4a:__subpackages__",
         "//frameworks/base/core/tests/bandwidthtests",
         "//frameworks/base/core/tests/benchmarks",
@@ -212,6 +221,7 @@
     },
     aconfig_declarations: [
         "com.android.net.flags-aconfig",
+        "com.android.net.http.flags-aconfig",
         "com.android.networksecurity.flags-aconfig",
     ],
 }
@@ -326,6 +336,7 @@
     aidl: {
         include_dirs: [
             "packages/modules/Connectivity/framework/aidl-export",
+            "packages/modules/Connectivity/Tethering/common/TetheringLib/src",
             "frameworks/native/aidl/binder", // For PersistableBundle.aidl
         ],
     },
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/api/module-lib-current.txt b/framework/api/module-lib-current.txt
index cd7307f..0129e5c 100644
--- a/framework/api/module-lib-current.txt
+++ b/framework/api/module-lib-current.txt
@@ -247,3 +247,11 @@
 
 }
 
+package android.net.http {
+
+  public abstract class HttpEngine {
+    method @FlaggedApi("android.net.http.preload_httpengine_in_zygote") public static void preload();
+  }
+
+}
+
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..5d99b74 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;
@@ -1873,7 +1874,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 +1968,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 +1994,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 +2028,7 @@
             int uid, @NonNull String packageName) {
         try {
             return mService.getRedactedNetworkCapabilitiesForPackage(nc, uid, packageName,
-                    getAttributionTag());
+                    mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -2752,21 +2753,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 +3065,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 +3091,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
@@ -4279,12 +4274,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 +4426,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 +4499,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 +4520,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 +4551,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 +4631,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 +4737,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);
@@ -4985,6 +5017,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 +5194,7 @@
         try {
             mService.pendingRequestForNetwork(
                     request.networkCapabilities, operation, mContext.getOpPackageName(),
-                    getAttributionTag());
+                    mContext.getAttributionTag());
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         } catch (ServiceSpecificException e) {
@@ -5276,7 +5343,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/L2capNetworkSpecifier.java b/framework/src/android/net/L2capNetworkSpecifier.java
new file mode 100644
index 0000000..cfc9ed9
--- /dev/null
+++ b/framework/src/android/net/L2capNetworkSpecifier.java
@@ -0,0 +1,370 @@
+/*
+ * 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;
+    }
+
+    /** 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 574ab2f..cefa1ea 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -31,12 +31,14 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteException;
 import android.telephony.data.EpsBearerQosSessionAttributes;
 import android.telephony.data.NrQosSessionAttributes;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.FrameworkConnectivityStatsLog;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -943,6 +945,19 @@
 
     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.
+
+                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);
diff --git a/framework/src/android/net/NetworkAgentConfig.java b/framework/src/android/net/NetworkAgentConfig.java
index da12a0a..deaa734 100644
--- a/framework/src/android/net/NetworkAgentConfig.java
+++ b/framework/src/android/net/NetworkAgentConfig.java
@@ -272,6 +272,27 @@
         return mVpnRequiresValidation;
     }
 
+    /**
+     * Whether the native network creation should be skipped.
+     *
+     * If set, the native network and routes should be maintained by the caller.
+     *
+     * @hide
+     */
+    private boolean mSkipNativeNetworkCreation = false;
+
+
+    /**
+     * @return Whether the native network creation should be skipped.
+     * @hide
+     */
+    // TODO: Expose API when ready.
+    // @FlaggedApi(Flags.FLAG_TETHERING_NETWORK_AGENT)
+    // @SystemApi(client = MODULE_LIBRARIES) when ready.
+    public boolean shouldSkipNativeNetworkCreation() {
+        return mSkipNativeNetworkCreation;
+    }
+
     /** @hide */
     public NetworkAgentConfig() {
     }
@@ -293,6 +314,7 @@
             mLegacyExtraInfo = nac.mLegacyExtraInfo;
             excludeLocalRouteVpn = nac.excludeLocalRouteVpn;
             mVpnRequiresValidation = nac.mVpnRequiresValidation;
+            mSkipNativeNetworkCreation = nac.mSkipNativeNetworkCreation;
         }
     }
 
@@ -484,6 +506,26 @@
         }
 
         /**
+         * Sets the native network creation should be skipped.
+         *
+         * @return this builder, to facilitate chaining.
+         * @hide
+         */
+        @NonNull
+        // TODO: Expose API when ready.
+        // @FlaggedApi(Flags.FLAG_TETHERING_NETWORK_AGENT)
+        // @SystemApi(client = MODULE_LIBRARIES) when ready.
+        public Builder setSkipNativeNetworkCreation(boolean skipNativeNetworkCreation) {
+            if (!SdkLevel.isAtLeastV()) {
+                // Local agents are supported starting on U on TVs and on V on everything else.
+                // Thus, only support this flag on V+.
+                throw new UnsupportedOperationException("Method is not supported");
+            }
+            mConfig.mSkipNativeNetworkCreation = skipNativeNetworkCreation;
+            return this;
+        }
+
+        /**
          * Returns the constructed {@link NetworkAgentConfig} object.
          */
         @NonNull
@@ -510,7 +552,8 @@
                 && Objects.equals(legacySubTypeName, that.legacySubTypeName)
                 && Objects.equals(mLegacyExtraInfo, that.mLegacyExtraInfo)
                 && excludeLocalRouteVpn == that.excludeLocalRouteVpn
-                && mVpnRequiresValidation == that.mVpnRequiresValidation;
+                && mVpnRequiresValidation == that.mVpnRequiresValidation
+                && mSkipNativeNetworkCreation == that.mSkipNativeNetworkCreation;
     }
 
     @Override
@@ -518,7 +561,8 @@
         return Objects.hash(allowBypass, explicitlySelected, acceptUnvalidated,
                 acceptPartialConnectivity, provisioningNotificationDisabled, subscriberId,
                 skip464xlat, legacyType, legacySubType, legacyTypeName, legacySubTypeName,
-                mLegacyExtraInfo, excludeLocalRouteVpn, mVpnRequiresValidation);
+                mLegacyExtraInfo, excludeLocalRouteVpn, mVpnRequiresValidation,
+                mSkipNativeNetworkCreation);
     }
 
     @Override
@@ -539,6 +583,7 @@
                 + ", legacyExtraInfo = '" + mLegacyExtraInfo + '\''
                 + ", excludeLocalRouteVpn = '" + excludeLocalRouteVpn + '\''
                 + ", vpnRequiresValidation = '" + mVpnRequiresValidation + '\''
+                + ", skipNativeNetworkCreation = '" + mSkipNativeNetworkCreation + '\''
                 + "}";
     }
 
@@ -563,33 +608,35 @@
         out.writeString(mLegacyExtraInfo);
         out.writeInt(excludeLocalRouteVpn ? 1 : 0);
         out.writeInt(mVpnRequiresValidation ? 1 : 0);
+        out.writeInt(mSkipNativeNetworkCreation ? 1 : 0);
     }
 
     public static final @NonNull Creator<NetworkAgentConfig> CREATOR =
             new Creator<NetworkAgentConfig>() {
-        @Override
-        public NetworkAgentConfig createFromParcel(Parcel in) {
-            NetworkAgentConfig networkAgentConfig = new NetworkAgentConfig();
-            networkAgentConfig.allowBypass = in.readInt() != 0;
-            networkAgentConfig.explicitlySelected = in.readInt() != 0;
-            networkAgentConfig.acceptUnvalidated = in.readInt() != 0;
-            networkAgentConfig.acceptPartialConnectivity = in.readInt() != 0;
-            networkAgentConfig.subscriberId = in.readString();
-            networkAgentConfig.provisioningNotificationDisabled = in.readInt() != 0;
-            networkAgentConfig.skip464xlat = in.readInt() != 0;
-            networkAgentConfig.legacyType = in.readInt();
-            networkAgentConfig.legacyTypeName = in.readString();
-            networkAgentConfig.legacySubType = in.readInt();
-            networkAgentConfig.legacySubTypeName = in.readString();
-            networkAgentConfig.mLegacyExtraInfo = in.readString();
-            networkAgentConfig.excludeLocalRouteVpn = in.readInt() != 0;
-            networkAgentConfig.mVpnRequiresValidation = in.readInt() != 0;
-            return networkAgentConfig;
-        }
+                @Override
+                public NetworkAgentConfig createFromParcel(Parcel in) {
+                    NetworkAgentConfig networkAgentConfig = new NetworkAgentConfig();
+                    networkAgentConfig.allowBypass = in.readInt() != 0;
+                    networkAgentConfig.explicitlySelected = in.readInt() != 0;
+                    networkAgentConfig.acceptUnvalidated = in.readInt() != 0;
+                    networkAgentConfig.acceptPartialConnectivity = in.readInt() != 0;
+                    networkAgentConfig.subscriberId = in.readString();
+                    networkAgentConfig.provisioningNotificationDisabled = in.readInt() != 0;
+                    networkAgentConfig.skip464xlat = in.readInt() != 0;
+                    networkAgentConfig.legacyType = in.readInt();
+                    networkAgentConfig.legacyTypeName = in.readString();
+                    networkAgentConfig.legacySubType = in.readInt();
+                    networkAgentConfig.legacySubTypeName = in.readString();
+                    networkAgentConfig.mLegacyExtraInfo = in.readString();
+                    networkAgentConfig.excludeLocalRouteVpn = in.readInt() != 0;
+                    networkAgentConfig.mVpnRequiresValidation = in.readInt() != 0;
+                    networkAgentConfig.mSkipNativeNetworkCreation = in.readInt() != 0;
+                    return networkAgentConfig;
+                }
 
-        @Override
-        public NetworkAgentConfig[] newArray(int size) {
-            return new NetworkAgentConfig[size];
-        }
-    };
+                @Override
+                public NetworkAgentConfig[] newArray(int size) {
+                    return new NetworkAgentConfig[size];
+                }
+            };
 }
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index 4a50397..c6b62ee 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -359,6 +359,7 @@
         mSubIds = new ArraySet<>();
         mUnderlyingNetworks = null;
         mEnterpriseId = 0;
+        mReservationId = RES_ID_UNSET;
     }
 
     /**
@@ -393,6 +394,7 @@
         // necessary.
         mUnderlyingNetworks = nc.mUnderlyingNetworks;
         mEnterpriseId = nc.mEnterpriseId;
+        mReservationId = nc.mReservationId;
     }
 
     /**
@@ -2233,7 +2235,8 @@
                 && (onlyImmutable || satisfiedByUids(nc))
                 && (onlyImmutable || satisfiedBySSID(nc))
                 && (onlyImmutable || satisfiedByRequestor(nc))
-                && (onlyImmutable || satisfiedBySubscriptionIds(nc)));
+                && (onlyImmutable || satisfiedBySubscriptionIds(nc)))
+                && satisfiedByReservationId(nc);
     }
 
     /**
@@ -2347,7 +2350,8 @@
                 && equalsAdministratorUids(that)
                 && equalsSubscriptionIds(that)
                 && equalsUnderlyingNetworks(that)
-                && equalsEnterpriseCapabilitiesId(that);
+                && equalsEnterpriseCapabilitiesId(that)
+                && equalsReservationId(that);
     }
 
     @Override
@@ -2373,7 +2377,9 @@
                 + Arrays.hashCode(mAdministratorUids) * 67
                 + Objects.hashCode(mSubIds) * 71
                 + Objects.hashCode(mUnderlyingNetworks) * 73
-                + mEnterpriseId * 79;
+                + mEnterpriseId * 79
+                + mReservationId * 83;
+
     }
 
     @Override
@@ -2411,6 +2417,7 @@
         dest.writeIntArray(CollectionUtils.toIntArray(mSubIds));
         dest.writeTypedList(mUnderlyingNetworks);
         dest.writeInt(mEnterpriseId & ALL_VALID_ENTERPRISE_IDS);
+        dest.writeInt(mReservationId);
     }
 
     public static final @android.annotation.NonNull Creator<NetworkCapabilities> CREATOR =
@@ -2446,6 +2453,7 @@
                 }
                 netCap.setUnderlyingNetworks(in.createTypedArrayList(Network.CREATOR));
                 netCap.mEnterpriseId = in.readInt() & ALL_VALID_ENTERPRISE_IDS;
+                netCap.mReservationId = in.readInt();
                 return netCap;
             }
             @Override
@@ -2548,6 +2556,11 @@
                     NetworkCapabilities::enterpriseIdNameOf, "&");
         }
 
+        if (mReservationId != RES_ID_UNSET) {
+            final boolean isReservationOffer = (mReservationId == RES_ID_MATCH_ALL_RESERVATIONS);
+            sb.append(" ReservationId: ").append(isReservationOffer ? "*" : mReservationId);
+        }
+
         sb.append(" UnderlyingNetworks: ");
         if (mUnderlyingNetworks != null) {
             sb.append("[");
@@ -2876,6 +2889,65 @@
     }
 
     /**
+     * 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;
+    }
+
+
+
+    /**
      * 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/connectivity/ConnectivityCompatChanges.java b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
index 51df8ab..0536263 100644
--- a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
+++ b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
@@ -135,6 +135,17 @@
     @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.
+     *
+     * @hide
+     */
+    @ChangeId
+    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.CUR_DEVELOPMENT)
+    public static final long RESTRICT_LOCAL_NETWORK = 365139289L;
+
     private ConnectivityCompatChanges() {
     }
 }
diff --git a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
index 6e87ed3..ba39ca0 100644
--- a/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/ConnectivityInternalApiUtil.java
@@ -24,8 +24,8 @@
 import androidx.annotation.RequiresApi;
 
 /**
- * Utility providing limited access to module-internal APIs which are only available on Android T+,
- * as this class is only in the bootclasspath on T+ as part of framework-connectivity.
+ * Utility providing limited access to module-internal APIs which are only available on Android S+,
+ * as this class is only in the bootclasspath on S+ as part of framework-connectivity.
  *
  * R+ module components like Tethering cannot depend on all hidden symbols from
  * framework-connectivity. They only have access to stable API stubs where newer APIs can be
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 39adee3..e78f999 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -32,7 +32,6 @@
 import android.nearby.aidl.IOffloadCallback;
 import android.os.RemoteException;
 import android.os.SystemProperties;
-import android.provider.Settings;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
@@ -123,18 +122,11 @@
 
     private static final int POWERED_OFF_FINDING_EID_LENGTH = 20;
 
-    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY =
+    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_RO =
             "ro.bluetooth.finder.supported";
 
-    /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Whether allows Fast Pair to scan.
-     *
-     * (0 = disabled, 1 = enabled)
-     *
-     * @hide
-     */
-    public static final String FAST_PAIR_SCAN_ENABLED = "fast_pair_scan_enabled";
+    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST =
+            "persist.bluetooth.finder.supported";
 
     @GuardedBy("sScanListeners")
     private static final WeakHashMap<ScanCallback, WeakReference<ScanListenerTransport>>
@@ -476,36 +468,6 @@
     }
 
     /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Read from {@link Settings} whether Fast Pair scan is enabled.
-     *
-     * @param context the {@link Context} to query the setting
-     * @return whether the Fast Pair is enabled
-     * @hide
-     */
-    public static boolean getFastPairScanEnabled(@NonNull Context context) {
-        final int enabled = Settings.Secure.getInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, 0);
-        return enabled != 0;
-    }
-
-    /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Write into {@link Settings} whether Fast Pair scan is enabled
-     *
-     * @param context the {@link Context} to set the setting
-     * @param enable whether the Fast Pair scan should be enabled
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
-    public static void setFastPairScanEnabled(@NonNull Context context, boolean enable) {
-        Settings.Secure.putInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, enable ? 1 : 0);
-        Log.v(TAG, String.format(
-                "successfully %s Fast Pair scan", enable ? "enables" : "disables"));
-    }
-
-    /**
      * Sets the precomputed EIDs for advertising when the phone is powered off. The Bluetooth
      * controller will store these EIDs in its memory, and will start advertising them in Find My
      * Device network EID frames when powered off, only if the powered off finding mode was
@@ -618,7 +580,9 @@
     }
 
     private boolean isPoweredOffFindingSupported() {
-        return Boolean.parseBoolean(SystemProperties.get(POWER_OFF_FINDING_SUPPORTED_PROPERTY));
+        return Boolean.parseBoolean(SystemProperties.get(POWER_OFF_FINDING_SUPPORTED_PROPERTY_RO))
+                || Boolean.parseBoolean(SystemProperties.get(
+                        POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST));
     }
 
     private boolean areLocationAndBluetoothEnabled() {
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/AndroidManifest.xml b/nearby/tests/cts/fastpair/AndroidManifest.xml
index 472f4f0..9e1ec70 100644
--- a/nearby/tests/cts/fastpair/AndroidManifest.xml
+++ b/nearby/tests/cts/fastpair/AndroidManifest.xml
@@ -21,6 +21,7 @@
   <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
   <uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
   <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+  <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
 
   <application>
     <uses-library android:name="android.test.runner"/>
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
index 1e36676..58d1808 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
 import static android.nearby.ScanCallback.ERROR_UNSUPPORTED;
@@ -25,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;
@@ -78,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;
 
@@ -90,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,
+        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);
+        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);
@@ -143,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
@@ -158,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);
     }
@@ -196,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;
@@ -207,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;
@@ -233,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;
@@ -264,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/NearbyConfigurationTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
index 644e178..e0dfd31 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
@@ -42,7 +43,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
     }
 
     @Test
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
index 5b640cc..891e941 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
@@ -19,6 +19,7 @@
 import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
@@ -71,7 +72,8 @@
         when(mScanListener.asBinder()).thenReturn(mIBinder);
 
         mUiAutomation.adoptShellPermissionIdentity(
-                READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
+                READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                BLUETOOTH_PRIVILEGED);
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mService = new NearbyService(mContext);
         mScanRequest = createScanRequest();
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/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
index 7ff7b13..faa32c0 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
@@ -88,7 +89,8 @@
     @Before
     public void setUp() {
         when(mBroadcastListener.asBinder()).thenReturn(mBinder);
-        mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+        mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG, READ_DEVICE_CONFIG);
         DeviceConfig.setProperty(
                 NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, "true", false);
         DeviceConfig.setProperty(
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
index ce479c8..01028bf 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
 import static com.android.server.nearby.provider.ChreCommunication.INVALID_NANO_APP_VERSION;
@@ -76,7 +77,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
         DeviceConfig.setProperty(
                 NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION, "1", false);
 
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
index 590a46e..7f391f1 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
@@ -84,7 +85,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
 
         MockitoAnnotations.initMocks(this);
         Context context = InstrumentationRegistry.getInstrumentation().getContext();
diff --git a/networksecurity/OWNERS b/networksecurity/OWNERS
index 1a4130a..0c838c0 100644
--- a/networksecurity/OWNERS
+++ b/networksecurity/OWNERS
@@ -1,4 +1,5 @@
 # Bug component: 1479456
 
+bessiej@google.com
 sandrom@google.com
 tweek@google.com
diff --git a/networksecurity/TEST_MAPPING b/networksecurity/TEST_MAPPING
index 20ecbce..f9238c3 100644
--- a/networksecurity/TEST_MAPPING
+++ b/networksecurity/TEST_MAPPING
@@ -1,5 +1,14 @@
 {
-  "postsubmit": [
+  "presubmit": [
+    {
+      "name": "CtsNetSecConfigCertificateTransparencyTestCases"
+    },
+    {
+      "name": "CtsNetSecConfigCertificateTransparencyDefaultTestCases"
+    },
+    {
+      "name": "NetSecConfigCertificateTransparencySctLogListTestCases"
+    },
     {
       "name": "NetworkSecurityUnitTests"
     }
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index 52667ae..d7aacdb 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -24,12 +24,24 @@
 
     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",
+    ],
+
+    static_libs: [
+        "auto_value_annotations",
+        "android.security.flags-aconfig-java-export",
+    ],
+
+    plugins: [
+        "auto_value_plugin",
+        "auto_annotation_plugin",
     ],
 
     // This is included in service-connectivity which is 30+
@@ -39,3 +51,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 b2ef345..ce14fc6 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -13,28 +13,35 @@
  * 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_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS;
+
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
-import android.content.ContentResolver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
 import android.net.Uri;
 import android.os.Build;
+import android.provider.DeviceConfig;
 import android.util.Log;
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.Signature;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.Base64;
+import java.security.InvalidKeyException;
+import java.util.ArrayList;
+import java.util.List;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -42,80 +49,99 @@
 
     private static final String TAG = "CertificateTransparencyDownloader";
 
-    // TODO: move key to a DeviceConfig flag.
-    private static final byte[] PUBLIC_KEY_BYTES =
-            Base64.getDecoder()
-                    .decode(
-                            "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsu0BHGnQ++W2CTdyZyxv"
-                                + "HHRALOZPlnu/VMVgo2m+JZ8MNbAOH2cgXb8mvOj8flsX/qPMuKIaauO+PwROMjiq"
-                                + "fUpcFm80Kl7i97ZQyBDYKm3MkEYYpGN+skAR2OebX9G2DfDqFY8+jUpOOWtBNr3L"
-                                + "rmVcwx+FcFdMjGDlrZ5JRmoJ/SeGKiORkbbu9eY1Wd0uVhz/xI5bQb0OgII7hEj+"
-                                + "i/IPbJqOHgB8xQ5zWAJJ0DmG+FM6o7gk403v6W3S8qRYiR84c50KppGwe4YqSMkF"
-                                + "bLDleGQWLoaDSpEWtESisb4JiLaY4H+Kk0EyAhPSb+49JfUozYl+lf7iFN3qRq/S"
-                                + "IXXTh6z0S7Qa8EYDhKGCrpI03/+qprwy+my6fpWHi6aUIk4holUCmWvFxZDfixox"
-                                + "K0RlqbFDl2JXMBquwlQpm8u5wrsic1ksIv9z8x9zh4PJqNpCah0ciemI3YGRQqSe"
-                                + "/mRRXBiSn9YQBUPcaeqCYan+snGADFwHuXCd9xIAdFBolw9R9HTedHGUfVXPJDiF"
-                                + "4VusfX6BRR/qaadB+bqEArF/TzuDUr6FvOR4o8lUUxgLuZ/7HO+bHnaPFKYHHSm+"
-                                + "+z1lVDhhYuSZ8ax3T0C3FZpb7HMjZtpEorSV5ElKJEJwrhrBCMOD8L01EoSPrGlS"
-                                + "1w22i9uGHMn/uGQKo28u7AsCAwEAAQ==");
-
     private final Context mContext;
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
-    private final CertificateTransparencyInstaller mInstaller;
-    private final byte[] mPublicKey;
+    private final SignatureVerifier mSignatureVerifier;
+    private final CertificateTransparencyLogger mLogger;
 
-    @VisibleForTesting
+    private final List<CompatibilityVersion> mCompatVersions = new ArrayList<>();
+
+    private boolean started = false;
+
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
-            CertificateTransparencyInstaller installer,
-            byte[] publicKey) {
+            SignatureVerifier signatureVerifier,
+            CertificateTransparencyLogger logger) {
         mContext = context;
+        mSignatureVerifier = signatureVerifier;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
-        mInstaller = installer;
-        mPublicKey = publicKey;
+        mLogger = logger;
     }
 
-    CertificateTransparencyDownloader(Context context, DataStore dataStore) {
-        this(
-                context,
-                dataStore,
-                new DownloadHelper(context),
-                new CertificateTransparencyInstaller(),
-                PUBLIC_KEY_BYTES);
+    void addCompatibilityVersion(CompatibilityVersion compatVersion) {
+        mCompatVersions.add(compatVersion);
     }
 
-    void registerReceiver() {
-        IntentFilter intentFilter = new IntentFilter();
-        intentFilter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
-        mContext.registerReceiver(this, intentFilter, Context.RECEIVER_EXPORTED);
+    void start() {
+        if (started) {
+            return;
+        }
+        mContext.registerReceiver(
+                this,
+                new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
+                Context.RECEIVER_EXPORTED);
+        mDataStore.load();
+        started = true;
 
         if (Config.DEBUG) {
-            Log.d(TAG, "CertificateTransparencyDownloader initialized successfully");
+            Log.d(TAG, "CertificateTransparencyDownloader started.");
         }
     }
 
-    void startMetadataDownload(String metadataUrl) {
-        long downloadId = download(metadataUrl);
-        if (downloadId == -1) {
-            Log.e(TAG, "Metadata download request failed for " + metadataUrl);
+    void stop() {
+        if (!started) {
             return;
         }
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, downloadId);
-        mDataStore.store();
+        mContext.unregisterReceiver(this);
+        mDataStore.delete();
+        started = false;
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyDownloader stopped.");
+        }
     }
 
-    void startContentDownload(String contentUrl) {
-        long downloadId = download(contentUrl);
-        if (downloadId == -1) {
-            Log.e(TAG, "Content download request failed for " + contentUrl);
-            return;
+    long startPublicKeyDownload() {
+        long downloadId = download(Config.URL_PUBLIC_KEY);
+        if (downloadId != -1) {
+            mDataStore.setPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, downloadId);
+            mDataStore.store();
         }
-        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, downloadId);
-        mDataStore.store();
+        return downloadId;
+    }
+
+    private long startMetadataDownload(CompatibilityVersion compatVersion) {
+        long downloadId = download(compatVersion.getMetadataUrl());
+        if (downloadId != -1) {
+            mDataStore.setPropertyLong(compatVersion.getMetadataPropertyName(), downloadId);
+            mDataStore.store();
+        }
+        return downloadId;
+    }
+
+    @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(compatVersion.getContentPropertyName(), downloadId);
+            mDataStore.store();
+        }
+        return downloadId;
     }
 
     @Override
@@ -126,94 +152,187 @@
             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 (isMetadataDownloadId(completedId)) {
-            handleMetadataDownloadCompleted(completedId);
+        if (getPublicKeyDownloadId() == completedId) {
+            handlePublicKeyDownloadCompleted(completedId);
             return;
         }
 
-        if (isContentDownloadId(completedId)) {
-            handleContentDownloadCompleted(completedId);
-            return;
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            if (getMetadataDownloadId(compatVersion) == completedId) {
+                handleMetadataDownloadCompleted(compatVersion, completedId);
+                return;
+            }
+
+            if (getContentDownloadId(compatVersion) == completedId) {
+                handleContentDownloadCompleted(compatVersion, completedId);
+                return;
+            }
         }
 
-        Log.e(TAG, "Download id " + completedId + " is neither metadata nor content.");
+        Log.i(TAG, "Download id " + completedId + " is not recognized.");
     }
 
-    private void handleMetadataDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Metadata download failed.");
-            // TODO: re-attempt download
+    private void handlePublicKeyDownloadCompleted(long downloadId) {
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
 
-        startContentDownload(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
+        Uri publicKeyUri = getPublicKeyDownloadUri();
+        if (publicKeyUri == null) {
+            Log.e(TAG, "Invalid public key URI");
+            return;
+        }
+
+        try {
+            mSignatureVerifier.setPublicKeyFrom(publicKeyUri);
+        } catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
+            Log.e(TAG, "Error setting the public Key", e);
+            return;
+        }
+
+        startMetadataDownload();
     }
 
-    private void handleContentDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Content download failed.");
-            // TODO: re-attempt download
+    private void handleMetadataDownloadCompleted(
+            CompatibilityVersion compatVersion, long downloadId) {
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
+            return;
+        }
+        if (startContentDownload(compatVersion) == -1) {
+            Log.e(TAG, "Content download failed for" + compatVersion.getCompatVersion());
+        } else if (Config.DEBUG) {
+            Log.d(TAG, "Content download started for" + compatVersion.getCompatVersion());
+        }
+    }
+
+    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;
+        int failureReason = -1;
+
         try {
-            success = verify(contentUri, metadataUri);
+            success = mSignatureVerifier.verify(contentUri, metadataUri);
+        } catch (MissingPublicKeyException e) {
+            if (updateFailureCount()) {
+                failureReason = CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND;
+            }
+        } catch (InvalidKeyException e) {
+            if (updateFailureCount()) {
+                failureReason = CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
+            }
         } catch (IOException | GeneralSecurityException e) {
             Log.e(TAG, "Could not verify new log list", e);
         }
+
         if (!success) {
             Log.w(TAG, "Log list did not pass verification");
+
+            // Avoid logging failure twice
+            if (failureReason == -1 && updateFailureCount()) {
+                failureReason = CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
+            }
+
+            if (failureReason != -1) {
+                mLogger.logCTLogListUpdateFailedEvent(
+                        failureReason,
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
+            }
             return;
         }
 
-        // TODO: validate file content.
-
-        String version = mDataStore.getProperty(Config.VERSION_PENDING);
-        String contentUrl = mDataStore.getProperty(Config.CONTENT_URL_PENDING);
-        String metadataUrl = mDataStore.getProperty(Config.METADATA_URL_PENDING);
         try (InputStream inputStream = mContext.getContentResolver().openInputStream(contentUri)) {
-            success = mInstaller.install(inputStream, version);
+            success = compatVersion.install(inputStream);
         } 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.setProperty(Config.CONTENT_URL, contentUrl);
-            mDataStore.setProperty(Config.METADATA_URL, metadataUrl);
+            // Reset the number of consecutive log list failure updates back to zero.
+            mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* value= */ 0);
             mDataStore.store();
+        } else {
+            if (updateFailureCount()) {
+                mLogger.logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS,
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0));
+            }
         }
     }
 
-    private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
-        Signature verifier = Signature.getInstance("SHA256withRSA");
-        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
-        verifier.initVerify(keyFactory.generatePublic(new X509EncodedKeySpec(mPublicKey)));
-        ContentResolver contentResolver = mContext.getContentResolver();
+    private void handleDownloadFailed(DownloadStatus status) {
+        Log.e(TAG, "Download failed with " + status);
 
-        try (InputStream fileStream = contentResolver.openInputStream(file);
-                InputStream signatureStream = contentResolver.openInputStream(signature)) {
-            verifier.update(fileStream.readAllBytes());
-            return verifier.verify(signatureStream.readAllBytes());
+        if (updateFailureCount()) {
+            int failureCount =
+                    mDataStore.getPropertyInt(
+                            Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0);
+
+            if (status.isHttpError()) {
+                mLogger.logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR,
+                        failureCount,
+                        status.reason());
+            } else {
+                // TODO(b/384935059): handle blocked domain logging
+                mLogger.logCTLogListUpdateFailedEventWithDownloadStatus(
+                        status.reason(), failureCount);
+            }
         }
     }
 
+    /**
+     * Updates the data store with the current number of consecutive log list update failures.
+     *
+     * @return whether the failure count exceeds the threshold and should be logged.
+     */
+    private boolean 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();
+
+        int threshold = DeviceConfig.getInt(
+                Config.NAMESPACE_NETWORK_SECURITY,
+                Config.FLAG_LOG_FAILURE_THRESHOLD,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+
+        boolean shouldReport = new_failure_count >= threshold;
+        if (shouldReport) {
+            Log.d(TAG, "Log list update failure count exceeds threshold: " + new_failure_count);
+        }
+        return shouldReport;
+    }
+
     private long download(String url) {
         try {
             return mDownloadHelper.startDownload(url);
@@ -224,20 +343,50 @@
     }
 
     @VisibleForTesting
-    boolean isMetadataDownloadId(long downloadId) {
-        return mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1) == downloadId;
+    long getPublicKeyDownloadId() {
+        return mDataStore.getPropertyLong(Config.PUBLIC_KEY_DOWNLOAD_ID, /* defaultValue= */ -1);
     }
 
     @VisibleForTesting
-    boolean isContentDownloadId(long downloadId) {
-        return mDataStore.getPropertyLong(Config.CONTENT_URL_KEY, -1) == downloadId;
+    long getMetadataDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getMetadataPropertyName(), /* defaultValue */ -1);
     }
 
-    private Uri getMetadataDownloadUri() {
-        return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.METADATA_URL_KEY, -1));
+    @VisibleForTesting
+    long getContentDownloadId(CompatibilityVersion compatVersion) {
+        return mDataStore.getPropertyLong(
+                compatVersion.getContentPropertyName(), /* defaultValue= */ -1);
     }
 
-    private Uri getContentDownloadUri() {
-        return mDownloadHelper.getUri(mDataStore.getPropertyLong(Config.CONTENT_URL_KEY, -1));
+    @VisibleForTesting
+    boolean hasPublicKeyDownloadId() {
+        return getPublicKeyDownloadId() != -1;
+    }
+
+    @VisibleForTesting
+    boolean hasMetadataDownloadId() {
+        return mCompatVersions.stream()
+                .map(this::getMetadataDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
+    }
+
+    @VisibleForTesting
+    boolean hasContentDownloadId() {
+        return mCompatVersions.stream()
+                .map(this::getContentDownloadId)
+                .anyMatch(downloadId -> downloadId != -1);
+    }
+
+    private Uri getPublicKeyDownloadUri() {
+        return mDownloadHelper.getUri(getPublicKeyDownloadId());
+    }
+
+    private Uri getMetadataDownloadUri(CompatibilityVersion compatVersion) {
+        return mDownloadHelper.getUri(getMetadataDownloadId(compatVersion));
+    }
+
+    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 f196abb..0000000
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ /dev/null
@@ -1,98 +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 android.provider.DeviceConfig.NAMESPACE_TETHERING;
-
-import android.annotation.RequiresApi;
-import android.content.Context;
-import android.os.Build;
-import android.provider.DeviceConfig;
-import android.provider.DeviceConfig.Properties;
-import android.text.TextUtils;
-import android.util.Log;
-
-import 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 CertificateTransparencyDownloader mCertificateTransparencyDownloader;
-
-    CertificateTransparencyFlagsListener(Context context) {
-        mDataStore = new DataStore(Config.PREFERENCES_FILE);
-        mCertificateTransparencyDownloader =
-                new CertificateTransparencyDownloader(context, mDataStore);
-    }
-
-    void initialize() {
-        mDataStore.load();
-        mCertificateTransparencyDownloader.registerReceiver();
-        DeviceConfig.addOnPropertiesChangedListener(
-                NAMESPACE_TETHERING, 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 (!NAMESPACE_TETHERING.equals(properties.getNamespace())) {
-            return;
-        }
-
-        String newVersion = DeviceConfig.getString(NAMESPACE_TETHERING, Config.VERSION, "");
-        String newContentUrl = DeviceConfig.getString(NAMESPACE_TETHERING, Config.CONTENT_URL, "");
-        String newMetadataUrl =
-                DeviceConfig.getString(NAMESPACE_TETHERING, Config.METADATA_URL, "");
-        if (TextUtils.isEmpty(newVersion)
-                || TextUtils.isEmpty(newContentUrl)
-                || TextUtils.isEmpty(newMetadataUrl)) {
-            return;
-        }
-
-        if (Config.DEBUG) {
-            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;
-        }
-
-        // TODO: handle the case where there is already a pending download.
-
-        mDataStore.setProperty(Config.VERSION_PENDING, newVersion);
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, newContentUrl);
-        mDataStore.setProperty(Config.METADATA_URL_PENDING, newMetadataUrl);
-        mDataStore.store();
-
-        mCertificateTransparencyDownloader.startMetadataDownload(newMetadataUrl);
-    }
-}
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 82dcadf..0000000
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
+++ /dev/null
@@ -1,162 +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.SuppressLint;
-import android.system.ErrnoException;
-import android.system.Os;
-import android.util.Log;
-
-import java.io.File;
-import java.io.IOException;
-import java.io.InputStream;
-import java.nio.file.Files;
-
-/** Installer of CT log lists. */
-public class CertificateTransparencyInstaller {
-
-    private static final String TAG = "CertificateTransparencyInstaller";
-    private static final String CT_DIR_NAME = "/data/misc/keychain/ct/";
-
-    static final String LOGS_DIR_PREFIX = "logs-";
-    static final String LOGS_LIST_FILE_NAME = "log_list.json";
-    static final String CURRENT_DIR_SYMLINK_NAME = "current";
-
-    private final File mCertificateTransparencyDir;
-    private final File mCurrentDirSymlink;
-
-    CertificateTransparencyInstaller(File certificateTransparencyDir) {
-        mCertificateTransparencyDir = certificateTransparencyDir;
-        mCurrentDirSymlink = new File(certificateTransparencyDir, CURRENT_DIR_SYMLINK_NAME);
-    }
-
-    CertificateTransparencyInstaller() {
-        this(new File(CT_DIR_NAME));
-    }
-
-    /**
-     * Install a new log list to use during SCT verification.
-     *
-     * @param newContent an input stream providing the log list
-     * @param version the 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(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 update dir exists and is readable.
-        makeDir(mCertificateTransparencyDir);
-
-        File newLogsDir = new File(mCertificateTransparencyDir, 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 (newLogsDir.getCanonicalPath().equals(mCurrentDirSymlink.getCanonicalPath())) {
-                deleteOldLogDirectories();
-                return false;
-            }
-            // 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.
-            deleteContentsAndDir(newLogsDir);
-        }
-        try {
-            // 3. Create /data/misc/keychain/ct/logs-<new_version>/ .
-            makeDir(newLogsDir);
-
-            // 4. Move the log list json file in logs-<new_version>/ .
-            File logListFile = new File(newLogsDir, LOGS_LIST_FILE_NAME);
-            if (Files.copy(newContent, logListFile.toPath()) == 0) {
-                throw new IOException("The log list appears empty");
-            }
-            setWorldReadable(logListFile);
-
-            // 5. Create temp symlink. We rename this to the target symlink to get an atomic update.
-            File tempSymlink = new File(mCertificateTransparencyDir, "new_symlink");
-            try {
-                Os.symlink(newLogsDir.getCanonicalPath(), tempSymlink.getCanonicalPath());
-            } catch (ErrnoException e) {
-                throw new IOException("Failed to create symlink", e);
-            }
-
-            // 6. Update the symlink target, this is the actual update step.
-            tempSymlink.renameTo(mCurrentDirSymlink.getAbsoluteFile());
-        } catch (IOException | RuntimeException e) {
-            deleteContentsAndDir(newLogsDir);
-            throw e;
-        }
-        Log.i(TAG, "CT log directory updated to " + newLogsDir.getAbsolutePath());
-        // 7. Cleanup
-        deleteOldLogDirectories();
-        return true;
-    }
-
-    private void makeDir(File dir) throws IOException {
-        dir.mkdir();
-        if (!dir.isDirectory()) {
-            throw new IOException("Unable to make directory " + dir.getCanonicalPath());
-        }
-        setWorldReadable(dir);
-    }
-
-    // CT files and directories are readable by all apps.
-    @SuppressLint("SetWorldReadable")
-    private void setWorldReadable(File file) throws IOException {
-        if (!file.setReadable(true, false)) {
-            throw new IOException("Failed to set " + file.getCanonicalPath() + " readable");
-        }
-    }
-
-    private void deleteOldLogDirectories() throws IOException {
-        if (!mCertificateTransparencyDir.exists()) {
-            return;
-        }
-        File currentTarget = mCurrentDirSymlink.getCanonicalFile();
-        for (File file : mCertificateTransparencyDir.listFiles()) {
-            if (!currentTarget.equals(file.getCanonicalFile())
-                    && file.getName().startsWith(LOGS_DIR_PREFIX)) {
-                deleteContentsAndDir(file);
-            }
-        }
-    }
-
-    static boolean deleteContentsAndDir(File dir) {
-        if (deleteContents(dir)) {
-            return dir.delete();
-        } else {
-            return false;
-        }
-    }
-
-    private static boolean deleteContents(File dir) {
-        File[] files = dir.listFiles();
-        boolean success = true;
-        if (files != null) {
-            for (File file : files) {
-                if (file.isDirectory()) {
-                    success &= deleteContents(file);
-                }
-                if (!file.delete()) {
-                    Log.w(TAG, "Failed to delete " + file);
-                    success = false;
-                }
-            }
-        }
-        return success;
-    }
-}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
new file mode 100644
index 0000000..a8acc60
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -0,0 +1,120 @@
+/*
+ * 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.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.ConfigUpdate;
+import android.os.SystemClock;
+import android.util.Log;
+
+/** 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 UPDATE_CONFIG_PERMISSION = "android.permission.UPDATE_CONFIG";
+
+    private final Context mContext;
+    private final CompatibilityVersion mCompatVersion;
+    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
+    private final AlarmManager mAlarmManager;
+    private final PendingIntent mPendingIntent;
+
+    private boolean mDependenciesReady = false;
+
+    /** Creates a new {@link CertificateTransparencyJob} object. */
+    public CertificateTransparencyJob(
+            Context context, CertificateTransparencyDownloader certificateTransparencyDownloader) {
+        mContext = context;
+        mCompatVersion =
+                new CompatibilityVersion(
+                        Config.COMPATIBILITY_VERSION,
+                        Config.URL_SIGNATURE,
+                        Config.URL_LOG_LIST,
+                        Config.CT_ROOT_DIRECTORY_PATH);
+        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
+        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
+        mAlarmManager = context.getSystemService(AlarmManager.class);
+        mPendingIntent =
+                PendingIntent.getBroadcast(
+                        mContext,
+                        /* requestCode= */ 0,
+                        new Intent(ConfigUpdate.ACTION_UPDATE_CT_LOGS),
+                        PendingIntent.FLAG_IMMUTABLE);
+    }
+
+    void schedule() {
+        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);
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob scheduled.");
+        }
+    }
+
+    void cancel() {
+        mContext.unregisterReceiver(this);
+        mAlarmManager.cancel(mPendingIntent);
+        mCertificateTransparencyDownloader.stop();
+        mCompatVersion.delete();
+        mDependenciesReady = false;
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob canceled.");
+        }
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (!ConfigUpdate.ACTION_UPDATE_CT_LOGS.equals(intent.getAction())) {
+            Log.w(TAG, "Received unexpected broadcast with action " + intent);
+            return;
+        }
+        if (context.checkCallingOrSelfPermission(UPDATE_CONFIG_PERMISSION)
+                != PackageManager.PERMISSION_GRANTED) {
+            Log.e(TAG, "Caller does not have UPDATE_CONFIG permission.");
+            return;
+        }
+        if (Config.DEBUG) {
+            Log.d(TAG, "Starting CT daily job.");
+        }
+        if (!mDependenciesReady) {
+            mCertificateTransparencyDownloader.start();
+            mDependenciesReady = true;
+        }
+
+        if (mCertificateTransparencyDownloader.startPublicKeyDownload() == -1) {
+            Log.e(TAG, "Public key download not started.");
+        } else if (Config.DEBUG) {
+            Log.d(TAG, "Public key download started successfully.");
+        }
+    }
+}
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..913c472
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLogger.java
@@ -0,0 +1,48 @@
+/*
+ * 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 CTLogListUpdateFailed event to statsd, when failure is provided by DownloadManager.
+     *
+     * @param downloadStatus DownloadManager failure status why the log list wasn't updated
+     * @param failureCount number of consecutive log list update failures
+     */
+    void logCTLogListUpdateFailedEventWithDownloadStatus(int downloadStatus, int failureCount);
+
+    /**
+     * Logs a CTLogListUpdateFailed event to statsd, when no HTTP error status code is present.
+     *
+     * @param failureReason reason why the log list wasn't updated
+     * @param failureCount number of consecutive log list update failures
+     */
+    void logCTLogListUpdateFailedEvent(int failureReason, int failureCount);
+
+    /**
+     * Logs a CTLogListUpdateFailed event to statsd, when an HTTP error status code is provided.
+     *
+     * @param failureReason reason why the log list wasn't updated (e.g. DownloadManager failures)
+     * @param failureCount number of consecutive log list update failures
+     * @param httpErrorStatusCode if relevant, the HTTP error status code from DownloadManager
+     */
+    void logCTLogListUpdateFailedEvent(
+            int failureReason, int failureCount, int httpErrorStatusCode);
+
+}
\ 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..b97a885
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyLoggerImpl.java
@@ -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.server.net.ct;
+
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_UNKNOWN;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
+
+import android.app.DownloadManager;
+
+/** Implementation for logging to statsd for Certificate Transparency. */
+class CertificateTransparencyLoggerImpl implements CertificateTransparencyLogger {
+
+    @Override
+    public void logCTLogListUpdateFailedEventWithDownloadStatus(
+            int downloadStatus, int failureCount) {
+        logCTLogListUpdateFailedEvent(downloadStatusToFailureReason(downloadStatus), failureCount);
+    }
+
+    @Override
+    public void logCTLogListUpdateFailedEvent(int failureReason, int failureCount) {
+        logCTLogListUpdateFailedEvent(failureReason, failureCount, /* httpErrorStatusCode= */ 0);
+    }
+
+    @Override
+    public void logCTLogListUpdateFailedEvent(
+            int failureReason, int failureCount, int httpErrorStatusCode) {
+        CertificateTransparencyStatsLog.write(
+                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED,
+                failureReason,
+                failureCount,
+                httpErrorStatusCode
+        );
+    }
+
+    /** 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_FAILED__FAILURE_REASON__FAILURE_DEVICE_OFFLINE;
+            case DownloadManager.ERROR_UNHANDLED_HTTP_CODE:
+            case DownloadManager.ERROR_HTTP_DATA_ERROR:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_HTTP_ERROR;
+            case DownloadManager.ERROR_TOO_MANY_REDIRECTS:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_TOO_MANY_REDIRECTS;
+            case DownloadManager.ERROR_CANNOT_RESUME:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_DOWNLOAD_CANNOT_RESUME;
+            case DownloadManager.ERROR_INSUFFICIENT_SPACE:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_NO_DISK_SPACE;
+            case DownloadManager.PAUSED_QUEUED_FOR_WIFI:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__PENDING_WAITING_FOR_WIFI;
+            default:
+                return CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__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 52478c0..ed98056 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -13,39 +13,56 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 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.net.module.util.DeviceConfigUtils;
 import com.android.server.SystemService;
 
+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 static final String CERTIFICATE_TRANSPARENCY_ENABLED =
-            "certificate_transparency_service_enabled";
+    private static final String TAG = "CertificateTransparencyService";
 
-    private final CertificateTransparencyFlagsListener mFlagsListener;
+    private final CertificateTransparencyJob mCertificateTransparencyJob;
+
+    private boolean started = false;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
      */
     public static boolean enabled(Context context) {
-        // TODO: replace isTetheringFeatureEnabled with CT namespace flag.
-        return DeviceConfigUtils.isTetheringFeatureEnabled(
-                        context, CERTIFICATE_TRANSPARENCY_ENABLED)
-                && Flags.certificateTransparencyService();
+        return certificateTransparencyService() && certificateTransparencyConfiguration();
     }
 
     /** Creates a new {@link CertificateTransparencyService} object. */
     public CertificateTransparencyService(Context context) {
-        mFlagsListener = new CertificateTransparencyFlagsListener(context);
+        DataStore dataStore = new DataStore(Config.PREFERENCES_FILE);
+        DownloadHelper downloadHelper = new DownloadHelper(context);
+        SignatureVerifier signatureVerifier = new SignatureVerifier(context);
+        CertificateTransparencyDownloader downloader =
+                new CertificateTransparencyDownloader(
+                        context,
+                        dataStore,
+                        downloadHelper,
+                        signatureVerifier,
+                        new CertificateTransparencyLoggerImpl());
+        mCertificateTransparencyJob = new CertificateTransparencyJob(context, downloader);
     }
 
     /**
@@ -54,12 +71,52 @@
      * @see com.android.server.SystemService#onBootPhase
      */
     public void onBootPhase(int phase) {
-
         switch (phase) {
             case SystemService.PHASE_BOOT_COMPLETED:
-                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");
+        }
+        if (!started) {
+            mCertificateTransparencyJob.schedule();
+            started = true;
+        }
+    }
+
+    private void stopService() {
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyService stop");
+        }
+        if (started) {
+            mCertificateTransparencyJob.cancel();
+            started = false;
+        }
+    }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
new file mode 100644
index 0000000..9d60163
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
@@ -0,0 +1,190 @@
+/*
+ * 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 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 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";
+
+    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 final String mCompatVersion;
+
+    private final String mMetadataUrl;
+    private final String mContentUrl;
+    private final File mRootDirectory;
+    private final File mVersionDirectory;
+    private final File mCurrentLogsDirSymlink;
+
+    CompatibilityVersion(
+            String compatVersion, String metadataUrl, String contentUrl, File rootDirectory) {
+        mCompatVersion = compatVersion;
+        mMetadataUrl = metadataUrl;
+        mContentUrl = contentUrl;
+        mRootDirectory = rootDirectory;
+        mVersionDirectory = new File(rootDirectory, compatVersion);
+        mCurrentLogsDirSymlink = new File(mVersionDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    }
+
+    CompatibilityVersion(
+            String compatVersion, String metadataUrl, String contentUrl, String rootDirectoryPath) {
+        this(compatVersion, metadataUrl, contentUrl, new File(rootDirectoryPath));
+    }
+
+    /**
+     * Installs a log list within this compatibility version directory.
+     *
+     * @param newContent an input stream providing the 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.
+     */
+    boolean install(InputStream newContent) throws IOException {
+        String content = new String(newContent.readAllBytes(), UTF_8);
+        try {
+            return install(
+                    new ByteArrayInputStream(content.getBytes()),
+                    new JSONObject(content).getString("version"));
+        } catch (JSONException e) {
+            Log.e(TAG, "invalid log list format", e);
+            return false;
+        }
+    }
+
+    private 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 the path to the root and version directories exist and are readable.
+        DirectoryUtils.makeDir(mRootDirectory);
+        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 (newLogsDir.getCanonicalPath().equals(mCurrentLogsDirSymlink.getCanonicalPath())) {
+                Log.i(TAG, newLogsDir + " already exists, skipping install.");
+                deleteOldLogDirectories();
+                return false;
+            }
+            // 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 {
+            // 3. Create a new logs-<new_version>/ directory to store the new list.
+            DirectoryUtils.makeDir(newLogsDir);
+
+            // 4. Move the log list json file in logs-<new_version>/ .
+            File logListFile = new File(newLogsDir, LOGS_LIST_FILE_NAME);
+            if (Files.copy(newContent, logListFile.toPath()) == 0) {
+                throw new IOException("The log list appears empty");
+            }
+            DirectoryUtils.setWorldReadable(logListFile);
+
+            // 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) {
+                throw new IOException("Failed to create symlink", e);
+            }
+
+            // 6. Update the symlink target, this is the actual update step.
+            tempSymlink.renameTo(mCurrentLogsDirSymlink.getAbsoluteFile());
+        } catch (IOException | RuntimeException e) {
+            DirectoryUtils.removeDir(newLogsDir);
+            throw e;
+        }
+        // 7. Cleanup
+        Log.i(TAG, "New logs installed at " + newLogsDir);
+        deleteOldLogDirectories();
+        return true;
+    }
+
+    String getCompatVersion() {
+        return mCompatVersion;
+    }
+
+    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() {
+        return mCurrentLogsDirSymlink;
+    }
+
+    File getLogsFile() {
+        return new File(mCurrentLogsDirSymlink, LOGS_LIST_FILE_NAME);
+    }
+
+    void delete() {
+        if (!DirectoryUtils.removeDir(mVersionDirectory)) {
+            Log.w(TAG, "Could not delete compatibility version directory " + mVersionDirectory);
+        }
+    }
+
+    private void deleteOldLogDirectories() throws IOException {
+        if (!mVersionDirectory.exists()) {
+            return;
+        }
+        File currentTarget = mCurrentLogsDirSymlink.getCanonicalFile();
+        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 04b7dac..bc4efab 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,13 +33,33 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
-    // flags and properties names
-    static final String VERSION_PENDING = "version_pending";
+    // CT directory
+    static final String CT_ROOT_DIRECTORY_PATH = "/data/misc/keychain/ct/";
+    static final String COMPATIBILITY_VERSION = "v1";
+
+    // 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";
+    static final String FLAG_LOG_FAILURE_THRESHOLD = FLAGS_PREFIX + "log_list_failure_threshold";
+
+    // properties
     static final String VERSION = "version";
-    static final String CONTENT_URL_PENDING = "content_url_pending";
-    static final String CONTENT_URL = "content_url";
-    static final String CONTENT_URL_KEY = "content_url_key";
-    static final String METADATA_URL_PENDING = "metadata_url_pending";
-    static final String METADATA_URL = "metadata_url";
-    static final String METADATA_URL_KEY = "metadata_url_key";
+    static final String CONTENT_DOWNLOAD_ID = "content_download_id";
+    static final String METADATA_DOWNLOAD_ID = "metadata_download_id";
+    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";
+    static final String URL_PUBLIC_KEY = URL_PREFIX + "log_list.pub";
+
+    // Threshold amounts
+    static final int DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD = 10;
 }
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..8180316 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DataStore.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DataStore.java
@@ -57,6 +57,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 +69,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/DirectoryUtils.java b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
new file mode 100644
index 0000000..ba42a82
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
@@ -0,0 +1,87 @@
+/*
+ * 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.SuppressLint;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Utility class to manipulate CT directories. */
+class DirectoryUtils {
+
+    static void makeDir(File dir) throws IOException {
+        dir.mkdir();
+        if (!dir.isDirectory()) {
+            throw new IOException("Unable to make directory " + dir.getCanonicalPath());
+        }
+        setWorldReadable(dir);
+        // Needed for the log list file to be accessible.
+        setWorldExecutable(dir);
+    }
+
+    // CT files and directories are readable by all apps.
+    @SuppressLint("SetWorldReadable")
+    static void setWorldReadable(File file) throws IOException {
+        if (!file.setReadable(/* readable= */ true, /* ownerOnly= */ false)) {
+            throw new IOException("Failed to set " + file.getCanonicalPath() + " readable");
+        }
+    }
+
+    // CT directories are executable by all apps, to allow access to the log list by anything on the
+    // device.
+    static void setWorldExecutable(File file) throws IOException {
+        if (!file.isDirectory()) {
+            // Only directories need to be marked as executable to allow for access
+            // to the files inside.
+            // See https://www.redhat.com/en/blog/linux-file-permissions-explained for more details.
+            return;
+        }
+
+        if (!file.setExecutable(/* executable= */ true, /* ownerOnly= */ false)) {
+            throw new IOException("Failed to set " + file.getCanonicalPath() + " executable");
+        }
+    }
+
+    static boolean removeDir(File dir) {
+        return deleteContentsAndDir(dir);
+    }
+
+    private static boolean deleteContentsAndDir(File dir) {
+        if (deleteContents(dir)) {
+            return dir.delete();
+        } else {
+            return false;
+        }
+    }
+
+    private static boolean deleteContents(File dir) {
+        File[] files = dir.listFiles();
+        boolean success = true;
+        if (files != null) {
+            for (File file : files) {
+                if (file.isDirectory()) {
+                    success &= deleteContents(file);
+                }
+                if (!file.delete()) {
+                    success = false;
+                }
+            }
+        }
+        return success;
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
index cc8c4c0..5748416 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
@@ -24,6 +24,8 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.google.auto.value.AutoValue;
+
 /** Class to handle downloads for Certificate Transparency. */
 public class DownloadHelper {
 
@@ -53,25 +55,22 @@
     }
 
     /**
-     * Returns true if the specified download completed successfully.
+     * Returns the status of the provided download id.
      *
      * @param downloadId the download.
-     * @return true if the download completed successfully.
+     * @return {@link DownloadStatus} of the download.
      */
-    public boolean isSuccessful(long downloadId) {
+    public DownloadStatus getDownloadStatus(long downloadId) {
+        DownloadStatus.Builder builder = DownloadStatus.builder().setDownloadId(downloadId);
         try (Cursor cursor = mDownloadManager.query(new Query().setFilterById(downloadId))) {
-            if (cursor == null) {
-                return false;
-            }
-            if (cursor.moveToFirst()) {
-                int status =
-                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
-                if (DownloadManager.STATUS_SUCCESSFUL == status) {
-                    return true;
-                }
+            if (cursor != null && cursor.moveToFirst()) {
+                builder.setStatus(
+                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)));
+                builder.setReason(
+                        cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)));
             }
         }
-        return false;
+        return builder.build();
     }
 
     /**
@@ -87,4 +86,58 @@
         }
         return mDownloadManager.getUriForDownloadedFile(downloadId);
     }
+
+    /** A wrapper around the status and reason Ids returned by the {@link DownloadManager}. */
+    @AutoValue
+    public abstract static class DownloadStatus {
+
+        abstract long downloadId();
+
+        abstract int status();
+
+        abstract int reason();
+
+        boolean isSuccessful() {
+            return status() == DownloadManager.STATUS_SUCCESSFUL;
+        }
+
+        boolean isStorageError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_DEVICE_NOT_FOUND
+                            || reason == DownloadManager.ERROR_FILE_ERROR
+                            || reason == DownloadManager.ERROR_FILE_ALREADY_EXISTS
+                            || reason == DownloadManager.ERROR_INSUFFICIENT_SPACE);
+        }
+
+        boolean isHttpError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_HTTP_DATA_ERROR
+                            || reason == DownloadManager.ERROR_TOO_MANY_REDIRECTS
+                            || reason == DownloadManager.ERROR_UNHANDLED_HTTP_CODE
+                            // If an HTTP error occurred, reason will hold the HTTP status code.
+                            || (400 <= reason && reason < 600));
+        }
+
+        @AutoValue.Builder
+        abstract static class Builder {
+            abstract Builder setDownloadId(long downloadId);
+
+            abstract Builder setStatus(int status);
+
+            abstract Builder setReason(int reason);
+
+            abstract DownloadStatus build();
+        }
+
+        static Builder builder() {
+            return new AutoValue_DownloadHelper_DownloadStatus.Builder()
+                    .setDownloadId(-1)
+                    .setStatus(-1)
+                    .setReason(-1);
+        }
+    }
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/MissingPublicKeyException.java b/networksecurity/service/src/com/android/server/net/ct/MissingPublicKeyException.java
new file mode 100644
index 0000000..80607f6
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/MissingPublicKeyException.java
@@ -0,0 +1,26 @@
+/*
+ * 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;
+
+/**
+ * An exception thrown when the public key is missing for CT signature verification.
+ */
+public class MissingPublicKeyException extends Exception {
+
+    public MissingPublicKeyException(String message) {
+        super(message);
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
new file mode 100644
index 0000000..96488fc
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
@@ -0,0 +1,91 @@
+/*
+ * 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.NonNull;
+import android.annotation.RequiresApi;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+
+import androidx.annotation.VisibleForTesting;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+import java.util.Optional;
+
+/** Verifier of the log list signature. */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+public class SignatureVerifier {
+
+    private final Context mContext;
+
+    @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
+
+    public SignatureVerifier(Context context) {
+        mContext = context;
+    }
+
+    @VisibleForTesting
+    Optional<PublicKey> getPublicKey() {
+        return mPublicKey;
+    }
+
+    void resetPublicKey() {
+        mPublicKey = Optional.empty();
+    }
+
+    void setPublicKeyFrom(Uri file) throws GeneralSecurityException, IOException {
+        try (InputStream fileStream = mContext.getContentResolver().openInputStream(file)) {
+            setPublicKey(new String(fileStream.readAllBytes()));
+        }
+    }
+
+    void setPublicKey(String publicKey) throws GeneralSecurityException {
+        setPublicKey(
+                KeyFactory.getInstance("RSA")
+                        .generatePublic(
+                                new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))));
+    }
+
+    @VisibleForTesting
+    void setPublicKey(PublicKey publicKey) {
+        mPublicKey = Optional.of(publicKey);
+    }
+
+    boolean verify(Uri file, Uri signature)
+            throws GeneralSecurityException, IOException, MissingPublicKeyException {
+        if (!mPublicKey.isPresent()) {
+            throw new MissingPublicKeyException("Missing public key for signature verification");
+        }
+        Signature verifier = Signature.getInstance("SHA256withRSA");
+        verifier.initVerify(mPublicKey.get());
+        ContentResolver contentResolver = mContext.getContentResolver();
+
+        try (InputStream fileStream = contentResolver.openInputStream(file);
+                InputStream signatureStream = contentResolver.openInputStream(signature)) {
+            verifier.update(fileStream.readAllBytes());
+            return verifier.verify(signatureStream.readAllBytes());
+        }
+    }
+}
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 a056c35..2f57fc9 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,24 +13,38 @@
  * 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_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION;
+import static com.android.server.net.ct.CertificateTransparencyStatsLog.CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS;
+
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 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;
 import android.content.Context;
 import android.content.Intent;
+import android.database.Cursor;
+import android.database.MatrixCursor;
 import android.net.Uri;
 
 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;
@@ -48,188 +62,727 @@
 import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
+import java.security.PublicKey;
 import java.security.Signature;
+import java.util.Base64;
 
 /** Tests for the {@link CertificateTransparencyDownloader}. */
 @RunWith(JUnit4.class)
 public class CertificateTransparencyDownloaderTest {
 
-    @Mock private DownloadHelper mDownloadHelper;
-    @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
+    @Mock private DownloadManager mDownloadManager;
+    @Mock private CertificateTransparencyLogger mLogger;
 
     private PrivateKey mPrivateKey;
+    private PublicKey mPublicKey;
     private Context mContext;
-    private File mTempFile;
     private DataStore mDataStore;
+    private SignatureVerifier mSignatureVerifier;
+    private CompatibilityVersion mCompatVersion;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
-    @Before
-    public void setUp() throws IOException, NoSuchAlgorithmException {
-        MockitoAnnotations.initMocks(this);
+    private long mNextDownloadId = 666;
 
+    @Before
+    public void setUp() throws IOException, GeneralSecurityException {
+        MockitoAnnotations.initMocks(this);
         KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
         KeyPair keyPair = instance.generateKeyPair();
         mPrivateKey = keyPair.getPrivate();
+        mPublicKey = keyPair.getPublic();
 
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
-        mTempFile = File.createTempFile("datastore-test", ".properties");
-        mDataStore = new DataStore(mTempFile);
-        mDataStore.load();
-
+        mDataStore = new DataStore(File.createTempFile("datastore-test", ".properties"));
+        mSignatureVerifier = new SignatureVerifier(mContext);
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
                         mContext,
                         mDataStore,
-                        mDownloadHelper,
-                        mCertificateTransparencyInstaller,
-                        keyPair.getPublic().getEncoded());
+                        new DownloadHelper(mDownloadManager),
+                        mSignatureVerifier,
+                        mLogger);
+        mCompatVersion =
+                new CompatibilityVersion(
+                        /* compatVersion= */ "v666",
+                        Config.URL_SIGNATURE,
+                        Config.URL_LOG_LIST,
+                        mContext.getFilesDir());
+
+        prepareDownloadManager();
+        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
+        mCertificateTransparencyDownloader.start();
     }
 
     @After
     public void tearDown() {
-        mTempFile.delete();
+        mSignatureVerifier.resetPublicKey();
+        mCertificateTransparencyDownloader.stop();
+        mCompatVersion.delete();
+    }
+
+    @Test
+    public void testDownloader_startPublicKeyDownload() {
+        assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isFalse();
+
+        long downloadId = mCertificateTransparencyDownloader.startPublicKeyDownload();
+
+        assertThat(mCertificateTransparencyDownloader.hasPublicKeyDownloadId()).isTrue();
+        assertThat(mCertificateTransparencyDownloader.getPublicKeyDownloadId())
+                .isEqualTo(downloadId);
     }
 
     @Test
     public void testDownloader_startMetadataDownload() {
-        String metadataUrl = "http://test-metadata.org";
-        long downloadId = 666;
-        when(mDownloadHelper.startDownload(metadataUrl)).thenReturn(downloadId);
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
 
-        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isFalse();
-        mCertificateTransparencyDownloader.startMetadataDownload(metadataUrl);
-        assertThat(mCertificateTransparencyDownloader.isMetadataDownloadId(downloadId)).isTrue();
+        mCertificateTransparencyDownloader.startMetadataDownload();
+
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
     }
 
     @Test
     public void testDownloader_startContentDownload() {
-        String contentUrl = "http://test-content.org";
-        long downloadId = 666;
-        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(downloadId);
+        assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
 
-        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isFalse();
-        mCertificateTransparencyDownloader.startContentDownload(contentUrl);
-        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(downloadId)).isTrue();
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
+
+        assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteSuccessful() {
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(true);
+    public void testDownloader_publicKeyDownloadSuccess_updatePublicKey_startMetadataDownload()
+            throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
 
-        long contentId = 666;
-        String contentUrl = "http://test-content.org";
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
-        when(mDownloadHelper.startDownload(contentUrl)).thenReturn(contentId);
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadCompleteIntent(writePublicKeyToFile(mPublicKey)));
+
+        assertThat(mSignatureVerifier.getPublicKey()).hasValue(mPublicKey);
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isTrue();
+    }
+
+    @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();
+    }
+
+    @Test
+    public void testDownloader_publicKeyDownloadFail_doNotUpdatePublicKey() throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+        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_failureThresholdExceeded_logsFailure()
+            throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext,
+                makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_INSUFFICIENT_SPACE));
 
-        assertThat(mCertificateTransparencyDownloader.isContentDownloadId(contentId)).isTrue();
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteFailed() {
-        long metadataId = 123;
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(false);
-
-        String contentUrl = "http://test-content.org";
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+    public void testDownloader_publicKeyDownloadFail_failureThresholdNotMet_doesNotLog()
+            throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+        // Set the failure count to well below the threshold
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
 
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+                mContext, makePublicKeyDownloadFailedIntent(DownloadManager.ERROR_HTTP_DATA_ERROR));
 
-        verify(mDownloadHelper, never()).startDownload(contentUrl);
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallSuccessful() throws Exception {
-        String version = "666";
-        long contentId = 666;
-        File logListFile = File.createTempFile("log_list", "json");
-        Uri contentUri = Uri.fromFile(logListFile);
-        long metadataId = 123;
+    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
+        mCertificateTransparencyDownloader.startMetadataDownload();
+
+        assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadCompleteIntent(mCompatVersion, new File("log_list.sig")));
+
+        assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isTrue();
+    }
+
+    @Test
+    public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
+        mCertificateTransparencyDownloader.startMetadataDownload();
+
+        assertThat(mCertificateTransparencyDownloader.hasContentDownloadId()).isFalse();
+        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_metadataDownloadFail_failureThresholdExceeded_logsFailure()
+            throws Exception {
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+    }
+
+    @Test
+    public void testDownloader_metadataDownloadFail_failureThresholdNotMet_doesNotLog()
+            throws Exception {
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        // Set the failure count to well below the threshold
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeMetadataDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installSuccess() throws Exception {
+        String newVersion = "456";
+        File logListFile = makeLogListFile(newVersion);
         File metadataFile = sign(logListFile);
-        Uri metadataUri = Uri.fromFile(metadataFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        mCertificateTransparencyDownloader.startMetadataDownload();
 
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
-
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
-
+        assertNoVersionIsInstalled();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, times(1)).install(any(), eq(version));
-        assertThat(mDataStore.getProperty(Config.VERSION)).isEqualTo(version);
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isEqualTo(contentUri.toString());
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isEqualTo(metadataUri.toString());
+        assertInstallSuccessful(newVersion);
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallFails() throws Exception {
-        String version = "666";
-        long contentId = 666;
-        File logListFile = File.createTempFile("log_list", "json");
-        Uri contentUri = Uri.fromFile(logListFile);
-        long metadataId = 123;
+    public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
+
+        assertNoVersionIsInstalled();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadFail_failureThresholdExceeded_logsFailure()
+            throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_INSUFFICIENT_SPACE));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEventWithDownloadStatus(
+                        DownloadManager.ERROR_INSUFFICIENT_SPACE,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+    }
+
+    @Test
+    public void testDownloader_contentDownloadFail_failureThresholdNotMet_doesNotLog()
+            throws Exception {
+        mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
+        // Set the failure count to well below the threshold
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext,
+                makeContentDownloadFailedIntent(
+                        mCompatVersion, DownloadManager.ERROR_HTTP_DATA_ERROR));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
+    }
+
+    @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_noSignatureFound_failureThresholdExceeded_logsSingleFailure()
+                    throws Exception {
+        File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
-        Uri metadataUri = Uri.fromFile(metadataFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
 
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(false);
-
+        // Set the public key to be missing
+        mSignatureVerifier.resetPublicKey();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
+                        anyInt());
     }
 
     @Test
-    public void testDownloader_handleContentCompleteVerificationFails() throws IOException {
-        String version = "666";
-        long contentId = 666;
-        Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
-        long metadataId = 123;
-        Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
+    public void
+            testDownloader_contentDownloadSuccess_wrongSignatureAlgo_failureThresholdExceeded_logsSingleFailure()
+                    throws Exception {
+        // Arrange
+        File logListFile = makeLogListFile("456");
+        File metadataFile = sign(logListFile);
 
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        // Set the key to be deliberately wrong by using diff algorithm
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("EC");
+        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
 
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+
+        // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
         mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(contentId));
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
 
-        verify(mCertificateTransparencyInstaller, never()).install(any(), eq(version));
-        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
-        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
-        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+        // Assert
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
     }
 
-    private Intent makeDownloadCompleteIntent(long downloadId) {
+    @Test
+    public void
+            testDownloader_contentDownloadSuccess_signatureNotVerified_failureThresholdExceeded_logsSingleFailure()
+                    throws Exception {
+        // Arrange
+        File logListFile = makeLogListFile("456");
+        File metadataFile = sign(logListFile);
+
+        // Set the key to be deliberately wrong by using diff key pair
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
+
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+
+        // Act
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        // Assert
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+    }
+
+    @Test
+    public void
+            testDownloader_contentDownloadSuccess_wrongSignature_failureThresholdNotMet_doesNotLog()
+                    throws Exception {
+        File logListFile = makeLogListFile("456");
+        File metadataFile = sign(logListFile);
+        // Set the key to be deliberately wrong by using diff key pair
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
+        // Set the failure count to well below the threshold
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_NOT_FOUND),
+                        anyInt());
+        verify(mLogger, never())
+                .logCTLogListUpdateFailedEvent(
+                        eq(
+                                CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_SIGNATURE_VERIFICATION),
+                        anyInt());
+    }
+
+    @Test
+    public void
+            testDownloader_contentDownloadSuccess_installFail_failureThresholdExceeded_logsFailure()
+                    throws Exception {
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        // Set the failure count to just below the threshold
+        mDataStore.setPropertyInt(
+                Config.LOG_LIST_UPDATE_FAILURE_COUNT,
+                Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD - 1);
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+        verify(mLogger, times(1))
+                .logCTLogListUpdateFailedEvent(
+                        CERTIFICATE_TRANSPARENCY_LOG_LIST_UPDATE_FAILED__FAILURE_REASON__FAILURE_VERSION_ALREADY_EXISTS,
+                        Config.DEFAULT_LOG_LIST_UPDATE_FAILURE_THRESHOLD);
+    }
+
+    @Test
+    public void
+            testDownloader_contentDownloadSuccess_installFail_failureThresholdNotMet_doesNotLog()
+                    throws Exception {
+        File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
+        File metadataFile = sign(invalidLogListFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
+        // Set the failure count to well below the threshold
+        mDataStore.setPropertyInt(Config.LOG_LIST_UPDATE_FAILURE_COUNT, 0);
+
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, invalidLogListFile));
+
+        assertThat(
+                        mDataStore.getPropertyInt(
+                                Config.LOG_LIST_UPDATE_FAILURE_COUNT, /* defaultValue= */ 0))
+                .isEqualTo(1);
+        verify(mLogger, never()).logCTLogListUpdateFailedEvent(anyInt(), anyInt());
+        verify(mLogger, never()).logCTLogListUpdateFailedEventWithDownloadStatus(
+                anyInt(), anyInt());
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_verificationFail_doNotInstall()
+            throws Exception {
+        File logListFile = makeLogListFile("456");
+        File metadataFile = File.createTempFile("log_list-wrong_metadata", "sig");
+        mSignatureVerifier.setPublicKey(mPublicKey);
+
+        assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        assertNoVersionIsInstalled();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_missingVerificationPublicKey_doNotInstall()
+            throws Exception {
+        File logListFile = makeLogListFile("456");
+        File metadataFile = sign(logListFile);
+        mSignatureVerifier.resetPublicKey();
+
+        assertNoVersionIsInstalled();
+        mCertificateTransparencyDownloader.startMetadataDownload();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        assertNoVersionIsInstalled();
+    }
+
+    @Test
+    public void testDownloader_endToEndSuccess_installNewVersion() throws Exception {
+        String newVersion = "456";
+        File logListFile = makeLogListFile(newVersion);
+        File metadataFile = sign(logListFile);
+        File publicKeyFile = writePublicKeyToFile(mPublicKey);
+
+        assertNoVersionIsInstalled();
+
+        // 1. Start download of public key.
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+
+        // 2. On successful public key download, set the key and start the metatadata
+        // download.
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadCompleteIntent(publicKeyFile));
+
+        // 3. On successful metadata download, start the content download.
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeMetadataDownloadCompleteIntent(mCompatVersion, metadataFile));
+
+        // 4. On successful content download, verify the signature and install the new
+        // version.
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeContentDownloadCompleteIntent(mCompatVersion, logListFile));
+
+        assertInstallSuccessful(newVersion);
+    }
+
+    private void assertNoVersionIsInstalled() {
+        assertThat(mCompatVersion.getVersionDir().exists()).isFalse();
+    }
+
+    private void assertInstallSuccessful(String version) {
+        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() {
+        when(mDownloadManager.enqueue(any(Request.class)))
+                .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 void setUpDownloadComplete(
-            String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
-            throws IOException {
-        mDataStore.setProperty(Config.VERSION_PENDING, version);
+    private Cursor makeSuccessfulDownloadCursor() {
+        MatrixCursor cursor =
+                new MatrixCursor(
+                        new String[] {
+                            DownloadManager.COLUMN_STATUS, DownloadManager.COLUMN_REASON
+                        });
+        cursor.addRow(new Object[] {DownloadManager.STATUS_SUCCESSFUL, -1});
+        return cursor;
+    }
 
-        mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        mDataStore.setProperty(Config.METADATA_URL_PENDING, metadataUri.toString());
-        when(mDownloadHelper.getUri(metadataId)).thenReturn(metadataUri);
+    private Intent makePublicKeyDownloadFailedIntent(int error) {
+        return makeDownloadFailedIntent(
+                mCertificateTransparencyDownloader.getPublicKeyDownloadId(), error);
+    }
 
-        mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
-        mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
-        when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
+    private 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) {
+        MatrixCursor cursor =
+                new MatrixCursor(
+                        new String[] {
+                            DownloadManager.COLUMN_STATUS, DownloadManager.COLUMN_REASON
+                        });
+        cursor.addRow(new Object[] {DownloadManager.STATUS_FAILED, error});
+        return cursor;
+    }
+
+    private File writePublicKeyToFile(PublicKey publicKey)
+            throws IOException, GeneralSecurityException {
+        return writeToFile(Base64.getEncoder().encode(publicKey.getEncoded()));
+    }
+
+    private File writeToFile(byte[] bytes) throws IOException, GeneralSecurityException {
+        File file = File.createTempFile("temp_file", "tmp");
+
+        try (OutputStream outputStream = new FileOutputStream(file)) {
+            outputStream.write(bytes);
+        }
+
+        return file;
+    }
+
+    private File makeLogListFile(String version) throws IOException, JSONException {
+        File logListFile = File.createTempFile("log_list", "json");
+
+        try (OutputStream outputStream = new FileOutputStream(logListFile)) {
+            outputStream.write(new JSONObject().put("version", version).toString().getBytes(UTF_8));
+        }
+
+        return logListFile;
     }
 
     private File sign(File file) throws IOException, GeneralSecurityException {
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 bfb8bdf..0000000
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
+++ /dev/null
@@ -1,149 +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 android.system.ErrnoException;
-import android.system.Os;
-
-import androidx.test.platform.app.InstrumentationRegistry;
-
-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 File mTestDir =
-            new File(
-                    InstrumentationRegistry.getInstrumentation().getContext().getFilesDir(),
-                    "test-dir");
-    private File mTestSymlink =
-            new File(mTestDir, CertificateTransparencyInstaller.CURRENT_DIR_SYMLINK_NAME);
-    private CertificateTransparencyInstaller mCertificateTransparencyInstaller =
-            new CertificateTransparencyInstaller(mTestDir);
-
-    @Before
-    public void setUp() {
-        CertificateTransparencyInstaller.deleteContentsAndDir(mTestDir);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_installSuccessfully() throws IOException {
-        String content = "i_am_a_certificate_and_i_am_transparent";
-        String version = "666";
-        boolean success = false;
-
-        try (InputStream inputStream = asStream(content)) {
-            success = mCertificateTransparencyInstaller.install(inputStream, version);
-        }
-
-        assertThat(success).isTrue();
-        assertThat(mTestDir.exists()).isTrue();
-        assertThat(mTestDir.isDirectory()).isTrue();
-        assertThat(mTestSymlink.exists()).isTrue();
-        assertThat(mTestSymlink.isDirectory()).isTrue();
-
-        File logsDir =
-                new File(mTestDir, CertificateTransparencyInstaller.LOGS_DIR_PREFIX + version);
-        assertThat(logsDir.exists()).isTrue();
-        assertThat(logsDir.isDirectory()).isTrue();
-        assertThat(mTestSymlink.getCanonicalPath()).isEqualTo(logsDir.getCanonicalPath());
-
-        File logsListFile = new File(logsDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
-        assertThat(logsListFile.exists()).isTrue();
-        assertThat(readAsString(logsListFile)).isEqualTo(content);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_versionIsAlreadyInstalled()
-            throws IOException, ErrnoException {
-        String existingVersion = "666";
-        String existingContent = "i_was_already_installed_successfully";
-        File existingLogDir =
-                new File(
-                        mTestDir,
-                        CertificateTransparencyInstaller.LOGS_DIR_PREFIX + existingVersion);
-        assertThat(mTestDir.mkdir()).isTrue();
-        assertThat(existingLogDir.mkdir()).isTrue();
-        Os.symlink(existingLogDir.getCanonicalPath(), mTestSymlink.getCanonicalPath());
-        File logsListFile =
-                new File(existingLogDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
-        logsListFile.createNewFile();
-        writeToFile(logsListFile, existingContent);
-        boolean success = false;
-
-        try (InputStream inputStream = asStream("i_will_be_ignored")) {
-            success = mCertificateTransparencyInstaller.install(inputStream, existingVersion);
-        }
-
-        assertThat(success).isFalse();
-        assertThat(readAsString(logsListFile)).isEqualTo(existingContent);
-    }
-
-    @Test
-    public void testCertificateTransparencyInstaller_versionInstalledFailed()
-            throws IOException, ErrnoException {
-        String existingVersion = "666";
-        String existingContent = "somebody_tried_to_install_me_but_failed_halfway_through";
-        String newContent = "i_am_the_real_certificate";
-        File existingLogDir =
-                new File(
-                        mTestDir,
-                        CertificateTransparencyInstaller.LOGS_DIR_PREFIX + existingVersion);
-        assertThat(mTestDir.mkdir()).isTrue();
-        assertThat(existingLogDir.mkdir()).isTrue();
-        File logsListFile =
-                new File(existingLogDir, CertificateTransparencyInstaller.LOGS_LIST_FILE_NAME);
-        logsListFile.createNewFile();
-        writeToFile(logsListFile, existingContent);
-        boolean success = false;
-
-        try (InputStream inputStream = asStream(newContent)) {
-            success = mCertificateTransparencyInstaller.install(inputStream, existingVersion);
-        }
-
-        assertThat(success).isTrue();
-        assertThat(mTestSymlink.getCanonicalPath()).isEqualTo(existingLogDir.getCanonicalPath());
-        assertThat(readAsString(logsListFile)).isEqualTo(newContent);
-    }
-
-    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..38fff48
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
@@ -0,0 +1,180 @@
+/*
+ * 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.json.JSONException;
+import org.json.JSONObject;
+import org.junit.After;
+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 final File mTestDir =
+            InstrumentationRegistry.getInstrumentation().getContext().getFilesDir();
+    private final CompatibilityVersion mCompatVersion =
+            new CompatibilityVersion(
+                    TEST_VERSION, Config.URL_SIGNATURE, Config.URL_LOG_LIST, mTestDir);
+
+    @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() throws Exception {
+        String version = "i_am_version";
+        JSONObject logList = makeLogList(version, "i_am_content");
+
+        try (InputStream inputStream = asStream(logList)) {
+            assertThat(mCompatVersion.install(inputStream)).isTrue();
+        }
+
+        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)).isTrue();
+        }
+
+        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)).isFalse();
+        }
+
+        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)).isTrue();
+        }
+
+        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)).isTrue();
+        }
+
+        try (InputStream inputStream = asStream(makeLogList(existingVersion, "i_am_ignored"))) {
+            assertThat(mCompatVersion.install(inputStream)).isFalse();
+        }
+
+        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);
+    }
+
+    private static JSONObject makeLogList(String version, String content) throws JSONException {
+        return makeLogList(version).put("content", content);
+    }
+
+    private static String readAsString(File file) throws IOException {
+        try (InputStream in = new FileInputStream(file)) {
+            return new String(in.readAllBytes());
+        }
+    }
+}
diff --git a/remoteauth/service/jni/Android.bp b/remoteauth/service/jni/Android.bp
index fc91e0c..57e3ec9 100644
--- a/remoteauth/service/jni/Android.bp
+++ b/remoteauth/service/jni/Android.bp
@@ -13,7 +13,6 @@
     rustlibs: [
         "libbinder_rs",
         "libjni_legacy",
-        "liblazy_static",
         "liblog_rust",
         "liblogger",
         "libnum_traits",
diff --git a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
index 421fe7e..1d43d38 100644
--- a/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
+++ b/remoteauth/service/jni/src/remoteauth_jni_android_platform.rs
@@ -21,12 +21,11 @@
 use jni::signature::TypeSignature;
 use jni::sys::{jbyteArray, jint, jlong, jvalue};
 use jni::{JNIEnv, JavaVM};
-use lazy_static::lazy_static;
 use log::{debug, error, info};
 use std::collections::HashMap;
 use std::sync::{
     atomic::{AtomicI64, Ordering},
-    Arc, Mutex,
+    Arc, LazyLock, Mutex,
 };
 
 /// Macro capturing the name of the function calling this macro.
@@ -51,11 +50,9 @@
     }};
 }
 
-lazy_static! {
-    static ref HANDLE_MAPPING: Mutex<HashMap<i64, Arc<Mutex<JavaPlatform>>>> =
-        Mutex::new(HashMap::new());
-    static ref HANDLE_RN: AtomicI64 = AtomicI64::new(0);
-}
+static HANDLE_MAPPING: LazyLock<Mutex<HashMap<i64, Arc<Mutex<JavaPlatform>>>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
+static HANDLE_RN: AtomicI64 = AtomicI64::new(0);
 
 fn generate_platform_handle() -> i64 {
     HANDLE_RN.fetch_add(1, Ordering::SeqCst)
@@ -143,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/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 787e94e..d2e2a80 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -101,7 +101,7 @@
     min_sdk_version: "21",
     lint: {
         error_checks: ["NewApi"],
-
+        baseline_filename: "lint-baseline-service-connectivity-mdns-standalone-build-test.xml",
     },
     srcs: [
         "src/com/android/server/connectivity/mdns/**/*.java",
diff --git a/service-t/lint-baseline-service-connectivity-mdns-standalone-build-test.xml b/service-t/lint-baseline-service-connectivity-mdns-standalone-build-test.xml
new file mode 100644
index 0000000..232d31c
--- /dev/null
+++ b/service-t/lint-baseline-service-connectivity-mdns-standalone-build-test.xml
@@ -0,0 +1,972 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="6" by="lint 8.4.0-alpha08" type="baseline" client="" dependencies="true" name="" variant="all" version="8.4.0-alpha08">
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadEngine.java"
+            line="37"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadEngine.java"
+            line="37"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadEngine.java"
+            line="37"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadEngine.java"
+            line="37"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadEngine.java"
+            line="37"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+    <issue
+        id="FlaggedApi"
+        message="@FlaggedApi should specify an actual flag constant; raw strings are discouraged (and more importantly, **not enforced**)"
+        errorLine1="@FlaggedApi(&quot;com.android.net.flags.register_nsd_offload_engine_api&quot;)"
+        errorLine2="            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+        <location
+            file="packages/modules/Connectivity/framework-t/src/android/net/nsd/OffloadServiceInfo.java"
+            line="43"
+            column="13"/>
+    </issue>
+
+</issues>
diff --git a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
index 241d5fa..9cca078 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
@@ -41,10 +41,7 @@
   // The task runner is sequential so these can't run on top of each other.
   runner->PostDelayedTask([=, this]() { PollAndSchedule(runner, poll_ms); }, poll_ms);
 
-  if (mMutex.try_lock()) {
-    ConsumeAllLocked();
-    mMutex.unlock();
-  }
+  ConsumeAll();
 }
 
 bool NetworkTracePoller::Start(uint32_t pollMs) {
@@ -76,7 +73,10 @@
     return false;
   }
 
-  mRingBuffer = std::move(*rb);
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer = std::move(*rb);
+  }
 
   auto res = mConfigurationMap.writeValue(0, true, BPF_ANY);
   if (!res.ok()) {
@@ -114,10 +114,14 @@
   // Drain remaining events from the ring buffer now that tracing is disabled.
   // This prevents the next trace from seeing stale events and allows writing
   // the last batch of events to Perfetto.
-  ConsumeAllLocked();
+  ConsumeAll();
 
   mTaskRunner.reset();
-  mRingBuffer.reset();
+
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer.reset();
+  }
 
   return res.ok();
 }
@@ -145,22 +149,20 @@
 }
 
 bool NetworkTracePoller::ConsumeAll() {
-  std::scoped_lock<std::mutex> lock(mMutex);
-  return ConsumeAllLocked();
-}
-
-bool NetworkTracePoller::ConsumeAllLocked() {
-  if (mRingBuffer == nullptr) {
-    ALOGW("Tracing is not active");
-    return false;
-  }
-
   std::vector<PacketTrace> packets;
-  base::Result<int> ret = mRingBuffer->ConsumeAll(
-      [&](const PacketTrace& pkt) { packets.push_back(pkt); });
-  if (!ret.ok()) {
-    ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
-    return false;
+  {
+    std::scoped_lock<std::mutex> lock(mBufferMutex);
+    if (mRingBuffer == nullptr) {
+      ALOGW("Tracing is not active");
+      return false;
+    }
+
+    base::Result<int> ret = mRingBuffer->ConsumeAll(
+        [&](const PacketTrace& pkt) { packets.push_back(pkt); });
+    if (!ret.ok()) {
+      ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
+      return false;
+    }
   }
 
   ATRACE_INT("NetworkTracePackets", packets.size());
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
index 6bf186a..dd6ed2e 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
@@ -88,6 +88,13 @@
   // Connects to the system Perfetto daemon and registers the trace handler.
   static void InitPerfettoTracing();
 
+  // This prevents Perfetto from holding the data source lock when calling
+  // OnSetup, OnStart, or OnStop. The lock is still held by the LockedHandle
+  // returned by GetDataSourceLocked. Disabling this lock prevents a deadlock
+  // where OnStop holds this lock waiting for the poller to stop, but the poller
+  // is running the callback that is trying to acquire the lock.
+  static constexpr bool kRequiresCallbacksUnderLock = false;
+
   // When isTest is true, skip non-hermetic code.
   NetworkTraceHandler(bool isTest = false) : mIsTest(isTest) {}
 
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
index 092ab64..72fa66e 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
@@ -50,7 +50,7 @@
   bool Stop() EXCLUDES(mMutex);
 
   // Consumes all available events from the ringbuffer.
-  bool ConsumeAll() EXCLUDES(mMutex);
+  bool ConsumeAll() EXCLUDES(mBufferMutex);
 
  private:
   // Poll the ring buffer for new data and schedule another run of ourselves
@@ -59,15 +59,19 @@
   // and thus a deadlock while resetting the TaskRunner. The runner pointer is
   // always valid within tasks run by that runner.
   void PollAndSchedule(perfetto::base::TaskRunner* runner, uint32_t poll_ms);
-  bool ConsumeAllLocked() REQUIRES(mMutex);
 
   // Record sparse iface stats via atrace. This queries the per-iface stats maps
   // for any iface present in the vector of packets. This is inexact, but should
   // have sufficient coverage given these are cumulative counters.
-  void TraceIfaces(const std::vector<PacketTrace>& packets) REQUIRES(mMutex);
+  static void TraceIfaces(const std::vector<PacketTrace>& packets);
 
   std::mutex mMutex;
 
+  // The mBufferMutex protects the ring buffer. This allows separate protected
+  // access of mTaskRunner in Stop (to terminate) and mRingBuffer in ConsumeAll.
+  // Without this separation, Stop() can deadlock.
+  std::mutex mBufferMutex;
+
   // Records the number of successfully started active sessions so that only the
   // first active session attempts setup and only the last cleans up. Note that
   // the session count will remain zero if Start fails. It is expected that Stop
@@ -78,10 +82,10 @@
   uint32_t mPollMs GUARDED_BY(mMutex);
 
   // The function to process PacketTrace, typically a Perfetto sink.
-  EventSink mCallback GUARDED_BY(mMutex);
+  const EventSink mCallback;
 
   // The BPF ring buffer handle.
-  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mMutex);
+  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mBufferMutex);
 
   // The packet tracing config map (really a 1-element array).
   BpfMap<uint32_t, bool> mConfigurationMap GUARDED_BY(mMutex);
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 8e4ec2f..555549c 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,6 +1942,13 @@
                         mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setAvoidAdvertisingEmptyTxtRecords(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS))
+                .setIsCachedServicesRemovalEnabled(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.NSD_CACHED_SERVICES_REMOVAL))
+                .setCachedServicesRetentionTime(mDeps.getDeviceConfigPropertyInt(
+                        MdnsFeatureFlags.NSD_CACHED_SERVICES_RETENTION_TIME,
+                        MdnsFeatureFlags.DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS))
+                .setIsShortHostnamesEnabled(mDeps.isTetheringFeatureNotChickenedOut(
+                        mContext, MdnsFeatureFlags.NSD_USE_SHORT_HOSTNAMES))
                 .setOverrideProvider(new MdnsFeatureFlags.FlagOverrideProvider() {
                     @Override
                     public boolean isForceEnabledForTest(@NonNull String flag) {
@@ -1947,10 +1958,9 @@
                     }
 
                     @Override
-                    public int getIntValueForTest(@NonNull String flag) {
+                    public int getIntValueForTest(@NonNull String flag, int defaultValue) {
                         return mDeps.getDeviceConfigPropertyInt(
-                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag,
-                                -1 /* defaultValue */);
+                                FORCE_ENABLE_FLAG_FOR_TEST_PREFIX + flag, defaultValue);
                     }
                 })
                 .build();
diff --git a/service-t/src/com/android/server/connectivity/mdns/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..54f7ca3 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -46,6 +46,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;
@@ -117,7 +118,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 +128,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};
+            }
         }
     }
 
@@ -825,7 +840,7 @@
         mCb = cb;
         mSocketProvider = socketProvider;
         mDeps = deps;
-        mDeviceHostName = deps.generateHostname();
+        mDeviceHostName = deps.generateHostname(mDnsFeatureFlags.isShortHostnamesEnabled());
         mSharedLog = sharedLog;
         mMdnsFeatureFlags = mDnsFeatureFlags;
         final ConnectivityResources res = new ConnectivityResources(context);
@@ -943,7 +958,7 @@
         mRegistrations.remove(id);
         // Regenerates host name when registrations removed.
         if (mRegistrations.size() == 0) {
-            mDeviceHostName = mDeps.generateHostname();
+            mDeviceHostName = mDeps.generateHostname(mMdnsFeatureFlags.isShortHostnamesEnabled());
         }
     }
 
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 a74bdf7..c833422 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -33,8 +33,8 @@
 
 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 com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -210,9 +210,22 @@
 
         void ensureRunningOnHandlerThread() {
             synchronized (pendingTasks) {
-                MdnsUtils.ensureRunningOnHandlerThread(handler);
+                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);
+        }
     }
 
     /**
@@ -301,6 +314,17 @@
                         serviceTypeClient.notifySocketDestroyed();
                         executorProvider.shutdownExecutorService(serviceTypeClient.getExecutor());
                         perSocketServiceTypeClients.remove(serviceTypeClient);
+                        // The cached services may not be reliable after the socket is disconnected,
+                        // the service type client won't receive any updates for them. Therefore,
+                        // remove these cached services after exceeding the retention time
+                        // (currently 10s) if no service type client requires them.
+                        if (mdnsFeatureFlags.isCachedServicesRemovalEnabled()) {
+                            final MdnsServiceCache.CacheKey cacheKey =
+                                    serviceTypeClient.getCacheKey();
+                            discoveryExecutor.executeDelayed(
+                                    () -> handleRemoveCachedServices(cacheKey),
+                                    mdnsFeatureFlags.getCachedServicesRetentionTime());
+                        }
                     }
                 });
     }
@@ -337,6 +361,42 @@
                 // of the service type clients.
                 executorProvider.shutdownExecutorService(serviceTypeClient.getExecutor());
                 perSocketServiceTypeClients.remove(serviceTypeClient);
+                // The cached services may not be reliable after the socket is disconnected, the
+                // service type client won't receive any updates for them. Therefore, remove these
+                // cached services after exceeding the retention time (currently 10s) if no service
+                // type client requires them.
+                // Note: This removal is only called if the requested socket is still active for
+                // other requests. If the requested socket is no longer needed after the listener
+                // is unregistered, SocketCreationCallback#onSocketDestroyed callback will remove
+                // both the service type client and cached services there.
+                //
+                // List some multiple listener cases for the cached service removal flow.
+                //
+                // Case 1 - Same service type, different network requests
+                //  - Register Listener A (service type X, requesting all networks: Y and Z)
+                //  - Create service type clients X-Y and X-Z
+                //  - Register Listener B (service type X, requesting network Y)
+                //  - Reuse service type client X-Y
+                //  - Unregister Listener A
+                //  - Socket destroyed on network Z; remove the X-Z client. Unregister the listener
+                //    from the X-Y client and keep it, as it's still being used by Listener B.
+                //  - Remove cached services associated with the X-Z client after 10 seconds.
+                //
+                // Case 2 - Different service types, same network request
+                //  - Register Listener A (service type X, requesting network Y)
+                //  - Create service type client X-Y
+                //  - Register Listener B (service type Z, requesting network Y)
+                //  - Create service type client Z-Y
+                //  - Unregister Listener A
+                //  - No socket is destroyed because network Y is still being used by Listener B.
+                //  - Unregister the listener from the X-Y client, then remove it.
+                //  - Remove cached services associated with the X-Y client after 10 seconds.
+                if (mdnsFeatureFlags.isCachedServicesRemovalEnabled()) {
+                    final MdnsServiceCache.CacheKey cacheKey = serviceTypeClient.getCacheKey();
+                    discoveryExecutor.executeDelayed(
+                            () -> handleRemoveCachedServices(cacheKey),
+                            mdnsFeatureFlags.getCachedServicesRetentionTime());
+                }
             }
         }
         if (perSocketServiceTypeClients.isEmpty()) {
@@ -381,6 +441,26 @@
         }
     }
 
+    private void handleRemoveCachedServices(@NonNull MdnsServiceCache.CacheKey cacheKey) {
+        // Check if there is an active service type client that requires the cached services. If so,
+        // do not remove associated services from cache.
+        for (MdnsServiceTypeClient client : getMdnsServiceTypeClient(cacheKey.mSocketKey)) {
+            if (client.getCacheKey().equals(cacheKey)) {
+                // Found a client that has same CacheKey.
+                return;
+            }
+        }
+        sharedLog.log("Remove cached services for " + cacheKey);
+        // No client has same CacheKey. Remove associated services.
+        getServiceCache().removeServices(cacheKey);
+    }
+
+    @VisibleForTesting
+    @NonNull
+    MdnsServiceCache getServiceCache() {
+        return serviceCache;
+    }
+
     @VisibleForTesting
     MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
             @NonNull SocketKey socketKey) {
@@ -402,7 +482,7 @@
      * Dump DiscoveryManager state.
      */
     public void dump(PrintWriter pw) {
-        discoveryExecutor.checkAndRunOnHandlerThread(() -> {
+        discoveryExecutor.runWithScissorsForDumpIfReady(() -> {
             pw.println("Clients:");
             // Dump ServiceTypeClients
             for (MdnsServiceTypeClient serviceTypeClient
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index b2be6ce..2f3bdc5 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -73,6 +73,28 @@
     public static final String NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS =
             "nsd_avoid_advertising_empty_txt_records";
 
+    /**
+     * A feature flag to control whether the cached services removal should be enabled.
+     * The removal will be triggered if the retention time has elapsed after all listeners have been
+     * unregistered from the service type client or the interface has been destroyed.
+     */
+    public static final String NSD_CACHED_SERVICES_REMOVAL = "nsd_cached_services_removal";
+
+    /**
+     * A feature flag to control 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.
+     */
+    public static final String NSD_CACHED_SERVICES_RETENTION_TIME =
+            "nsd_cached_services_retention_time";
+    public static final int DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS = 10000;
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
@@ -100,6 +122,15 @@
     // Flag for avoiding advertising empty TXT records
     public final boolean mAvoidAdvertisingEmptyTxtRecords;
 
+    // Flag for cached services removal
+    public final boolean mIsCachedServicesRemovalEnabled;
+
+    // Retention Time for cached services
+    public final long mCachedServicesRetentionTime;
+
+    // Flag to use shorter (16 characters + .local) hostnames
+    public final boolean mIsShortHostnamesEnabled;
+
     @Nullable
     private final FlagOverrideProvider mOverrideProvider;
 
@@ -116,7 +147,7 @@
         /**
          * Get the int value of the flag for testing purposes.
          */
-        int getIntValueForTest(@NonNull String flag);
+        int getIntValueForTest(@NonNull String flag, int defaultValue);
     }
 
     /**
@@ -129,13 +160,14 @@
     /**
      * Get the int value of the flag for testing purposes.
      *
-     * @return the test int value, or -1 if it is unset or the OverrideProvider doesn't exist.
+     * @return the test int value, or given default value if it is unset or the OverrideProvider
+     * doesn't exist.
      */
-    private int getIntValueForTest(@NonNull String flag) {
+    private int getIntValueForTest(@NonNull String flag, int defaultValue) {
         if (mOverrideProvider == null) {
-            return -1;
+            return defaultValue;
         }
-        return mOverrideProvider.getIntValueForTest(flag);
+        return mOverrideProvider.getIntValueForTest(flag, defaultValue);
     }
 
     /**
@@ -178,6 +210,27 @@
     }
 
     /**
+     * Indicates whether {@link #NSD_CACHED_SERVICES_REMOVAL} is enabled, including for testing.
+     */
+    public boolean isCachedServicesRemovalEnabled() {
+        return mIsCachedServicesRemovalEnabled
+                || isForceEnabledForTest(NSD_CACHED_SERVICES_REMOVAL);
+    }
+
+    /**
+     * Get the value which is set to {@link #NSD_CACHED_SERVICES_RETENTION_TIME}, including for
+     * testing.
+     */
+    public long getCachedServicesRetentionTime() {
+        return getIntValueForTest(
+                NSD_CACHED_SERVICES_RETENTION_TIME, (int) mCachedServicesRetentionTime);
+    }
+
+    public boolean isShortHostnamesEnabled() {
+        return mIsShortHostnamesEnabled || isForceEnabledForTest(NSD_USE_SHORT_HOSTNAMES);
+    }
+
+    /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
     public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
@@ -189,6 +242,9 @@
             boolean isAggressiveQueryModeEnabled,
             boolean isQueryWithKnownAnswerEnabled,
             boolean avoidAdvertisingEmptyTxtRecords,
+            boolean isCachedServicesRemovalEnabled,
+            long cachedServicesRetentionTime,
+            boolean isShortHostnamesEnabled,
             @Nullable FlagOverrideProvider overrideProvider) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
         mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
@@ -199,6 +255,9 @@
         mIsAggressiveQueryModeEnabled = isAggressiveQueryModeEnabled;
         mIsQueryWithKnownAnswerEnabled = isQueryWithKnownAnswerEnabled;
         mAvoidAdvertisingEmptyTxtRecords = avoidAdvertisingEmptyTxtRecords;
+        mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
+        mCachedServicesRetentionTime = cachedServicesRetentionTime;
+        mIsShortHostnamesEnabled = isShortHostnamesEnabled;
         mOverrideProvider = overrideProvider;
     }
 
@@ -220,6 +279,9 @@
         private boolean mIsAggressiveQueryModeEnabled;
         private boolean mIsQueryWithKnownAnswerEnabled;
         private boolean mAvoidAdvertisingEmptyTxtRecords;
+        private boolean mIsCachedServicesRemovalEnabled;
+        private long mCachedServicesRetentionTime;
+        private boolean mIsShortHostnamesEnabled;
         private FlagOverrideProvider mOverrideProvider;
 
         /**
@@ -235,6 +297,9 @@
             mIsAggressiveQueryModeEnabled = false;
             mIsQueryWithKnownAnswerEnabled = false;
             mAvoidAdvertisingEmptyTxtRecords = true; // Default enabled.
+            mIsCachedServicesRemovalEnabled = false;
+            mCachedServicesRetentionTime = DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS;
+            mIsShortHostnamesEnabled = true; // Default enabled.
             mOverrideProvider = null;
         }
 
@@ -341,6 +406,36 @@
         }
 
         /**
+         * Set whether the cached services removal is enabled.
+         *
+         * @see #NSD_CACHED_SERVICES_REMOVAL
+         */
+        public Builder setIsCachedServicesRemovalEnabled(boolean isCachedServicesRemovalEnabled) {
+            mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
+            return this;
+        }
+
+        /**
+         * Set cached services retention time.
+         *
+         * @see #NSD_CACHED_SERVICES_RETENTION_TIME
+         */
+        public Builder setCachedServicesRetentionTime(long cachedServicesRetentionTime) {
+            mCachedServicesRetentionTime = cachedServicesRetentionTime;
+            return this;
+        }
+
+        /**
+         * 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() {
@@ -353,6 +448,9 @@
                     mIsAggressiveQueryModeEnabled,
                     mIsQueryWithKnownAnswerEnabled,
                     mAvoidAdvertisingEmptyTxtRecords,
+                    mIsCachedServicesRemovalEnabled,
+                    mCachedServicesRetentionTime,
+                    mIsShortHostnamesEnabled,
                     mOverrideProvider);
         }
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
index 4399f2d..3eae3c7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import java.io.IOException;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -29,7 +27,6 @@
 import java.util.Objects;
 
 /** An mDNS "AAAA" or "A" record, which holds an IPv6 or IPv4 address. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsInetAddressRecord extends MdnsRecord {
     @Nullable private Inet6Address inet6Address;
     @Nullable private Inet4Address inet4Address;
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 0b2003f..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);
+        }
     }
 
     /**
@@ -416,13 +425,6 @@
         // recvbuf and src are reused after this returns; ensure references to src are not kept.
         final InetSocketAddress srcCopy = new InetSocketAddress(src.getAddress(), src.getPort());
 
-        if (DBG) {
-            mSharedLog.v("Parsed packet with " + packet.questions.size() + " questions, "
-                    + packet.answers.size() + " answers, "
-                    + packet.authorityRecords.size() + " authority, "
-                    + packet.additionalRecords.size() + " additional from " + srcCopy);
-        }
-
         Map<Integer, Integer> conflictingServices =
                 mRecordRepository.getConflictingServices(packet);
 
@@ -440,7 +442,14 @@
         // answer. One exception is simultaneous probe tiebreaking (rfc6762 8.2), in which case the
         // conflicting service is still probing and won't reply either.
         final MdnsReplyInfo answers = mRecordRepository.getReply(packet, srcCopy);
-
+        // Dump the query packet.
+        if (DBG || answers != null) {
+            mSharedLog.v("Parsed packet with transactionId(" + packet.transactionId + "): "
+                    + packet.questions.size() + " questions, "
+                    + packet.answers.size() + " answers, "
+                    + packet.authorityRecords.size() + " authority, "
+                    + packet.additionalRecords.size() + " additional from " + srcCopy);
+        }
         if (answers == null) return;
         mReplySender.queueReply(answers);
     }
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 c575d40..36fad31 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
@@ -16,7 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
index e84cead..cfd8e9a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
@@ -27,6 +27,7 @@
 import android.os.Looper;
 import android.os.Message;
 
+import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.SharedLog;
 
 import java.io.IOException;
@@ -167,9 +168,7 @@
      * @return true if probing was in progress, false if this was a no-op
      */
     public boolean stop(int id) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException("stop can only be called from the looper thread");
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         // Since this is run on the looper thread, messages cannot be currently processing and are
         // all in the handler queue; unless this method is called from a message, but the current
         // message cannot be cancelled.
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
index 39bf653..e8f5e71 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
@@ -18,15 +18,12 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.net.module.util.DnsUtils;
 
 import java.io.IOException;
 import java.util.Arrays;
 
 /** An mDNS "PTR" record, which holds a name (the "pointer"). */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsPointerRecord extends MdnsRecord {
     private String[] pointer;
 
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 cfeca5d..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.delayUntilNextTaskWithoutBackoffMs;
+            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.delayUntilNextTaskWithoutBackoffMs;
+        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/MdnsReplySender.java b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
index db3845a..4708cb6 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
@@ -16,9 +16,9 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR;
 import static com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.RequiresApi;
@@ -245,7 +245,7 @@
                 return;
             }
 
-            if (mEnableDebugLog) mSharedLog.v("Sending " + replyInfo);
+            mSharedLog.log("Sending " + replyInfo);
 
             final int flags = 0x8400; // Response, authoritative (rfc6762 18.4)
             final MdnsPacket packet = new MdnsPacket(flags,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index 591ed8b..4ae8701 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -18,8 +18,8 @@
 
 import static com.android.net.module.util.DnsUtils.equalsIgnoreDnsCase;
 import static com.android.net.module.util.DnsUtils.toDnsUpperCase;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.MdnsResponse.EXPIRATION_NEVER;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
 
 import static java.lang.Math.min;
 
@@ -49,7 +49,7 @@
  *  to their default value (0, false or null).
  */
 public class MdnsServiceCache {
-    static class CacheKey {
+    public static class CacheKey {
         @NonNull final String mUpperCaseServiceType;
         @NonNull final SocketKey mSocketKey;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
index fd716d2..907e2ff 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.net.module.util.DnsUtils;
 
 import java.io.IOException;
@@ -28,7 +26,6 @@
 import java.util.Objects;
 
 /** An mDNS "SRV" record, which contains service information. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsServiceRecord extends MdnsRecord {
     public static final int PROTO_NONE = 0;
     public static final int PROTO_TCP = 1;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 4b55ea9..8c86fb8 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -16,11 +16,12 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 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.util.MdnsUtils.Clock;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.server.connectivity.mdns.util.MdnsUtils.buildMdnsServiceInfoFromResponse;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -41,10 +42,7 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.net.DatagramPacket;
-import java.net.Inet4Address;
-import java.net.Inet6Address;
 import java.net.InetSocketAddress;
-import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -61,6 +59,7 @@
 public class MdnsServiceTypeClient {
 
     private static final String TAG = MdnsServiceTypeClient.class.getSimpleName();
+    private static final boolean DBG = MdnsDiscoveryManager.DBG;
     @VisibleForTesting
     static final int EVENT_START_QUERYTASK = 1;
     static final int EVENT_QUERY_RESULT = 2;
@@ -186,10 +185,14 @@
                                     searchOptions.numOfQueriesBeforeBackoff(),
                                     false /* forceEnableBackoff */
                             );
+                    final long timeToNextTaskMs = calculateTimeToNextTask(args, now);
+                    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),
-                            calculateTimeToNextTask(args, now, sharedLog));
+                            timeToNextTaskMs);
                     break;
                 }
                 default:
@@ -309,57 +312,6 @@
         serviceCache.unregisterServiceExpiredCallback(cacheKey);
     }
 
-    private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(@NonNull MdnsResponse response,
-            @NonNull String[] serviceTypeLabels, long elapsedRealtimeMillis) {
-        String[] hostName = null;
-        int port = 0;
-        if (response.hasServiceRecord()) {
-            hostName = response.getServiceRecord().getServiceHost();
-            port = response.getServiceRecord().getServicePort();
-        }
-
-        final List<String> ipv4Addresses = new ArrayList<>();
-        final List<String> ipv6Addresses = new ArrayList<>();
-        if (response.hasInet4AddressRecord()) {
-            for (MdnsInetAddressRecord inetAddressRecord : response.getInet4AddressRecords()) {
-                final Inet4Address inet4Address = inetAddressRecord.getInet4Address();
-                ipv4Addresses.add((inet4Address == null) ? null : inet4Address.getHostAddress());
-            }
-        }
-        if (response.hasInet6AddressRecord()) {
-            for (MdnsInetAddressRecord inetAddressRecord : response.getInet6AddressRecords()) {
-                final Inet6Address inet6Address = inetAddressRecord.getInet6Address();
-                ipv6Addresses.add((inet6Address == null) ? null : inet6Address.getHostAddress());
-            }
-        }
-        String serviceInstanceName = response.getServiceInstanceName();
-        if (serviceInstanceName == null) {
-            throw new IllegalStateException(
-                    "mDNS response must have non-null service instance name");
-        }
-        List<String> textStrings = null;
-        List<MdnsServiceInfo.TextEntry> textEntries = null;
-        if (response.hasTextRecord()) {
-            textStrings = response.getTextRecord().getStrings();
-            textEntries = response.getTextRecord().getEntries();
-        }
-        Instant now = Instant.now();
-        // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address.
-        return new MdnsServiceInfo(
-                serviceInstanceName,
-                serviceTypeLabels,
-                response.getSubtypes(),
-                hostName,
-                port,
-                ipv4Addresses,
-                ipv6Addresses,
-                textStrings,
-                textEntries,
-                response.getInterfaceIndex(),
-                response.getNetwork(),
-                now.plusMillis(response.getMinRemainingTtl(elapsedRealtimeMillis)));
-    }
-
     private List<MdnsResponse> getExistingServices() {
         return featureFlags.isQueryWithKnownAnswerEnabled()
                 ? serviceCache.getCachedServices(cacheKey) : Collections.emptyList();
@@ -422,10 +374,13 @@
                             searchOptions.numOfQueriesBeforeBackoff(),
                             forceEnableBackoff
                     );
+            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),
-                    calculateTimeToNextTask(args, now, sharedLog));
+                    timeToNextTaskMs);
         } else {
             final List<MdnsResponse> servicesToResolve = makeResponsesForResolve(socketKey);
             final QueryTask queryTask = new QueryTask(
@@ -456,6 +411,14 @@
         return executor;
     }
 
+    /**
+     * Get the cache key for this service type client.
+     */
+    @NonNull
+    public MdnsServiceCache.CacheKey getCacheKey() {
+        return cacheKey;
+    }
+
     private void removeScheduledTask() {
         dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
         sharedLog.log("Remove EVENT_START_QUERYTASK"
@@ -537,6 +500,10 @@
                 // If the response is not modified and already in the cache. The cache will
                 // need to be updated to refresh the last receipt time.
                 serviceCache.addOrUpdateService(cacheKey, response);
+                if (DBG) {
+                    sharedLog.v("Update the last receipt time for service:"
+                            + serviceInstanceName);
+                }
             }
         }
         if (dependencies.hasMessages(handler, EVENT_START_QUERYTASK)) {
@@ -548,10 +515,13 @@
                             searchOptions.numOfQueriesBeforeBackoff());
             if (args != null) {
                 removeScheduledTask();
+                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),
-                        calculateTimeToNextTask(args, now, sharedLog));
+                        timeToNextTaskMs);
             }
         }
     }
@@ -760,7 +730,7 @@
                                 serviceType,
                                 subtypes,
                                 taskArgs.config.expectUnicastResponse,
-                                taskArgs.config.transactionId,
+                                taskArgs.config.getTransactionId(),
                                 socketKey,
                                 onlyUseIpv6OnIpv6OnlyNetworks,
                                 sendDiscoveryQueries,
@@ -802,11 +772,8 @@
     }
 
     private static long calculateTimeToNextTask(MdnsQueryScheduler.ScheduledQueryTaskArgs args,
-            long now, SharedLog sharedLog) {
-        long timeToNextTasksWithBackoffInMs = Math.max(args.timeToRun - now, 0);
-        sharedLog.log(String.format("Next run: sessionId: %d, in %d ms",
-                args.sessionId, timeToNextTasksWithBackoffInMs));
-        return timeToNextTasksWithBackoffInMs;
+            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 5c9ec09..1212e29 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
@@ -19,12 +19,14 @@
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.isNetworkMatched;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.RequiresApi;
+import android.annotation.SuppressLint;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -163,6 +165,7 @@
         this(context, looper, new Dependencies(), sharedLog, socketRequestMonitor);
     }
 
+    @SuppressLint("NewApi")
     MdnsSocketProvider(@NonNull Context context, @NonNull Looper looper,
             @NonNull Dependencies deps, @NonNull SharedLog sharedLog,
             @NonNull SocketRequestMonitor socketRequestMonitor) {
@@ -334,6 +337,7 @@
     }
 
     /*** Start monitoring sockets by listening callbacks for sockets creation or removal */
+    @SuppressLint("NewApi")
     public void startMonitoringSockets() {
         ensureRunningOnHandlerThread(mHandler);
         mRequestStop = false; // Reset stop request flag.
@@ -364,6 +368,7 @@
         }
     }
 
+    @SuppressLint("NewApi")
     private void maybeStopMonitoringSockets() {
         if (!mMonitoringSockets) return; // Already unregistered.
         if (!mRequestStop) return; // No stop request.
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
index 77d1d7a..2b3ebf9 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 
 import java.io.IOException;
@@ -29,7 +27,6 @@
 import java.util.Objects;
 
 /** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsTextRecord extends MdnsRecord {
     private List<TextEntry> entries;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java b/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
index 70451f3..4d7e4bc 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
@@ -16,7 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.os.Handler;
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 d2cd463..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,154 +25,42 @@
  * 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 queriesPerBurst;
-    private final int timeBetweenBurstsInMs;
-    private final int burstCounter;
-    final long delayUntilNextTaskWithoutBackoffMs;
-    private final boolean isFirstBurst;
-    private final long queryCount;
+    final int queryIndex;
+    final int queryMode;
 
-    QueryTaskConfig(long queryCount, int transactionId,
-            boolean expectUnicastResponse, boolean isFirstBurst, int burstCounter,
-            int queriesPerBurst, int timeBetweenBurstsInMs,
-            long delayUntilNextTaskWithoutBackoffMs) {
-        this.transactionId = transactionId;
-        this.expectUnicastResponse = expectUnicastResponse;
-        this.queriesPerBurst = queriesPerBurst;
-        this.timeBetweenBurstsInMs = timeBetweenBurstsInMs;
-        this.burstCounter = burstCounter;
-        this.delayUntilNextTaskWithoutBackoffMs = delayUntilNextTaskWithoutBackoffMs;
-        this.isFirstBurst = isFirstBurst;
-        this.queryCount = queryCount;
+    QueryTaskConfig(int queryMode, int queryIndex) {
+        this.queryMode = queryMode;
+        this.queryIndex = queryIndex;
+        this.expectUnicastResponse = getExpectUnicastResponse();
     }
 
     QueryTaskConfig(int queryMode) {
-        this.queriesPerBurst = QUERIES_PER_BURST;
-        this.burstCounter = 0;
-        this.transactionId = 1;
-        this.expectUnicastResponse = true;
-        this.isFirstBurst = true;
-        // Config the scan frequency based on the scan mode.
-        if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            this.timeBetweenBurstsInMs = INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs =
-                    TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS;
-        } else if (queryMode == PASSIVE_QUERY_MODE) {
-            // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and then
-            // in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
-            // queries.
-            this.timeBetweenBurstsInMs = MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
-        } else {
-            // In active scan mode, sends a burst of QUERIES_PER_BURST queries,
-            // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and
-            // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and
-            // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS.
-            this.timeBetweenBurstsInMs = INITIAL_TIME_BETWEEN_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
-        }
-        this.queryCount = 0;
-    }
-
-    long getDelayUntilNextTaskWithoutBackoff(boolean isFirstQueryInBurst,
-            boolean isLastQueryInBurst, int queryMode) {
-        if (isFirstQueryInBurst && queryMode == AGGRESSIVE_QUERY_MODE) {
-            return 0;
-        }
-        if (isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
-        }
-        return queryMode == AGGRESSIVE_QUERY_MODE
-                ? TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS
-                : TIME_BETWEEN_QUERIES_IN_BURST_MS;
-    }
-
-    boolean getNextExpectUnicastResponse(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return false;
-        }
-        if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            return true;
-        }
-        return alwaysAskForUnicastResponse;
-    }
-
-    int getNextTimeBetweenBurstsMs(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
-        }
-        final int maxTimeBetweenBursts = queryMode == AGGRESSIVE_QUERY_MODE
-                ? MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS : MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-        return Math.min(timeBetweenBurstsInMs * 2, maxTimeBetweenBursts);
+        this(queryMode, 0);
     }
 
     /**
      * Get new QueryTaskConfig for next run.
      */
     public QueryTaskConfig getConfigForNextRun(int queryMode) {
-        long newQueryCount = queryCount + 1;
-        int newTransactionId = transactionId + 1;
-        if (newTransactionId > UNSIGNED_SHORT_MAX_VALUE) {
-            newTransactionId = 1;
-        }
-
-        int newQueriesPerBurst = queriesPerBurst;
-        int newBurstCounter = burstCounter + 1;
-        final boolean isFirstQueryInBurst = newBurstCounter == 1;
-        final boolean isLastQueryInBurst = newBurstCounter == queriesPerBurst;
-        boolean newIsFirstBurst = isFirstBurst && !isLastQueryInBurst;
-        if (isLastQueryInBurst) {
-            newBurstCounter = 0;
-            // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and
-            // then in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
-            // queries.
-            if (isFirstBurst && queryMode == PASSIVE_QUERY_MODE) {
-                newQueriesPerBurst = QUERIES_PER_BURST_PASSIVE_MODE;
-            }
-        }
-
-        return new QueryTaskConfig(newQueryCount, newTransactionId,
-                getNextExpectUnicastResponse(isLastQueryInBurst, queryMode), newIsFirstBurst,
-                newBurstCounter, newQueriesPerBurst,
-                getNextTimeBetweenBurstsMs(isLastQueryInBurst, queryMode),
-                getDelayUntilNextTaskWithoutBackoff(
-                        isFirstQueryInBurst, isLastQueryInBurst, queryMode));
+        final int newQueryIndex = queryIndex + 1;
+        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 (burstCounter != 0 || isFirstBurst) {
-            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 queryCount > numOfQueriesBeforeBackoff;
+        return queryIndex == 0 || alwaysAskForUnicastResponse;
     }
 }
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 8745941..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
@@ -24,18 +24,23 @@
 import android.annotation.Nullable;
 import android.net.Network;
 import android.os.Build;
-import android.os.Handler;
 import android.os.SystemClock;
 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;
 import com.android.server.connectivity.mdns.MdnsPacketWriter;
 import com.android.server.connectivity.mdns.MdnsRecord;
+import com.android.server.connectivity.mdns.MdnsResponse;
+import com.android.server.connectivity.mdns.MdnsServiceInfo;
 
 import java.io.IOException;
 import java.net.DatagramPacket;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.nio.ByteBuffer;
@@ -43,6 +48,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -82,21 +88,6 @@
         }
     }
 
-    /*** Ensure that current running thread is same as given handler thread */
-    public static void ensureRunningOnHandlerThread(@NonNull Handler handler) {
-        if (!isRunningOnHandlerThread(handler)) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
-    /*** Check that current running thread is same as given handler thread */
-    public static boolean isRunningOnHandlerThread(@NonNull Handler handler) {
-        if (handler.getLooper().getThread() == Thread.currentThread()) {
-            return true;
-        }
-        return false;
-    }
 
     /*** Check whether the target network matches the current network */
     public static boolean isNetworkMatched(@Nullable Network targetNetwork,
@@ -283,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. */
@@ -318,4 +306,62 @@
         }
         return true;
     }
+
+    /**
+     * Build MdnsServiceInfo object from given MdnsResponse, service type labels and current time.
+     *
+     * @param response target service response
+     * @param serviceTypeLabels service type labels
+     * @param elapsedRealtimeMillis current time.
+     */
+    public static MdnsServiceInfo buildMdnsServiceInfoFromResponse(@NonNull MdnsResponse response,
+            @NonNull String[] serviceTypeLabels, long elapsedRealtimeMillis) {
+        String[] hostName = null;
+        int port = 0;
+        if (response.hasServiceRecord()) {
+            hostName = response.getServiceRecord().getServiceHost();
+            port = response.getServiceRecord().getServicePort();
+        }
+
+        final List<String> ipv4Addresses = new ArrayList<>();
+        final List<String> ipv6Addresses = new ArrayList<>();
+        if (response.hasInet4AddressRecord()) {
+            for (MdnsInetAddressRecord inetAddressRecord : response.getInet4AddressRecords()) {
+                final Inet4Address inet4Address = inetAddressRecord.getInet4Address();
+                ipv4Addresses.add((inet4Address == null) ? null : inet4Address.getHostAddress());
+            }
+        }
+        if (response.hasInet6AddressRecord()) {
+            for (MdnsInetAddressRecord inetAddressRecord : response.getInet6AddressRecords()) {
+                final Inet6Address inet6Address = inetAddressRecord.getInet6Address();
+                ipv6Addresses.add((inet6Address == null) ? null : inet6Address.getHostAddress());
+            }
+        }
+        String serviceInstanceName = response.getServiceInstanceName();
+        if (serviceInstanceName == null) {
+            throw new IllegalStateException(
+                    "mDNS response must have non-null service instance name");
+        }
+        List<String> textStrings = null;
+        List<MdnsServiceInfo.TextEntry> textEntries = null;
+        if (response.hasTextRecord()) {
+            textStrings = response.getTextRecord().getStrings();
+            textEntries = response.getTextRecord().getEntries();
+        }
+        Instant now = Instant.now();
+        // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address.
+        return new MdnsServiceInfo(
+                serviceInstanceName,
+                serviceTypeLabels,
+                response.getSubtypes(),
+                hostName,
+                port,
+                ipv4Addresses,
+                ipv6Addresses,
+                textStrings,
+                textEntries,
+                response.getInterfaceIndex(),
+                response.getNetwork(),
+                now.plusMillis(response.getMinRemainingTtl(elapsedRealtimeMillis)));
+    }
 }
\ No newline at end of file
diff --git a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
index cadc04d..1ac99e4 100644
--- a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
+++ b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
@@ -202,20 +202,6 @@
         return;
     }
 
-    private static NetworkCapabilities mixInCapabilities(NetworkCapabilities nc,
-            NetworkCapabilities addedNc) {
-       final NetworkCapabilities.Builder builder = new NetworkCapabilities.Builder(nc);
-       for (int transport : addedNc.getTransportTypes()) builder.addTransportType(transport);
-       for (int capability : addedNc.getCapabilities()) builder.addCapability(capability);
-       return builder.build();
-    }
-
-    private static NetworkCapabilities createDefaultNetworkCapabilities() {
-        return NetworkCapabilities.Builder
-                .withoutDefaultCapabilities()
-                .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET).build();
-    }
-
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
     protected boolean removeInterface(String interfaceName) {
         NetworkInterfaceState iface = mTrackingInterfaces.remove(interfaceName);
@@ -556,14 +542,6 @@
             maybeRestart();
         }
 
-        private void ensureRunningOnEthernetHandlerThread() {
-            if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-                throw new IllegalStateException(
-                        "Not running on the Ethernet thread: "
-                                + Thread.currentThread().getName());
-            }
-        }
-
         private void handleOnLinkPropertiesChange(LinkProperties linkProperties) {
             mLinkProperties = linkProperties;
             if (mNetworkAgent != null) {
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 71f289e..eedf427 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -21,6 +21,7 @@
 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 +41,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;
@@ -49,19 +49,23 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 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;
@@ -104,7 +108,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;
 
@@ -138,8 +142,8 @@
     private int mTetheringInterfaceMode = INTERFACE_MODE_CLIENT;
     // Tracks whether clients were notified that the tethered interface is available
     private boolean mTetheredInterfaceWasAvailable = false;
-
-    private int mEthernetState = ETHERNET_STATE_ENABLED;
+    // Tracks the current state of ethernet as configured by EthernetManager#setEthernetEnabled.
+    private boolean mIsEthernetEnabled = true;
 
     private class TetheredInterfaceRequestList extends
             RemoteCallbackList<ITetheredInterfaceCallback> {
@@ -214,9 +218,6 @@
         // Note: processNetlinkMessage is called on the handler thread.
         @Override
         protected void processNetlinkMessage(NetlinkMessage nlMsg, long whenMs) {
-            // ignore all updates when ethernet is disabled.
-            if (mEthernetState == ETHERNET_STATE_DISABLED) return;
-
             if (nlMsg instanceof RtNetlinkLinkMessage) {
                 processRtNetlinkLinkMessage((RtNetlinkLinkMessage) nlMsg);
             } else {
@@ -302,11 +303,7 @@
     }
 
     private void ensureRunningOnEthernetServiceThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on EthernetService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
     }
 
     /**
@@ -404,26 +401,27 @@
         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;
     }
@@ -450,7 +448,7 @@
                 unicastInterfaceStateChange(listener, mTetheringInterface);
             }
 
-            unicastEthernetStateChange(listener, mEthernetState);
+            unicastEthernetStateChange(listener, mIsEthernetEnabled);
         });
     }
 
@@ -595,15 +593,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);
-            if (NetdUtils.hasFlag(config, INetd.IF_STATE_DOWN)) {
-                // 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);
-            }
+            // 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.
@@ -629,7 +623,7 @@
             nc = mNetworkCapabilities.get(hwAddress);
             if (nc == null) {
                 final boolean isTestIface = iface.matches(TEST_IFACE_REGEXP);
-                nc = createDefaultNetworkCapabilities(isTestIface);
+                nc = createDefaultNetworkCapabilities(isTestIface, /* overrideTransport */ null);
             }
         }
 
@@ -649,6 +643,10 @@
     }
 
     private void setInterfaceAdministrativeState(String iface, boolean up, EthernetCallback cb) {
+        if (!mIsEthernetEnabled) {
+            cb.onError("Cannot enable/disable interface when ethernet is disabled");
+            return;
+        }
         if (getInterfaceState(iface) == EthernetManager.STATE_ABSENT) {
             cb.onError("Failed to enable/disable absent interface: " + iface);
             return;
@@ -659,15 +657,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);
     }
 
@@ -706,10 +696,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)) {
@@ -729,13 +715,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,9 +738,13 @@
      */
     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();
+        NetworkCapabilities nc;
+        if (TextUtils.isEmpty(config.mCapabilities)) {
+            boolean isTestIface = config.mIface.matches(TEST_IFACE_REGEXP);
+            nc = createDefaultNetworkCapabilities(isTestIface, config.mTransport);
+        } else {
+            nc = createNetworkCapabilities(config.mCapabilities, config.mTransport).build();
+        }
         mNetworkCapabilities.put(config.mIface, nc);
 
         if (null != config.mIpConfig) {
@@ -773,15 +759,16 @@
         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);
+    private static NetworkCapabilities createDefaultNetworkCapabilities(
+            boolean isTestIface, @Nullable String overrideTransport) {
+        NetworkCapabilities.Builder builder =
+                createNetworkCapabilities(/* commaSeparatedCapabilities */ null, overrideTransport)
+                        .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);
 
         if (isTestIface) {
             builder.addTransportType(NetworkCapabilities.TRANSPORT_TEST);
@@ -795,7 +782,6 @@
     /**
      * 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
@@ -805,12 +791,12 @@
      */
     @VisibleForTesting
     static NetworkCapabilities.Builder createNetworkCapabilities(
-            boolean clearDefaultCapabilities, @Nullable String commaSeparatedCapabilities,
-            @Nullable String overrideTransport) {
+            @Nullable String commaSeparatedCapabilities, @Nullable String overrideTransport) {
 
-        final NetworkCapabilities.Builder builder = clearDefaultCapabilities
-                ? NetworkCapabilities.Builder.withoutDefaultCapabilities()
-                : new NetworkCapabilities.Builder();
+        final NetworkCapabilities.Builder builder =
+                TextUtils.isEmpty(commaSeparatedCapabilities)
+                        ? new NetworkCapabilities.Builder()
+                        : NetworkCapabilities.Builder.withoutDefaultCapabilities();
 
         // 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
@@ -963,43 +949,47 @@
     @VisibleForTesting(visibility = PACKAGE)
     protected void setEthernetEnabled(boolean enabled) {
         mHandler.post(() -> {
-            int newState = enabled ? ETHERNET_STATE_ENABLED : ETHERNET_STATE_DISABLED;
-            if (mEthernetState == newState) return;
+            if (mIsEthernetEnabled == enabled) return;
 
-            mEthernetState = newState;
+            mIsEthernetEnabled = enabled;
 
-            if (enabled) {
-                trackAvailableInterfaces();
-            } else {
-                // TODO: maybe also disable server mode interface as well.
-                untrackFactoryInterfaces();
+            // 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);
             }
-            broadcastEthernetStateChange(mEthernetState);
+
+            for (String iface : interfaces) {
+                setInterfaceUpState(iface, enabled);
+            }
+            broadcastEthernetStateChange(mIsEthernetEnabled);
         });
     }
 
-    private void untrackFactoryInterfaces() {
-        for (String iface : mFactory.getAvailableInterfaces(true /* includeRestricted */)) {
-            stopTrackingInterface(iface);
-        }
+    private int isEthernetEnabledAsInt(boolean state) {
+        return state ? ETHERNET_STATE_ENABLED : ETHERNET_STATE_DISABLED;
     }
 
     private void unicastEthernetStateChange(@NonNull IEthernetServiceListener listener,
-            int state) {
+            boolean enabled) {
         ensureRunningOnEthernetServiceThread();
         try {
-            listener.onEthernetStateChanged(state);
+            listener.onEthernetStateChanged(isEthernetEnabledAsInt(enabled));
         } catch (RemoteException e) {
             // Do nothing here.
         }
     }
 
-    private void broadcastEthernetStateChange(int state) {
+    private void broadcastEthernetStateChange(boolean enabled) {
         ensureRunningOnEthernetServiceThread();
         final int n = mListeners.beginBroadcast();
         for (int i = 0; i < n; i++) {
             try {
-                mListeners.getBroadcastItem(i).onEthernetStateChanged(state);
+                mListeners.getBroadcastItem(i)
+                            .onEthernetStateChanged(isEthernetEnabledAsInt(enabled));
             } catch (RemoteException e) {
                 // Do nothing here.
             }
@@ -1007,11 +997,17 @@
         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());
             pw.println("Ethernet State: "
-                    + (mEthernetState == ETHERNET_STATE_ENABLED ? "enabled" : "disabled"));
+                    + (mIsEthernetEnabled ? "enabled" : "disabled"));
             pw.println("Ethernet interface name filter: " + mIfaceMatch);
             pw.println("Interface used for tethering: " + mTetheringInterface);
             pw.println("Tethering interface mode: " + mTetheringInterfaceMode);
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 0d27d54..a8e3203 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -52,7 +52,6 @@
 import static android.net.TrafficStats.KB_IN_BYTES;
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.net.TrafficStats.UID_TETHERING;
-import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -127,6 +126,8 @@
 import android.net.Uri;
 import android.net.netstats.IUsageCallback;
 import android.net.netstats.NetworkStatsDataMigrationUtils;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProvider;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.net.netstats.provider.NetworkStatsProvider;
@@ -304,9 +305,10 @@
 
     static final String TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME =
             "trafficstats_cache_expiry_duration_ms";
-    static final String TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME = "trafficstats_cache_max_entries";
+    static final String TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME =
+            "trafficstats_cache_max_entries";
     static final int DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS = 1000;
-    static final int DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES = 400;
+    static final int DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES = 400;
     /**
      * The delay time between to network stats update intents.
      * Added to fix intent spams (b/343844995)
@@ -484,16 +486,24 @@
     @GuardedBy("mStatsLock")
     private long mLatestNetworkStatsUpdatedBroadcastScheduledTime = Long.MIN_VALUE;
 
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsTotalCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsIfaceCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
-    static final String TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG =
+    // A feature flag to control whether the client-side rate limit cache should be enabled.
+    @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";
     static final String BROADCAST_NETWORK_STATS_UPDATED_RATE_LIMIT_ENABLED_FLAG =
             "broadcast_network_stats_updated_rate_limit_enabled_flag";
-    private final boolean mAlwaysUseTrafficStatsRateLimitCache;
+    private final boolean mIsTrafficStatsServiceRateLimitCacheEnabled;
     private final int mTrafficStatsRateLimitCacheExpiryDuration;
-    private final int mTrafficStatsRateLimitCacheMaxEntries;
+    private final int mTrafficStatsServiceRateLimitCacheMaxEntries;
+    private final TrafficStatsRateLimitCacheConfig mTrafficStatsRateLimitCacheClientSideConfig;
     private final boolean mBroadcastNetworkStatsUpdatedRateLimitEnabled;
 
 
@@ -687,20 +697,34 @@
             mEventLogger = null;
         }
 
-        mAlwaysUseTrafficStatsRateLimitCache =
-                mDeps.alwaysUseTrafficStatsRateLimitCache(mContext);
+        mTrafficStatsRateLimitCacheClientSideConfig =
+                mDeps.getTrafficStatsRateLimitCacheClientSideConfig(mContext);
+        // If the client side cache feature is enabled, disable the service side
+        // cache unconditionally.
+        mIsTrafficStatsServiceRateLimitCacheEnabled =
+                mDeps.isTrafficStatsServiceRateLimitCacheEnabled(mContext,
+                        mTrafficStatsRateLimitCacheClientSideConfig.isCacheEnabled);
         mBroadcastNetworkStatsUpdatedRateLimitEnabled =
                 mDeps.enabledBroadcastNetworkStatsUpdatedRateLimiting(mContext);
         mTrafficStatsRateLimitCacheExpiryDuration =
                 mDeps.getTrafficStatsRateLimitCacheExpiryDuration();
-        mTrafficStatsRateLimitCacheMaxEntries =
-                mDeps.getTrafficStatsRateLimitCacheMaxEntries();
-        mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
-        mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
-        mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration, mTrafficStatsRateLimitCacheMaxEntries);
+        mTrafficStatsServiceRateLimitCacheMaxEntries =
+                mDeps.getTrafficStatsServiceRateLimitCacheMaxEntries();
+        if (mIsTrafficStatsServiceRateLimitCacheEnabled) {
+            mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+            mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+            mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+        } else {
+            mTrafficStatsTotalCache = null;
+            mTrafficStatsIfaceCache = null;
+            mTrafficStatsUidCache = null;
+        }
 
         // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
         // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
@@ -960,14 +984,43 @@
         }
 
         /**
-         * Get whether TrafficStats rate-limit cache is always applied.
+         * Get client side traffic stats rate-limit cache config.
          *
          * This method should only be called once in the constructor,
          * to ensure that the code does not need to deal with flag values changing at runtime.
          */
-        public boolean alwaysUseTrafficStatsRateLimitCache(@NonNull Context ctx) {
-            return SdkLevel.isAtLeastV() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
-                    ctx, TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG);
+        @NonNull
+        public TrafficStatsRateLimitCacheConfig getTrafficStatsRateLimitCacheClientSideConfig(
+                @NonNull Context ctx) {
+            final TrafficStatsRateLimitCacheConfig config =
+                    new TrafficStatsRateLimitCacheConfig.Builder()
+                            .setIsCacheEnabled(DeviceConfigUtils.isTetheringFeatureEnabled(
+                                    ctx, TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG))
+                            .setExpiryDurationMs(getDeviceConfigPropertyInt(
+                                    NAMESPACE_TETHERING, TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
+                                    DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS))
+                            .setMaxEntries(getDeviceConfigPropertyInt(
+                                    NAMESPACE_TETHERING,
+                                    TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                                    DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES))
+                            .build();
+            return config;
+        }
+
+        /**
+         * Determines whether the service-side rate-limiting cache is enabled.
+         *
+         * The cache is enabled for devices running Android V+ or apps targeting SDK V+
+         * if the `TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG` feature flag
+         * is enabled and client-side caching is disabled.
+         *
+         * This method should only be called once in the constructor,
+         * to ensure that the code does not need to deal with flag values changing at runtime.
+         */
+        public boolean isTrafficStatsServiceRateLimitCacheEnabled(@NonNull Context ctx,
+                boolean clientCacheEnabled) {
+            return !clientCacheEnabled && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    ctx, TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG);
         }
 
         /**
@@ -983,15 +1036,15 @@
         }
 
         /**
-         * Get TrafficStats rate-limit cache max entries.
+         * Get TrafficStats service side rate-limit cache max entries.
          *
          * This method should only be called once in the constructor,
          * to ensure that the code does not need to deal with flag values changing at runtime.
          */
-        public int getTrafficStatsRateLimitCacheMaxEntries() {
+        public int getTrafficStatsServiceRateLimitCacheMaxEntries() {
             return getDeviceConfigPropertyInt(
-                    NAMESPACE_TETHERING, TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME,
-                    DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES);
+                    NAMESPACE_TETHERING, TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                    DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES);
         }
 
         /**
@@ -2129,30 +2182,48 @@
         }
     }
 
-    @Override
-    public long getUidStats(int uid, int type) {
-        return getValueForTypeFromFirstEntry(getTypelessUidStats(uid), type);
+    /**
+     * Determines whether to use the client-side cache for traffic stats rate limiting.
+     *
+     * This is based on the cache enabled feature flag. If enabled, the client-side cache
+     * is used for V+ devices or callers with V+ target sdk.
+     *
+     * @param callingUid The UID of the app making the request.
+     * @return True if the client-side cache should be used, false otherwise.
+     */
+    private boolean useClientSideCache(int callingUid) {
+        return mTrafficStatsRateLimitCacheClientSideConfig.isCacheEnabled && (SdkLevel.isAtLeastV()
+                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid));
     }
 
-    @NonNull
+    /**
+     * Determines whether to use the service-side cache for traffic stats rate limiting.
+     *
+     * This is based on the cache enabled feature flag. If enabled, the service-side cache
+     * is used for V+ devices or callers with V+ target sdk.
+     *
+     * @param callingUid The UID of the app making the request.
+     * @return True if the service-side cache should be used, false otherwise.
+     */
+    private boolean useServiceSideCache(int callingUid) {
+        return mIsTrafficStatsServiceRateLimitCacheEnabled && (SdkLevel.isAtLeastV()
+                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid));
+    }
+
+    @Nullable
     @Override
-    public NetworkStats getTypelessUidStats(int uid) {
-        final NetworkStats stats = new NetworkStats(0, 0);
+    public StatsResult getUidStats(int uid) {
         final int callingUid = Binder.getCallingUid();
         if (callingUid != android.os.Process.SYSTEM_UID && callingUid != uid) {
-            return stats;
+            return null;
         }
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsRateLimitCache
-                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid)) {
+        if (useServiceSideCache(callingUid)) {
             entry = mTrafficStatsUidCache.getOrCompute(IFACE_ALL, uid,
                     () -> mDeps.nativeGetUidStat(uid));
         } else entry = mDeps.nativeGetUidStat(uid);
 
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
     }
 
     @Nullable
@@ -2169,24 +2240,24 @@
         return entry;
     }
 
-    @NonNull
+    @Nullable
     @Override
-    public NetworkStats getTypelessIfaceStats(@NonNull String iface) {
+    public StatsResult getIfaceStats(@NonNull String iface) {
         Objects.requireNonNull(iface);
 
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsRateLimitCache
-                || mDeps.isChangeEnabled(
-                        ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
+        if (useServiceSideCache(Binder.getCallingUid())) {
             entry = mTrafficStatsIfaceCache.getOrCompute(iface, UID_ALL,
                     () -> getIfaceStatsInternal(iface));
         } else entry = getIfaceStatsInternal(iface);
 
-        NetworkStats stats = new NetworkStats(0, 0);
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
+    }
+
+    @Nullable
+    private StatsResult getStatsResultFromEntryOrNull(@Nullable NetworkStats.Entry entry) {
+        if (entry == null) return null;
+        return new StatsResult(entry.rxBytes, entry.rxPackets, entry.txBytes, entry.txPackets);
     }
 
     @Nullable
@@ -2199,30 +2270,39 @@
         return entry;
     }
 
-    @NonNull
+    @Nullable
     @Override
-    public NetworkStats getTypelessTotalStats() {
+    public StatsResult getTotalStats() {
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsRateLimitCache
-                || mDeps.isChangeEnabled(
-                        ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
+        if (useServiceSideCache(Binder.getCallingUid())) {
             entry = mTrafficStatsTotalCache.getOrCompute(
                     IFACE_ALL, UID_ALL, () -> getTotalStatsInternal());
         } else entry = getTotalStatsInternal();
 
-        final NetworkStats stats = new NetworkStats(0, 0);
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
     }
 
     @Override
     public void clearTrafficStatsRateLimitCaches() {
         PermissionUtils.enforceNetworkStackPermissionOr(mContext, NETWORK_SETTINGS);
-        mTrafficStatsUidCache.clear();
-        mTrafficStatsIfaceCache.clear();
-        mTrafficStatsTotalCache.clear();
+        if (mIsTrafficStatsServiceRateLimitCacheEnabled) {
+            mTrafficStatsUidCache.clear();
+            mTrafficStatsIfaceCache.clear();
+            mTrafficStatsTotalCache.clear();
+        }
+    }
+
+    @Override
+    public TrafficStatsRateLimitCacheConfig getRateLimitCacheConfig() {
+        // Build a per uid config for the client based on the checking result.
+        final TrafficStatsRateLimitCacheConfig config =
+                new TrafficStatsRateLimitCacheConfig.Builder()
+                        .setIsCacheEnabled(useClientSideCache(Binder.getCallingUid()))
+                        .setExpiryDurationMs(
+                                mTrafficStatsRateLimitCacheClientSideConfig.expiryDurationMs)
+                        .setMaxEntries(mTrafficStatsRateLimitCacheClientSideConfig.maxEntries)
+                        .build();
+        return config;
     }
 
     private NetworkStats.Entry getProviderIfaceStats(@Nullable String iface) {
@@ -2992,12 +3072,17 @@
             } catch (IOException e) {
                 pw.println("(failed to dump FastDataInput counters)");
             }
-            pw.print("trafficstats.cache.alwaysuse", mAlwaysUseTrafficStatsRateLimitCache);
+            pw.print("trafficstats.service.cache.isenabled",
+                    mIsTrafficStatsServiceRateLimitCacheEnabled);
             pw.println();
             pw.print(TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
                     mTrafficStatsRateLimitCacheExpiryDuration);
             pw.println();
-            pw.print(TRAFFIC_STATS_CACHE_MAX_ENTRIES_NAME, mTrafficStatsRateLimitCacheMaxEntries);
+            pw.print(TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+            pw.println();
+            pw.print("trafficstats.client.cache.config",
+                    mTrafficStatsRateLimitCacheClientSideConfig);
             pw.println();
 
             pw.decreaseIndent();
diff --git a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
index ca97d07..4f99d1b 100644
--- a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
+++ b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
@@ -19,9 +19,8 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.NetworkStats;
-import android.util.LruCache;
 
-import com.android.internal.annotations.GuardedBy;
+import com.android.net.module.util.LruCacheWithExpiry;
 
 import java.time.Clock;
 import java.util.Objects;
@@ -30,10 +29,12 @@
 /**
  * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
  * with an adjustable expiry duration to manage data freshness.
+ *
+ * @deprecated Use {@link LruCacheWithExpiry} instead.
  */
-class TrafficStatsRateLimitCache {
-    private final Clock mClock;
-    private final long mExpiryDurationMs;
+// TODO: Remove this when service side rate limit cache solution is removed.
+class TrafficStatsRateLimitCache extends
+        LruCacheWithExpiry<TrafficStatsRateLimitCache.TrafficStatsCacheKey, NetworkStats.Entry> {
 
     /**
      * Constructs a new {@link TrafficStatsRateLimitCache} with the specified expiry duration.
@@ -43,19 +44,17 @@
      * @param maxSize Maximum number of entries.
      */
     TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs, int maxSize) {
-        mClock = clock;
-        mExpiryDurationMs = expiryDurationMs;
-        mMap = new LruCache<>(maxSize);
+        super(()-> clock.millis(), expiryDurationMs, maxSize, it -> !it.isEmpty());
     }
 
-    private static class TrafficStatsCacheKey {
+    public static class TrafficStatsCacheKey {
         @Nullable
-        public final String iface;
-        public final int uid;
+        private final String mIface;
+        private final int mUid;
 
         TrafficStatsCacheKey(@Nullable String iface, int uid) {
-            this.iface = iface;
-            this.uid = uid;
+            this.mIface = iface;
+            this.mUid = uid;
         }
 
         @Override
@@ -63,29 +62,15 @@
             if (this == o) return true;
             if (!(o instanceof TrafficStatsCacheKey)) return false;
             TrafficStatsCacheKey that = (TrafficStatsCacheKey) o;
-            return uid == that.uid && Objects.equals(iface, that.iface);
+            return mUid == that.mUid && Objects.equals(mIface, that.mIface);
         }
 
         @Override
         public int hashCode() {
-            return Objects.hash(iface, uid);
+            return Objects.hash(mIface, mUid);
         }
     }
 
-    private static class TrafficStatsCacheValue {
-        public final long timestamp;
-        @NonNull
-        public final NetworkStats.Entry entry;
-
-        TrafficStatsCacheValue(long timestamp, NetworkStats.Entry entry) {
-            this.timestamp = timestamp;
-            this.entry = entry;
-        }
-    }
-
-    @GuardedBy("mMap")
-    private final LruCache<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap;
-
     /**
      * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
      *
@@ -95,16 +80,7 @@
      */
     @Nullable
     NetworkStats.Entry get(String iface, int uid) {
-        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
-        synchronized (mMap) { // Synchronize for thread-safety
-            final TrafficStatsCacheValue value = mMap.get(key);
-            if (value != null && !isExpired(value.timestamp)) {
-                return value.entry;
-            } else {
-                mMap.remove(key); // Remove expired entries
-                return null;
-            }
-        }
+        return super.get(new TrafficStatsCacheKey(iface, uid));
     }
 
     /**
@@ -122,19 +98,7 @@
     @Nullable
     NetworkStats.Entry getOrCompute(String iface, int uid,
             @NonNull Supplier<NetworkStats.Entry> supplier) {
-        synchronized (mMap) {
-            final NetworkStats.Entry cachedValue = get(iface, uid);
-            if (cachedValue != null) {
-                return cachedValue;
-            }
-
-            // Entry not found or expired, compute it
-            final NetworkStats.Entry computedEntry = supplier.get();
-            if (computedEntry != null && !computedEntry.isEmpty()) {
-                put(iface, uid, computedEntry);
-            }
-            return computedEntry;
-        }
+        return super.getOrCompute(new TrafficStatsCacheKey(iface, uid), supplier);
     }
 
     /**
@@ -145,23 +109,7 @@
      * @param entry The {@link NetworkStats.Entry} to store in the cache.
      */
     void put(String iface, int uid, @NonNull final NetworkStats.Entry entry) {
-        Objects.requireNonNull(entry);
-        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
-        synchronized (mMap) { // Synchronize for thread-safety
-            mMap.put(key, new TrafficStatsCacheValue(mClock.millis(), entry));
-        }
+        super.put(new TrafficStatsCacheKey(iface, uid), entry);
     }
 
-    /**
-     * Clear the cache.
-     */
-    void clear() {
-        synchronized (mMap) {
-            mMap.evictAll();
-        }
-    }
-
-    private boolean isExpired(long timestamp) {
-        return mClock.millis() > timestamp + mExpiryDurationMs;
-    }
 }
diff --git a/service/Android.bp b/service/Android.bp
index 94061a4..c4e2ef0 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,6 +124,7 @@
         "libmodules-utils-build",
         "libnetjniutils",
         "libnet_utils_device_common_bpfjni",
+        "libserviceconnectivityjni",
         "netd_aidl_interface-lateststable-ndk",
     ],
     shared_libs: [
@@ -206,6 +206,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__",
@@ -251,7 +252,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
@@ -310,7 +315,7 @@
     apex_available: ["com.android.tethering"],
 }
 
-genrule {
+java_genrule {
     name: "connectivity-jarjar-rules",
     defaults: ["jarjar-rules-combine-defaults"],
     srcs: [
@@ -337,6 +342,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",
     ],
@@ -346,6 +352,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/Android.bp b/service/ServiceConnectivityResources/Android.bp
index 2621256..be9b2b5 100644
--- a/service/ServiceConnectivityResources/Android.bp
+++ b/service/ServiceConnectivityResources/Android.bp
@@ -33,6 +33,7 @@
         "com.android.tethering",
     ],
     certificate: ":com.android.connectivity.resources.certificate",
+    updatable: true,
 }
 
 android_app_certificate {
diff --git a/service/ServiceConnectivityResources/res/values-eu/strings.xml b/service/ServiceConnectivityResources/res/values-eu/strings.xml
index 81d8ddb..5a0a9d4 100644
--- a/service/ServiceConnectivityResources/res/values-eu/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-eu/strings.xml
@@ -18,7 +18,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="connectivityResourcesAppLabel" msgid="2476261877900882974">"Sistemaren konexio-baliabideak"</string>
-    <string name="wifi_available_sign_in" msgid="8041178343789805553">"Hasi saioa Wi-Fi sarean"</string>
+    <string name="wifi_available_sign_in" msgid="8041178343789805553">"Hasi saioa wifi-sarean"</string>
     <string name="network_available_sign_in" msgid="2622520134876355561">"Hasi saioa sarean"</string>
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
     <skip />
diff --git a/service/ServiceConnectivityResources/res/values-sw/strings.xml b/service/ServiceConnectivityResources/res/values-sw/strings.xml
index 29ec013..9ff9ada 100644
--- a/service/ServiceConnectivityResources/res/values-sw/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-sw/strings.xml
@@ -25,7 +25,7 @@
     <string name="mobile_network_available_no_internet" msgid="1000871587359324217">"Hakuna intaneti"</string>
     <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"Huenda data ya <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> imeisha. Gusa ili upate chaguo."</string>
     <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"Huenda data yako imeisha. Gusa ili upate chaguo."</string>
-    <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> haina uwezo wa kufikia intaneti"</string>
+    <string name="wifi_no_internet" msgid="1326348603404555475">"<xliff:g id="NETWORK_SSID">%1$s</xliff:g> haina intaneti"</string>
     <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"Gusa ili upate chaguo"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"Mtandao wa simu hauna uwezo wa kufikia intaneti"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"Mtandao hauna uwezo wa kufikia intaneti"</string>
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index 4027038..d1d9e52 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -71,4 +71,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..7ac86aa 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -52,6 +52,8 @@
             <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/onload.cpp b/service/jni/onload.cpp
index bb70d4f..f87470d 100644
--- a/service/jni/onload.cpp
+++ b/service/jni/onload.cpp
@@ -21,11 +21,12 @@
 
 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_ServiceConnectivityJni(JNIEnv *env,
+                                                      char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
     JNIEnv *env;
@@ -34,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;
     }
@@ -56,6 +53,12 @@
         }
     }
 
+    if (register_com_android_net_module_util_ServiceConnectivityJni(
+            env, "android/net/connectivity/com/android/net/module/util/"
+                 "ServiceConnectivityJni") < 0) {
+      return JNI_ERR;
+    }
+
     return JNI_VERSION_1_6;
 }
 
diff --git a/service/libconnectivity/Android.bp b/service/libconnectivity/Android.bp
index 3a72134..9bfe3a9 100644
--- a/service/libconnectivity/Android.bp
+++ b/service/libconnectivity/Android.bp
@@ -42,6 +42,7 @@
     ],
     llndk: {
         symbol_file: "libconnectivity_native.map.txt",
+        moved_to_apex: true,
     },
     stubs: {
         symbol_file: "libconnectivity_native.map.txt",
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
old mode 100755
new mode 100644
index cb62ae1..18801f0
--- 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,6 +109,8 @@
 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_UNSET;
+import static android.net.NetworkCapabilities.RES_ID_MATCH_ALL_RESERVATIONS;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
@@ -121,6 +124,9 @@
 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;
@@ -145,10 +151,13 @@
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermissionOr;
 import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf;
 import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
+import static com.android.server.connectivity.ConnectivityFlags.CELLULAR_DATA_INACTIVITY_TIMEOUT;
 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.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.REQUEST_RESTRICTED_WIFI;
+import static com.android.server.connectivity.ConnectivityFlags.WIFI_DATA_INACTIVITY_TIMEOUT;
 
 import android.Manifest;
 import android.annotation.CheckResult;
@@ -192,6 +201,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;
@@ -1610,6 +1620,18 @@
                     connectivityServiceInternalHandler);
         }
 
+        /** Returns the data inactivity timeout to be used for cellular networks */
+        public int getDefaultCellularDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING_BOOT,
+                    CELLULAR_DATA_INACTIVITY_TIMEOUT, 10);
+        }
+
+        /** Returns the data inactivity timeout to be used for WiFi networks */
+        public int getDefaultWifiDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING_BOOT,
+                    WIFI_DATA_INACTIVITY_TIMEOUT, 15);
+        }
+
         /**
          * @see DeviceConfigUtils#isTetheringFeatureEnabled
          */
@@ -1958,8 +1980,13 @@
         // But reading the trunk stable flags from mainline modules is not supported yet.
         // So enabling this feature on V+ release.
         mTrackMultiNetworkActivities = mDeps.isAtLeastV();
+        final int defaultCellularDataInactivityTimeout =
+                mDeps.getDefaultCellularDataInactivityTimeout();
+        final int defaultWifiDataInactivityTimeout =
+                mDeps.getDefaultWifiDataInactivityTimeout();
         mNetworkActivityTracker = new LegacyNetworkActivityTracker(mContext, mNetd, mHandler,
-                mTrackMultiNetworkActivities);
+                mTrackMultiNetworkActivities, defaultCellularDataInactivityTimeout,
+                defaultWifiDataInactivityTimeout);
 
         final NetdCallback netdCallback = new NetdCallback();
         try {
@@ -2028,7 +2055,8 @@
             mCdmps = null;
         }
 
-        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+        mRoutingCoordinatorService =
+                new RoutingCoordinatorService(netd, this::getAllNetworks, mContext);
         mMulticastRoutingCoordinatorService =
                 mDeps.makeMulticastRoutingCoordinatorService(mHandler);
 
@@ -5530,7 +5558,7 @@
         }
 
         // Delayed teardown.
-        if (nai.isCreated()) {
+        if (nai.isCreated() && !nai.isDestroyed()) {
             try {
                 mNetd.networkSetPermissionForNetwork(nai.network.netId, INetd.PERMISSION_SYSTEM);
             } catch (RemoteException e) {
@@ -6002,12 +6030,10 @@
             // TODO : The only way out of this is to diff old defaults and new defaults, and only
             // remove ranges for those requests that won't have a replacement
             final NetworkAgentInfo satisfier = nri.getSatisfier();
-            if (null != satisfier && !satisfier.isDestroyed()) {
+            if (null != satisfier) {
                 try {
-                    mNetd.networkRemoveUidRangesParcel(new NativeUidRangeConfig(
-                            satisfier.network.getNetId(),
-                            toUidRangeStableParcels(nri.getUids()),
-                            nri.getPreferenceOrderForNetd()));
+                    modifyNetworkUidRanges(false /* add */, satisfier, nri.getUids(),
+                            nri.getPreferenceOrderForNetd());
                 } catch (RemoteException e) {
                     loge("Exception setting network preference default network", e);
                 }
@@ -6321,8 +6347,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;
@@ -6362,6 +6400,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
@@ -6371,6 +6458,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() {
@@ -6672,7 +6766,7 @@
                     final NetworkOfferInfo offer =
                             findNetworkOfferInfoByCallback((INetworkOfferCallback) msg.obj);
                     if (null != offer) {
-                        handleUnregisterNetworkOffer(offer);
+                        handleUnregisterNetworkOffer(offer, true /* releaseReservations */);
                     }
                     break;
                 }
@@ -7589,17 +7683,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");
+        }
     }
 
     /**
@@ -7710,6 +7810,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
@@ -7729,7 +7851,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;
@@ -7763,7 +7885,7 @@
                 @NetworkCallback.Flag int callbackFlags,
                 @Nullable String callingAttributionTag, int declaredMethodsFlags) {
             super();
-            ensureAllNetworkRequestsHaveType(r);
+            ensureAllNetworkRequestsHaveSupportedType(r);
             mRequests = initializeRequests(r);
             mNetworkRequestForCallback = requestForCallback;
             mMessenger = m;
@@ -7783,7 +7905,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();
@@ -8069,6 +8191,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
@@ -8120,6 +8250,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));
         }
@@ -8324,6 +8455,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);
@@ -8762,7 +8894,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));
     }
@@ -8805,6 +8937,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),
@@ -8843,7 +8980,7 @@
             }
         }
         for (final NetworkOfferInfo noi : toRemove) {
-            handleUnregisterNetworkOffer(noi);
+            handleUnregisterNetworkOffer(noi, true /* releaseReservations */);
         }
         if (DBG) log("unregisterNetworkProvider for " + npi.name);
     }
@@ -9121,11 +9258,7 @@
     }
 
     private void ensureRunningOnConnectivityServiceThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
     }
 
     @VisibleForTesting
@@ -9280,7 +9413,7 @@
 
         @Override
         public void binderDied() {
-            mHandler.post(() -> handleUnregisterNetworkOffer(this));
+            mHandler.post(() -> handleUnregisterNetworkOffer(this, true /* releaseReservations */));
         }
     }
 
@@ -9291,6 +9424,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
@@ -9307,19 +9452,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) {
@@ -9330,7 +9518,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);
@@ -9340,6 +9529,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 */);
     }
 
@@ -10270,8 +10471,7 @@
         return stableRanges;
     }
 
-    private void maybeCloseSockets(NetworkAgentInfo nai, Set<UidRange> ranges,
-            UidRangeParcel[] uidRangeParcels, int[] exemptUids) {
+    private void maybeCloseSockets(NetworkAgentInfo nai, Set<UidRange> ranges, int[] exemptUids) {
         if (nai.isVPN() && !nai.networkAgentConfig.allowBypass) {
             try {
                 if (mDeps.isAtLeastU()) {
@@ -10281,7 +10481,7 @@
                     }
                     mDeps.destroyLiveTcpSockets(UidRange.toIntRanges(ranges), exemptUidSet);
                 } else {
-                    mNetd.socketDestroy(uidRangeParcels, exemptUids);
+                    mNetd.socketDestroy(toUidRangeStableParcels(ranges), exemptUids);
                 }
             } catch (Exception e) {
                 loge("Exception in socket destroy: ", e);
@@ -10289,6 +10489,28 @@
         }
     }
 
+    private void modifyNetworkUidRanges(boolean add, NetworkAgentInfo nai, UidRangeParcel[] ranges,
+            int preference) throws RemoteException {
+        // UID ranges can be added or removed to a network that has already been destroyed (e.g., if
+        // the network disconnects, or a a multilayer request is filed after
+        // unregisterAfterReplacement is called).
+        if (nai.isDestroyed()) {
+            return;
+        }
+        final NativeUidRangeConfig config = new NativeUidRangeConfig(nai.network.netId,
+                ranges, preference);
+        if (add) {
+            mNetd.networkAddUidRangesParcel(config);
+        } else {
+            mNetd.networkRemoveUidRangesParcel(config);
+        }
+    }
+
+    private void modifyNetworkUidRanges(boolean add, NetworkAgentInfo nai, Set<UidRange> uidRanges,
+            int preference) throws RemoteException {
+        modifyNetworkUidRanges(add, nai, toUidRangeStableParcels(uidRanges), preference);
+    }
+
     private void updateVpnUidRanges(boolean add, NetworkAgentInfo nai, Set<UidRange> uidRanges) {
         int[] exemptUids = new int[2];
         // TODO: Excluding VPN_UID is necessary in order to not to kill the TCP connection used
@@ -10296,24 +10518,17 @@
         // starting a legacy VPN, and remove VPN_UID here. (b/176542831)
         exemptUids[0] = VPN_UID;
         exemptUids[1] = nai.networkCapabilities.getOwnerUid();
-        UidRangeParcel[] ranges = toUidRangeStableParcels(uidRanges);
 
         // Close sockets before modifying uid ranges so that RST packets can reach to the server.
-        maybeCloseSockets(nai, uidRanges, ranges, exemptUids);
+        maybeCloseSockets(nai, uidRanges, exemptUids);
         try {
-            if (add) {
-                mNetd.networkAddUidRangesParcel(new NativeUidRangeConfig(
-                        nai.network.netId, ranges, PREFERENCE_ORDER_VPN));
-            } else {
-                mNetd.networkRemoveUidRangesParcel(new NativeUidRangeConfig(
-                        nai.network.netId, ranges, PREFERENCE_ORDER_VPN));
-            }
+            modifyNetworkUidRanges(add, nai, uidRanges, PREFERENCE_ORDER_VPN);
         } catch (Exception e) {
             loge("Exception while " + (add ? "adding" : "removing") + " uid ranges " + uidRanges +
                     " on netId " + nai.network.netId + ". " + e);
         }
         // Close sockets that established connection while requesting netd.
-        maybeCloseSockets(nai, uidRanges, ranges, exemptUids);
+        maybeCloseSockets(nai, uidRanges, exemptUids);
     }
 
     private boolean isProxySetOnAnyDefaultNetwork() {
@@ -10427,16 +10642,12 @@
         toAdd.removeAll(prevUids);
         try {
             if (!toAdd.isEmpty()) {
-                mNetd.networkAddUidRangesParcel(new NativeUidRangeConfig(
-                        nai.network.netId,
-                        intsToUidRangeStableParcels(toAdd),
-                        PREFERENCE_ORDER_IRRELEVANT_BECAUSE_NOT_DEFAULT));
+                modifyNetworkUidRanges(true /* add */, nai, intsToUidRangeStableParcels(toAdd),
+                        PREFERENCE_ORDER_IRRELEVANT_BECAUSE_NOT_DEFAULT);
             }
             if (!toRemove.isEmpty()) {
-                mNetd.networkRemoveUidRangesParcel(new NativeUidRangeConfig(
-                        nai.network.netId,
-                        intsToUidRangeStableParcels(toRemove),
-                        PREFERENCE_ORDER_IRRELEVANT_BECAUSE_NOT_DEFAULT));
+                modifyNetworkUidRanges(false /* add */, nai, intsToUidRangeStableParcels(toRemove),
+                        PREFERENCE_ORDER_IRRELEVANT_BECAUSE_NOT_DEFAULT);
             }
         } catch (ServiceSpecificException e) {
             // Has the interface disappeared since the network was built ?
@@ -10548,9 +10759,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) {
@@ -10562,6 +10773,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;
         }
@@ -10569,14 +10784,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(
@@ -10791,16 +11016,12 @@
                         + " any applications to set as the default." + nri);
             }
             if (null != newDefaultNetwork) {
-                mNetd.networkAddUidRangesParcel(new NativeUidRangeConfig(
-                        newDefaultNetwork.network.getNetId(),
-                        toUidRangeStableParcels(nri.getUids()),
-                        nri.getPreferenceOrderForNetd()));
+                modifyNetworkUidRanges(true /* add */, newDefaultNetwork, nri.getUids(),
+                        nri.getPreferenceOrderForNetd());
             }
             if (null != oldDefaultNetwork) {
-                mNetd.networkRemoveUidRangesParcel(new NativeUidRangeConfig(
-                        oldDefaultNetwork.network.getNetId(),
-                        toUidRangeStableParcels(nri.getUids()),
-                        nri.getPreferenceOrderForNetd()));
+                modifyNetworkUidRanges(false /* add */, oldDefaultNetwork, nri.getUids(),
+                        nri.getPreferenceOrderForNetd());
             }
         } catch (RemoteException | ServiceSpecificException e) {
             loge("Exception setting app default network", e);
@@ -13026,6 +13247,8 @@
         // Key is netId. Value is configured idle timer information.
         private final SparseArray<IdleTimerParams> mActiveIdleTimers = new SparseArray<>();
         private final boolean mTrackMultiNetworkActivities;
+        private final int mDefaultCellularDataInactivityTimeout;
+        private final int mDefaultWifiDataInactivityTimeout;
         // Store netIds of Wi-Fi networks whose idletimers report that they are active
         private final Set<Integer> mActiveWifiNetworks = new ArraySet<>();
         // Store netIds of cellular networks whose idletimers report that they are active
@@ -13042,18 +13265,18 @@
         }
 
         LegacyNetworkActivityTracker(@NonNull Context context, @NonNull INetd netd,
-                @NonNull Handler handler, boolean trackMultiNetworkActivities) {
+                @NonNull Handler handler, boolean trackMultiNetworkActivities,
+                int defaultCellularDataInactivityTimeout, int defaultWifiDataInactivityTimeout) {
             mContext = context;
             mNetd = netd;
             mHandler = handler;
             mTrackMultiNetworkActivities = trackMultiNetworkActivities;
+            mDefaultCellularDataInactivityTimeout = defaultCellularDataInactivityTimeout;
+            mDefaultWifiDataInactivityTimeout = defaultWifiDataInactivityTimeout;
         }
 
         private void ensureRunningOnConnectivityServiceThread() {
-            if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-                throw new IllegalStateException("Not running on ConnectivityService thread: "
-                                + Thread.currentThread().getName());
-            }
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         }
 
         /**
@@ -13249,13 +13472,13 @@
                     NetworkCapabilities.TRANSPORT_CELLULAR)) {
                 timeout = Settings.Global.getInt(mContext.getContentResolver(),
                         ConnectivitySettingsManager.DATA_ACTIVITY_TIMEOUT_MOBILE,
-                        10);
+                        mDefaultCellularDataInactivityTimeout);
                 type = NetworkCapabilities.TRANSPORT_CELLULAR;
             } else if (networkAgent.networkCapabilities.hasTransport(
                     NetworkCapabilities.TRANSPORT_WIFI)) {
                 timeout = Settings.Global.getInt(mContext.getContentResolver(),
                         ConnectivitySettingsManager.DATA_ACTIVITY_TIMEOUT_WIFI,
-                        15);
+                        mDefaultWifiDataInactivityTimeout);
                 type = NetworkCapabilities.TRANSPORT_WIFI;
             } else {
                 return false; // do not track any other networks
@@ -13379,6 +13602,12 @@
 
         public void dump(IndentingPrintWriter pw) {
             pw.print("mTrackMultiNetworkActivities="); pw.println(mTrackMultiNetworkActivities);
+
+            pw.print("mDefaultCellularDataInactivityTimeout=");
+            pw.println(mDefaultCellularDataInactivityTimeout);
+            pw.print("mDefaultWifiDataInactivityTimeout=");
+            pw.println(mDefaultWifiDataInactivityTimeout);
+
             pw.print("mIsDefaultNetworkActive="); pw.println(mIsDefaultNetworkActive);
             pw.print("mDefaultNetwork="); pw.println(mDefaultNetwork);
             pw.println("Idle timers:");
diff --git a/service/src/com/android/server/L2capNetworkProvider.java b/service/src/com/android/server/L2capNetworkProvider.java
new file mode 100644
index 0000000..c5ec9ee
--- /dev/null
+++ b/service/src/com/android/server/L2capNetworkProvider.java
@@ -0,0 +1,244 @@
+/*
+ * 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.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN;
+import static android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_ANY;
+import static android.net.L2capNetworkSpecifier.PSM_ANY;
+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.content.pm.PackageManager.FEATURE_BLUETOOTH_LE;
+
+import android.annotation.Nullable;
+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.net.NetworkSpecifier;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Map;
+
+
+public class L2capNetworkProvider {
+    private static final String TAG = L2capNetworkProvider.class.getSimpleName();
+    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 Map<Integer, ReservedServerOffer> mReservedServerOffers = new ArrayMap<>();
+
+    /**
+     * 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 {
+        // TODO: ensure that once the incoming request is satisfied, the blanket offer does not get
+        // unneeded. This means the blanket offer must always outscore the reserved offer. This
+        // might require setting the blanket offer as setTransportPrimary().
+        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 {
+            final L2capNetworkSpecifier l2capNetworkSpecifier = new L2capNetworkSpecifier.Builder()
+                    .setRole(ROLE_SERVER)
+                    .build();
+            NetworkCapabilities caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                    .addTransportType(TRANSPORT_BLUETOOTH)
+                    // TODO: consider removing NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED.
+                    .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)
+                    .setNetworkSpecifier(l2capNetworkSpecifier)
+                    .build();
+            caps.setReservationId(RES_ID_MATCH_ALL_RESERVATIONS);
+            CAPABILITIES = caps;
+        }
+
+        // TODO: consider moving this into L2capNetworkSpecifier as #isValidServerReservation().
+        private boolean isValidL2capSpecifier(@Nullable NetworkSpecifier spec) {
+            if (spec == null) return false;
+            // If spec is not null, L2capNetworkSpecifier#canBeSatisfiedBy() guarantees the
+            // specifier is of type L2capNetworkSpecifier.
+            final L2capNetworkSpecifier l2capSpec = (L2capNetworkSpecifier) spec;
+
+            // The ROLE_SERVER offer can be satisfied by a ROLE_ANY request.
+            if (l2capSpec.getRole() != ROLE_SERVER) return false;
+
+            // HEADER_COMPRESSION_ANY is never valid in a request.
+            if (l2capSpec.getHeaderCompression() == HEADER_COMPRESSION_ANY) return false;
+
+            // remoteAddr must be null for ROLE_SERVER requests.
+            if (l2capSpec.getRemoteAddress() != null) return false;
+
+            // reservation must allocate a PSM, so only PSM_ANY can be passed.
+            if (l2capSpec.getPsm() != PSM_ANY) return false;
+
+            return true;
+        }
+
+        @Override
+        public void onNetworkNeeded(NetworkRequest request) {
+            Log.d(TAG, "New reservation request: " + request);
+            if (!isValidL2capSpecifier(request.getNetworkSpecifier())) {
+                Log.w(TAG, "Ignoring invalid reservation request: " + request);
+                return;
+            }
+
+            final NetworkCapabilities reservationCaps = request.networkCapabilities;
+            final ReservedServerOffer reservedOffer = new ReservedServerOffer(reservationCaps);
+
+            final NetworkCapabilities reservedCaps = reservedOffer.getReservedCapabilities();
+            mProvider.registerNetworkOffer(SCORE, reservedCaps, mHandler::post, reservedOffer);
+            mReservedServerOffers.put(request.requestId, reservedOffer);
+        }
+
+        @Override
+        public void onNetworkUnneeded(NetworkRequest request) {
+            if (!mReservedServerOffers.containsKey(request.requestId)) {
+                return;
+            }
+
+            final ReservedServerOffer reservedOffer = mReservedServerOffers.get(request.requestId);
+            // Note that the reserved offer gets torn down when the reservation goes away, even if
+            // there are lingering requests.
+            reservedOffer.tearDown();
+            mProvider.unregisterNetworkOffer(reservedOffer);
+        }
+    }
+
+    private class ReservedServerOffer implements NetworkOfferCallback {
+        private final boolean mUseHeaderCompression;
+        private final int mPsm;
+        private final NetworkCapabilities mReservedCapabilities;
+
+        public ReservedServerOffer(NetworkCapabilities reservationCaps) {
+            // getNetworkSpecifier() is guaranteed to return a non-null L2capNetworkSpecifier.
+            final L2capNetworkSpecifier reservationSpec =
+                    (L2capNetworkSpecifier) reservationCaps.getNetworkSpecifier();
+            mUseHeaderCompression =
+                    reservationSpec.getHeaderCompression() == HEADER_COMPRESSION_6LOWPAN;
+
+            // TODO: open BluetoothServerSocket and allocate a PSM.
+            mPsm = 0x80;
+
+            final L2capNetworkSpecifier reservedSpec = new L2capNetworkSpecifier.Builder()
+                    .setRole(ROLE_SERVER)
+                    .setHeaderCompression(reservationSpec.getHeaderCompression())
+                    .setPsm(mPsm)
+                    .build();
+            mReservedCapabilities = new NetworkCapabilities.Builder(reservationCaps)
+                    .setNetworkSpecifier(reservedSpec)
+                    .build();
+        }
+
+        public NetworkCapabilities getReservedCapabilities() {
+            return mReservedCapabilities;
+        }
+
+        @Override
+        public void onNetworkNeeded(NetworkRequest request) {
+            // TODO: implement
+        }
+
+        @Override
+        public void onNetworkUnneeded(NetworkRequest request) {
+            // TODO: implement
+        }
+
+        /**
+         * Called when the reservation goes away and the reserved offer must be torn down.
+         *
+         * This method can be called multiple times.
+         */
+        public void tearDown() {
+            // TODO: implement.
+            // This method can be called multiple times.
+        }
+    }
+
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Get NetworkProvider */
+        public NetworkProvider getNetworkProvider(Context context, Looper looper) {
+            return new NetworkProvider(context, looper, TAG);
+        }
+
+        /** Get the HandlerThread for L2capNetworkProvider to run on */
+        public HandlerThread getHandlerThread() {
+            final HandlerThread thread = new HandlerThread("L2capNetworkProviderThread");
+            thread.start();
+            return thread;
+        }
+    }
+
+    public L2capNetworkProvider(Context context) {
+        this(new Dependencies(), context);
+    }
+
+    @VisibleForTesting
+    public L2capNetworkProvider(Dependencies deps, Context context) {
+        mDeps = deps;
+        mContext = context;
+        mHandlerThread = mDeps.getHandlerThread();
+        mHandler = new Handler(mHandlerThread.getLooper());
+        mProvider = mDeps.getNetworkProvider(context, mHandlerThread.getLooper());
+        mBlanketOffer = new BlanketReservationOffer();
+    }
+
+    /**
+     * Start L2capNetworkProvider.
+     *
+     * Called on CS Handler thread.
+     */
+    public void start() {
+        final PackageManager pm = mContext.getPackageManager();
+        if (pm.hasSystemFeature(FEATURE_BLUETOOTH_LE)) {
+            mContext.getSystemService(ConnectivityManager.class).registerNetworkProvider(mProvider);
+            mProvider.registerNetworkOffer(BlanketReservationOffer.SCORE,
+                    BlanketReservationOffer.CAPABILITIES, mHandler::post, mBlanketOffer);
+        }
+    }
+}
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/AutomaticOnOffKeepaliveTracker.java b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
index 31108fc..c7d96de 100644
--- a/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
+++ b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
@@ -25,6 +25,7 @@
 import static android.system.OsConstants.SOL_SOCKET;
 import static android.system.OsConstants.SO_SNDTIMEO;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.net.module.util.netlink.NetlinkUtils.IO_TIMEOUT_MS;
 
 import android.annotation.IntDef;
@@ -440,7 +441,7 @@
      */
     @Nullable
     public AutomaticOnOffKeepalive getKeepaliveForBinder(@NonNull final IBinder token) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
 
         return CollectionUtils.findFirst(mAutomaticOnOffKeepalives,
                 it -> it.mCallback.asBinder().equals(token));
@@ -580,7 +581,7 @@
     }
 
     private void cleanupAutoOnOffKeepalive(@NonNull final AutomaticOnOffKeepalive autoKi) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         mKeepaliveStatsTracker.onStopKeepalive(autoKi.getNetwork(), autoKi.mKi.getSlot());
         autoKi.close();
         if (null != autoKi.mAlarmListener) mAlarmManager.cancel(autoKi.mAlarmListener);
@@ -693,7 +694,7 @@
      * This should be only be called in ConnectivityService handler thread.
      */
     public void dump(IndentingPrintWriter pw) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         mKeepaliveTracker.dump(pw);
         // Reading DeviceConfig will check if the calling uid and calling package name are the same.
         // Clear calling identity to align the calling uid and package so that it won't fail if cts
@@ -771,7 +772,7 @@
     private boolean isAnyTcpSocketConnectedForFamily(FileDescriptor fd, int family, int networkMark,
             int networkMask)
             throws ErrnoException, InterruptedIOException {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         // Build SocketDiag messages and cache it.
         if (mSockDiagMsg.get(family) == null) {
             mSockDiagMsg.put(family, InetDiagMessage.buildInetDiagReqForAliveTcpSockets(family));
@@ -843,13 +844,6 @@
         return mark;
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mConnectivityServiceHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     private long getTcpPollingIntervalMs(@NonNull AutomaticOnOffKeepalive ki) {
         final boolean useLowTimer = mTestLowTcpPollingTimerUntilMs > System.currentTimeMillis();
         // Adjust the polling interval to be smaller than the keepalive delay to preserve
diff --git a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
index f5fa4fb..14a935f 100644
--- a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
+++ b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
@@ -19,6 +19,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK;
 
 import android.annotation.NonNull;
@@ -168,7 +169,7 @@
     private void simConfigChanged() {
         //  If mRequestRestrictedWifiEnabled is false, constructor calls simConfigChanged
         if (mRequestRestrictedWifiEnabled) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
         }
         synchronized (mLock) {
             unregisterCarrierPrivilegesListeners();
@@ -212,7 +213,7 @@
         public void onCarrierPrivilegesChanged(
                 @NonNull List<String> privilegedPackageNames,
                 @NonNull int[] privilegedUids) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
             if (mUseCallbacksForServiceChanged) return;
             // Re-trigger the synchronous check (which is also very cheap due
             // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -223,7 +224,7 @@
         @Override
         public void onCarrierServiceChanged(@Nullable final String carrierServicePackageName,
                 final int carrierServiceUid) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
             if (!mUseCallbacksForServiceChanged) {
                 // Re-trigger the synchronous check (which is also very cheap due
                 // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -465,13 +466,6 @@
         }
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     public void dump(IndentingPrintWriter pw) {
         pw.println("CarrierPrivilegeAuthenticator:");
         pw.println("mRequestRestrictedWifiEnabled = " + mRequestRestrictedWifiEnabled);
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index df87316..136ea81 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -26,6 +26,11 @@
  */
 public final class ConnectivityFlags {
     /**
+     * Boot namespace for this module. Values from this should only be read at boot.
+     */
+    public static final String NAMESPACE_TETHERING_BOOT = "tethering_boot";
+
+    /**
      * Minimum module version at which to avoid rematching all requests when a network request is
      * registered, and rematch only the registered requests instead.
      */
@@ -44,6 +49,11 @@
 
     public static final String BACKGROUND_FIREWALL_CHAIN = "background_firewall_chain";
 
+    public static final String CELLULAR_DATA_INACTIVITY_TIMEOUT =
+            "cellular_data_inactivity_timeout";
+
+    public static final String WIFI_DATA_INACTIVITY_TIMEOUT = "wifi_data_inactivity_timeout";
+
     public static final String DELAY_DESTROY_SOCKETS = "delay_destroy_sockets";
 
     public static final String USE_DECLARED_METHODS_FOR_CALLBACKS =
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index 917ad4d..7a008c6 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -43,7 +43,7 @@
     private static final String TAG = ConnectivityNativeService.class.getSimpleName();
 
     private static final String BLOCKED_PORTS_MAP_PATH =
-            "/sys/fs/bpf/net_shared/map_block_blocked_ports_map";
+            "/sys/fs/bpf/netd_shared/map_netd_blocked_ports_map";
 
     private final Context mContext;
 
diff --git a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
index 21dbb45..8acd1c8 100644
--- a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
+++ b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
@@ -18,6 +18,8 @@
 
 import static android.telephony.SubscriptionManager.OnSubscriptionsChangedListener;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
+
 import android.annotation.NonNull;
 import android.content.BroadcastReceiver;
 import android.content.Context;
@@ -466,7 +468,7 @@
             int intervalSeconds,
             int appUid,
             boolean isAutoKeepalive) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         final int keepaliveId = getKeepaliveId(network, slot);
         if (keepaliveId == INVALID_KEEPALIVE_ID) return;
@@ -538,21 +540,21 @@
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been paused. */
     public void onPauseKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         onKeepaliveActive(network, slot, /* keepaliveActive= */ false);
     }
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been resumed. */
     public void onResumeKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         onKeepaliveActive(network, slot, /* keepaliveActive= */ true);
     }
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been stopped. */
     public void onStopKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
 
         final int keepaliveId = getKeepaliveId(network, slot);
@@ -615,7 +617,7 @@
      */
     @VisibleForTesting
     public @NonNull DailykeepaliveInfoReported buildKeepaliveMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         final long timeNow = mDependencies.getElapsedRealtime();
         return buildKeepaliveMetrics(timeNow);
     }
@@ -673,7 +675,7 @@
      */
     @VisibleForTesting
     public @NonNull DailykeepaliveInfoReported buildAndResetMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         final long timeNow = mDependencies.getElapsedRealtime();
 
         final DailykeepaliveInfoReported metrics = buildKeepaliveMetrics(timeNow);
@@ -750,7 +752,7 @@
 
     /** Writes the stored metrics to ConnectivityStatsLog and resets. */
     public void writeAndResetMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         // Keepalive stats use repeated atoms, which are only supported on T+. If written to statsd
         // on S- they will bootloop the system, so they must not be sent on S-. See b/289471411.
         if (!SdkLevel.isAtLeastT()) {
@@ -771,17 +773,10 @@
 
     /** Dump KeepaliveStatsTracker state. */
     public void dump(IndentingPrintWriter pw) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         pw.println("KeepaliveStatsTracker enabled: " + isEnabled());
         pw.increaseIndent();
         pw.println(buildKeepaliveMetrics().toString());
         pw.decreaseIndent();
     }
-
-    private void ensureRunningOnHandlerThread() {
-        if (mConnectivityServiceHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
 }
diff --git a/service/src/com/android/server/connectivity/Nat464Xlat.java b/service/src/com/android/server/connectivity/Nat464Xlat.java
index a979681..37aef22 100644
--- a/service/src/com/android/server/connectivity/Nat464Xlat.java
+++ b/service/src/com/android/server/connectivity/Nat464Xlat.java
@@ -20,6 +20,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 
 import static com.android.net.module.util.CollectionUtils.contains;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -500,7 +501,7 @@
         // Once this code is converted to StateMachine, it will be possible to use deferMessage to
         // ensure it stays in STARTING state until the interfaceLinkStateChanged notification fires,
         // and possibly use a timeout (or provide some guarantees at the lower layer) to address #1.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarting() || !up || !Objects.equals(mIface, iface)) {
             return;
         }
@@ -524,7 +525,7 @@
      * Must be called on the handler thread.
      */
     public void handleInterfaceRemoved(String iface) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!Objects.equals(mIface, iface)) {
             return;
         }
@@ -546,7 +547,7 @@
     @Nullable
     public Inet6Address translateV4toV6(@NonNull Inet4Address addr) {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarted()) return null;
 
         return convertv4ToClatv6(mNat64PrefixInUse, addr);
@@ -574,7 +575,7 @@
     @Nullable
     public Inet6Address getClatv6SrcAddress() {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
 
         return mIPv6Address;
     }
@@ -585,7 +586,7 @@
     @Nullable
     public Inet4Address getClatv4SrcAddress() {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarted()) return null;
 
         final LinkAddress v4Addr = getLinkAddress(mIface);
@@ -594,13 +595,6 @@
         return (Inet4Address) v4Addr.getAddress();
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mNetwork.handler().getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     /**
      * Dump the NAT64 xlat information.
      *
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 76993a6..2686e4a 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.EIO;
+import static android.system.OsConstants.EEXIST;
+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;
@@ -57,9 +63,11 @@
 import android.os.Handler;
 import android.os.IBinder;
 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,7 +76,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;
@@ -573,6 +584,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
@@ -625,6 +640,7 @@
     private final Context mContext;
     private final Handler mHandler;
     private final QosCallbackTracker mQosCallbackTracker;
+    private final INetd mNetd;
 
     private final long mCreationTime;
 
@@ -654,6 +670,7 @@
         mConnServiceDeps = deps;
         setScore(score); // uses members connService, networkCapabilities and networkAgentConfig
         clatd = new Nat464Xlat(this, netd, dnsResolver, deps);
+        mNetd = netd;
         mContext = context;
         mHandler = handler;
         this.factorySerialNumber = factorySerialNumber;
@@ -1138,11 +1155,7 @@
      *         already present.
      */
     public boolean addRequest(NetworkRequest networkRequest) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         NetworkRequest existing = mNetworkRequests.get(networkRequest.requestId);
         if (existing == networkRequest) return false;
         if (existing != null) {
@@ -1161,11 +1174,7 @@
      * Remove the specified request from this network.
      */
     public void removeRequest(int requestId) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         NetworkRequest existing = mNetworkRequests.get(requestId);
         if (existing == null) return;
         updateRequestCounts(REMOVE, existing);
@@ -1187,11 +1196,7 @@
      * network.
      */
     public NetworkRequest requestAt(int index) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         return mNetworkRequests.valueAt(index);
     }
 
@@ -1222,11 +1227,7 @@
      * Returns the number of requests of any type currently satisfied by this network.
      */
     public int numNetworkRequests() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         return mNetworkRequests.size();
     }
 
@@ -1564,6 +1565,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/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/staticlibs/Android.bp b/staticlibs/Android.bp
index 85258f8..0eab6e7 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -32,6 +32,8 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+// This library shouldn't be used anymore (no class should be added), and per-user libraries like
+// net-utils-service-connectivity or net-utils-framework-wifi should be used instead.
 java_library {
     name: "net-utils-device-common",
     srcs: [
@@ -348,7 +350,7 @@
     // TODO: remove "apex_available:platform".
     apex_available: [
         "//apex_available:platform",
-        "com.android.btservices",
+        "com.android.bt",
         "com.android.tethering",
         "com.android.wifi",
     ],
@@ -435,6 +437,7 @@
     sdk_version: "core_platform",
     srcs: [
         "device/com/android/net/module/util/FdEventsReader.java",
+        "device/com/android/net/module/util/HandlerUtils.java",
         "device/com/android/net/module/util/SharedLog.java",
         "framework/com/android/net/module/util/ByteUtils.java",
         "framework/com/android/net/module/util/CollectionUtils.java",
@@ -625,6 +628,31 @@
     visibility: ["//visibility:private"],
 }
 
+// Filegroup to build lib used by IPsec/IKE framework
+// Any class here *must* have a corresponding jarjar rule in the IPsec build rules.
+filegroup {
+    name: "net-utils-framework-ipsec-common-srcs",
+    srcs: [
+        "framework/com/android/net/module/util/HexDump.java",
+    ],
+    path: "framework",
+    visibility: ["//visibility:private"],
+}
+
+java_library {
+    name: "net-utils-framework-ipsec",
+    sdk_version: "module_current",
+    min_sdk_version: "30",
+    srcs: [":net-utils-framework-ipsec-common-srcs"],
+    libs: [
+        "androidx.annotation_annotation",
+    ],
+    visibility: [
+        "//packages/modules/IPsec",
+    ],
+    apex_available: ["com.android.ipsec"],
+}
+
 // Use a file group containing classes necessary for framework-connectivity. The file group should
 // be as small as possible because because the classes end up in the bootclasspath and R8 is not
 // used to remove unused classes.
@@ -645,6 +673,8 @@
     visibility: ["//visibility:private"],
 }
 
+// Sources outside of com.android.net.module.util should not be added because many modules depend on
+// them and need jarjar rules
 filegroup {
     name: "net-utils-all-srcs",
     srcs: [
@@ -697,3 +727,34 @@
     ],
     apex_available: ["com.android.wifi"],
 }
+
+genrule {
+    name: "statslog-framework-connectivity-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module connectivity --javaPackage com.android.net.module.util --javaClass FrameworkConnectivityStatsLog",
+    out: ["com/android/net/module/util/FrameworkConnectivityStatsLog.java"],
+}
+
+java_library {
+    name: "net-utils-service-vcn",
+    sdk_version: "module_current",
+    min_sdk_version: "30",
+    srcs: [
+        "device/com/android/net/module/util/HandlerUtils.java",
+    ],
+    libs: [
+        "framework-annotations-lib",
+    ],
+    visibility: [
+        // TODO: b/374174952 Remove it when VCN modularization is released
+        "//frameworks/base/packages/Vcn/service-b",
+
+        "//packages/modules/Connectivity/service-b",
+    ],
+    apex_available: [
+        // TODO: b/374174952 Remove it when VCN modularization is released
+        "//apex_available:platform",
+
+        "com.android.tethering",
+    ],
+}
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..8b2fe58 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
@@ -149,21 +149,21 @@
     }
 
     /** 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 */);
+    public static void tetherInterface(final INetd netd, int netId, final String iface,
+            final IpPrefix dest) throws RemoteException, ServiceSpecificException {
+        tetherInterface(netd, netId, 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)
+    public static void tetherInterface(final INetd netd, int netId, final String iface,
+            final IpPrefix dest, int maxAttempts, int pollingIntervalMs)
             throws RemoteException, ServiceSpecificException {
         netd.tetherInterfaceAdd(iface);
-        networkAddInterface(netd, iface, maxAttempts, pollingIntervalMs);
+        networkAddInterface(netd, netId, iface, maxAttempts, pollingIntervalMs);
         // Activate a route to dest and IPv6 link local.
-        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+        modifyRoute(netd, ModifyOperation.ADD, netId,
                 new RouteInfo(dest, null, iface, RTN_UNICAST));
-        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+        modifyRoute(netd, ModifyOperation.ADD, netId,
                 new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
     }
 
@@ -174,12 +174,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,
+    private 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) {
@@ -194,37 +194,38 @@
     }
 
     /** Reset interface for tethering. */
-    public static void untetherInterface(final INetd netd, String iface)
+    public static void untetherInterface(final INetd netd, int netId, String iface)
             throws RemoteException, ServiceSpecificException {
         try {
             netd.tetherInterfaceRemove(iface);
         } finally {
-            netd.networkRemoveInterface(INetd.LOCAL_NET_ID, iface);
+            netd.networkRemoveInterface(netId, 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..c2fbb56 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;
@@ -63,6 +62,7 @@
 
     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 +134,7 @@
             }
 
             throw new ServiceSpecificException(EBUSY);
-        }).when(mNetd).networkAddInterface(LOCAL_NET_ID, IFACE);
+        }).when(mNetd).networkAddInterface(TEST_NET_ID, IFACE);
     }
 
     class Counter {
@@ -163,7 +163,7 @@
         setNetworkAddInterfaceOutcome(new ServiceSpecificException(expectedCode), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.tetherInterface(mNetd, TEST_NET_ID, IFACE, TEST_IPPREFIX, 20, 0);
             fail("Expect throw ServiceSpecificException");
         } catch (ServiceSpecificException e) {
             assertEquals(e.errorCode, expectedCode);
@@ -177,7 +177,7 @@
         setNetworkAddInterfaceOutcome(new RemoteException(), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.tetherInterface(mNetd, TEST_NET_ID, IFACE, TEST_IPPREFIX, 20, 0);
             fail("Expect throw RemoteException");
         } catch (RemoteException e) { }
 
@@ -187,18 +187,19 @@
 
     private void verifyNetworkAddInterfaceFails(int expectedTries) throws Exception {
         verify(mNetd).tetherInterfaceAdd(IFACE);
-        verify(mNetd, times(expectedTries)).networkAddInterface(LOCAL_NET_ID, IFACE);
+        verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
         verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
+
         verifyNoMoreInteractions(mNetd);
     }
 
     private void verifyTetherInterfaceSucceeds(int expectedTries) throws Exception {
         setNetworkAddInterfaceOutcome(null, expectedTries);
 
-        NetdUtils.tetherInterface(mNetd, IFACE, TEST_IPPREFIX);
+        NetdUtils.tetherInterface(mNetd, TEST_NET_ID, 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());
+        verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
+        verify(mNetd, times(2)).networkAddRoute(eq(TEST_NET_ID), eq(IFACE), any(), any());
         verifyNoMoreInteractions(mNetd);
         reset(mNetd);
     }
diff --git a/staticlibs/device/com/android/net/module/util/HandlerUtils.java b/staticlibs/device/com/android/net/module/util/HandlerUtils.java
index c620368..991df8f 100644
--- a/staticlibs/device/com/android/net/module/util/HandlerUtils.java
+++ b/staticlibs/device/com/android/net/module/util/HandlerUtils.java
@@ -102,4 +102,37 @@
         if (e != null) throw e;
         return true;
     }
+
+    /**
+     * Ensures that the current running thread is the same as the thread associated with the given
+     * handler.
+     *
+     * @param handler The handler whose thread to compare.
+     * @throws IllegalStateException if the thread associated with the given handler is not the same
+     *                               as the current running thread.
+     * @hide
+     */
+    public static void ensureRunningOnHandlerThread(@NonNull Handler handler) {
+        if (!isRunningOnHandlerThread(handler)) {
+            throw new IllegalStateException(
+                    "Not running on Handler thread: " + Thread.currentThread().getName());
+        }
+    }
+
+    /**
+     * Checks if the current running thread is the same as the thread associated with the given
+     * handler.
+     *
+     * @param handler The handler whose thread to compare.
+     * @return {@code true} if the thread associated with the given handler is the same as the
+     *         current running thread, {@code false} otherwise.
+     *
+     * @hide
+     */
+    public static boolean isRunningOnHandlerThread(@NonNull Handler handler) {
+        if (handler.getLooper().getThread() == Thread.currentThread()) {
+            return true;
+        }
+        return false;
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl b/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl
new file mode 100644
index 0000000..cc1c19c
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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.net.IpPrefix;
+import android.net.LinkAddress;
+
+/** @hide */
+// TODO: b/350630377 - This @Descriptor annotation workaround is to prevent the class from being
+// jarjared which changes the DESCRIPTOR and casues "java.lang.SecurityException: Binder invocation
+// to an incorrect interface" when calling the IPC.
+@Descriptor("value=no.jarjar.com.android.net.module.util.IIpv4PrefixRequest")
+interface IIpv4PrefixRequest {
+    void onIpv4PrefixConflict(in IpPrefix ipPrefix);
+}
diff --git a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
index 72a4a94..7688e6a 100644
--- a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
+++ b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
@@ -16,8 +16,14 @@
 
 package com.android.net.module.util;
 
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 
+import com.android.net.module.util.IIpv4PrefixRequest;
+
 /** @hide */
 // TODO: b/350630377 - This @Descriptor annotation workaround is to prevent the DESCRIPTOR from
 // being jarjared which changes the DESCRIPTOR and casues "java.lang.SecurityException: Binder
@@ -96,4 +102,41 @@
     *         cause of the failure.
     */
     void removeInterfaceForward(in String fromIface, in String toIface);
+
+    /** Update the prefix of an upstream. */
+    void updateUpstreamPrefix(in @nullable LinkProperties lp,
+                              in @nullable NetworkCapabilities nc,
+                              in Network network);
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    void removeUpstreamPrefix(in Network network);
+
+    /** Remove the deprecated upstream networks if any. */
+    void maybeRemoveDeprecatedUpstreams();
+
+   /**
+    * Request an IPv4 address for the downstream. Return the last time used address for the
+    * provided (interfaceType, scope) pair if possible.
+    *
+    * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+    * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+    * @param request a {@link IIpv4PrefixRequest} to report conflicts
+    * @return an IPv4 address allocated for the downstream, could be null
+    */
+    @nullable
+    LinkAddress requestStickyDownstreamAddress(
+            in int interfaceType,
+            in int scope,
+            in IIpv4PrefixRequest request);
+   /**
+    * Request an IPv4 address for the downstream.
+    *
+    * @param request a {@link IIpv4PrefixRequest} to report conflicts
+    * @return an IPv4 address allocated for the downstream, could be null
+    */
+    @nullable
+    LinkAddress requestDownstreamAddress(in IIpv4PrefixRequest request);
+
+    /** Release the IPv4 address allocated for the downstream. */
+    void releaseDownstream(in IIpv4PrefixRequest request);
 }
diff --git a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
new file mode 100644
index 0000000..bb95585
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -0,0 +1,485 @@
+/*
+ * 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.
+ */
+package com.android.net.module.util;
+
+import static android.net.NetworkCapabilities.TRANSPORT_VPN;
+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_WIFI_P2P;
+
+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.Inet4AddressUtils.prefixLengthToV4NetmaskIntHTH;
+
+import static java.util.Arrays.asList;
+
+import android.content.Context;
+import android.net.IpPrefix;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.os.RemoteException;
+import android.util.ArrayMap;
+
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.PrintWriter;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Random;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * This class coordinate IP addresses conflict problem.
+ *
+ * Tethering downstream IP addresses may conflict with network assigned addresses. This
+ * coordinator is responsible for recording all of network assigned addresses and dispatched
+ * free address to downstream interfaces.
+ *
+ * This class is not thread-safe.
+ * @hide
+ */
+public class PrivateAddressCoordinator {
+    // 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
+    // when tethering is down. Instead tethering would remove all deprecated upstreams from
+    // mUpstreamPrefixMap when tethering is starting. See #maybeRemoveDeprecatedUpstreams().
+    private final ArrayMap<Network, List<IpPrefix>> mUpstreamPrefixMap;
+    // The downstreams are indexed by Ipv4PrefixRequest, which is a wrapper of the Binder object of
+    // IIpv4PrefixRequest.
+    private final ArrayMap<Ipv4PrefixRequest, LinkAddress> mDownstreams;
+    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
+    private static final String LEGACY_BLUETOOTH_IFACE_ADDRESS = "192.168.44.1/24";
+    private final List<IpPrefix> mTetheringPrefixes;
+    // A supplier that returns ConnectivityManager#getAllNetworks.
+    private final Supplier<Network[]> mGetAllNetworksSupplier;
+    private final Dependencies mDeps;
+    // keyed by downstream type(TetheringManager.TETHERING_*).
+    private final ArrayMap<AddressKey, LinkAddress> mCachedAddresses;
+    private final Random mRandom;
+
+    /** Capture PrivateAddressCoordinator dependencies for injection. */
+    public static class Dependencies {
+        private final Context mContext;
+
+        Dependencies(Context context) {
+            mContext = context;
+        }
+
+        /**
+         * Check whether or not one specific experimental feature is enabled according to {@link
+         * DeviceConfigUtils}.
+         *
+         * @param featureName The feature's name to look up.
+         * @return true if this feature is enabled, or false if disabled.
+         */
+        public boolean isFeatureEnabled(String featureName) {
+            return DeviceConfigUtils.isTetheringFeatureEnabled(mContext, featureName);
+        }
+    }
+
+    public PrivateAddressCoordinator(Supplier<Network[]> getAllNetworksSupplier, Context context) {
+        this(getAllNetworksSupplier, new Dependencies(context));
+    }
+
+    @VisibleForTesting
+    public PrivateAddressCoordinator(Supplier<Network[]> getAllNetworksSupplier,
+                                     Dependencies deps) {
+        mDownstreams = new ArrayMap<>();
+        mUpstreamPrefixMap = new ArrayMap<>();
+        mGetAllNetworksSupplier = getAllNetworksSupplier;
+        mDeps = deps;
+        mCachedAddresses = new ArrayMap<AddressKey, LinkAddress>();
+        // Reserved static addresses for bluetooth and wifi p2p.
+        mCachedAddresses.put(new AddressKey(TETHERING_BLUETOOTH, CONNECTIVITY_SCOPE_GLOBAL),
+                new LinkAddress(LEGACY_BLUETOOTH_IFACE_ADDRESS));
+        mCachedAddresses.put(new AddressKey(TETHERING_WIFI_P2P, CONNECTIVITY_SCOPE_LOCAL),
+                new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS));
+
+        mTetheringPrefixes = new ArrayList<>(Arrays.asList(new IpPrefix("192.168.0.0/16"),
+            new IpPrefix("172.16.0.0/12"), new IpPrefix("10.0.0.0/8")));
+        mRandom = new Random();
+    }
+
+    /**
+     * Record a new upstream IpPrefix which may conflict with tethering downstreams. The downstreams
+     * will be notified if a conflict is found. When updateUpstreamPrefix is called,
+     * UpstreamNetworkState must have an already populated LinkProperties.
+     */
+    public void updateUpstreamPrefix(
+            final LinkProperties lp, final NetworkCapabilities nc, final Network network) {
+        // Do not support VPN as upstream. Normally, networkCapabilities is not expected to be null,
+        // but just checking to be sure.
+        if (nc != null && nc.hasTransport(TRANSPORT_VPN)) {
+            removeUpstreamPrefix(network);
+            return;
+        }
+
+        final ArrayList<IpPrefix> ipv4Prefixes = getIpv4Prefixes(lp.getAllLinkAddresses());
+        if (ipv4Prefixes.isEmpty()) {
+            removeUpstreamPrefix(network);
+            return;
+        }
+
+        mUpstreamPrefixMap.put(network, ipv4Prefixes);
+        handleMaybePrefixConflict(ipv4Prefixes);
+    }
+
+    private ArrayList<IpPrefix> getIpv4Prefixes(final List<LinkAddress> linkAddresses) {
+        final ArrayList<IpPrefix> list = new ArrayList<>();
+        for (LinkAddress address : linkAddresses) {
+            if (!address.isIpv4()) continue;
+
+            list.add(asIpPrefix(address));
+        }
+
+        return list;
+    }
+
+    private void handleMaybePrefixConflict(final List<IpPrefix> prefixes) {
+        for (Map.Entry<Ipv4PrefixRequest, LinkAddress> entry : mDownstreams.entrySet()) {
+            final Ipv4PrefixRequest request = entry.getKey();
+            final LinkAddress downstream = entry.getValue();
+            final IpPrefix target = asIpPrefix(downstream);
+
+            for (IpPrefix source : prefixes) {
+                if (isConflictPrefix(source, target)) {
+                    try {
+                        request.getRequest().onIpv4PrefixConflict(target);
+                    } catch (RemoteException ignored) {
+                        // ignore
+                    }
+                    break;
+                }
+            }
+        }
+    }
+
+    /** Remove IpPrefix records corresponding to input network. */
+    public void removeUpstreamPrefix(final Network network) {
+        mUpstreamPrefixMap.remove(network);
+    }
+
+    /**
+     * Maybe remove deprecated upstream records, this would be called once tethering started without
+     * any exiting tethered downstream.
+     */
+    public void maybeRemoveDeprecatedUpstreams() {
+        if (mUpstreamPrefixMap.isEmpty()) return;
+
+        // Remove all upstreams that are no longer valid networks
+        final Set<Network> toBeRemoved = new HashSet<>(mUpstreamPrefixMap.keySet());
+        toBeRemoved.removeAll(asList(mGetAllNetworksSupplier.get()));
+
+        mUpstreamPrefixMap.removeAll(toBeRemoved);
+    }
+
+    // TODO: There needs to be a reserveDownstreamAddress() method for the cases where
+    // TetheringRequest has been set a static IPv4 address.
+
+    /**
+     * Request a downstream address for the provided IIpv4PrefixRequest.
+     *
+     * This method will first try to return the last time used address for the provided
+     * (interfaceType, scope) pair if possible. If not, it will pick a random available address and
+     * mark its prefix as in use for the provided IIpv4PrefixRequest.
+     */
+    @Nullable
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, final int scope,
+            IIpv4PrefixRequest request) {
+        final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
+        final AddressKey addrKey = new AddressKey(interfaceType, scope);
+        // This ensures that tethering isn't started on 2 different interfaces with the same type.
+        // Once tethering could support multiple interface with the same type,
+        // TetheringSoftApCallback would need to handle it among others.
+        final LinkAddress cachedAddress = mCachedAddresses.get(addrKey);
+        if (cachedAddress != null && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
+            mDownstreams.put(wrappedRequest, cachedAddress);
+            return cachedAddress;
+        }
+
+        final LinkAddress newAddress = requestDownstreamAddress(request);
+        if (newAddress != null) {
+            mCachedAddresses.put(addrKey, newAddress);
+        }
+        return newAddress;
+    }
+
+    /**
+     * Pick a random available address and mark its prefix as in use for the provided
+     * IIpv4PrefixRequest. Return null if there is no available address.
+     */
+    @Nullable
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
+        final int prefixIndex = getRandomPrefixIndex();
+        for (int i = 0; i < mTetheringPrefixes.size(); i++) {
+            final IpPrefix prefixRange = mTetheringPrefixes.get(
+                    (prefixIndex + i) % mTetheringPrefixes.size());
+            final LinkAddress newAddress = chooseDownstreamAddress(prefixRange);
+            if (newAddress != null) {
+                mDownstreams.put(wrappedRequest, newAddress);
+                return newAddress;
+            }
+        }
+
+        // No available address.
+        return null;
+    }
+
+    private int getRandomPrefixIndex() {
+        if (!mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)) return 0;
+
+        final int random = getRandomInt() & 0xffffff;
+        // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
+        // LinkAddress. To avoid complex operations in the selection logic and make the selected
+        // rate approximate consistency with that /8 is around 2^4 times of /12 and /12 is around
+        // 2^4 times of /16, we simply define a map between the value and the prefix value like
+        // this:
+        //
+        // Value 0 ~ 0xffff (65536/16777216 = 0.39%) -> 192.168.0.0/16
+        // Value 0x10000 ~ 0xfffff (983040/16777216 = 5.86%) -> 172.16.0.0/12
+        // Value 0x100000 ~ 0xffffff (15728640/16777216 = 93.7%) -> 10.0.0.0/8
+        if (random > 0xfffff) {
+            return 2;
+        } else if (random > 0xffff) {
+            return 1;
+        } else {
+            return 0;
+        }
+    }
+
+    private int getPrefixBaseAddress(final IpPrefix prefix) {
+        return inet4AddressToIntHTH((Inet4Address) prefix.getAddress());
+    }
+
+    /**
+     * Check whether input prefix conflict with upstream prefixes or in-use downstream prefixes.
+     * If yes, return one of them.
+     */
+    private IpPrefix getConflictPrefix(final IpPrefix prefix) {
+        final IpPrefix upstream = getConflictWithUpstream(prefix);
+        if (upstream != null) return upstream;
+
+        return getInUseDownstreamPrefix(prefix);
+    }
+
+    @VisibleForTesting
+    public LinkAddress chooseDownstreamAddress(final IpPrefix prefixRange) {
+        // The netmask of the prefix assignment block (e.g., 0xfff00000 for 172.16.0.0/12).
+        final int prefixRangeMask = prefixLengthToV4NetmaskIntHTH(prefixRange.getPrefixLength());
+
+        // The zero address in the block (e.g., 0xac100000 for 172.16.0.0/12).
+        final int baseAddress = getPrefixBaseAddress(prefixRange);
+
+        // Try to get an address within the given prefix that does not conflict with any other
+        // prefix in the system.
+        for (int i = 0; i < 20; ++i) {
+            final int randomSuffix = mRandom.nextInt() & ~prefixRangeMask;
+            final int randomAddress = baseAddress | randomSuffix;
+
+            // Avoid selecting x.x.x.[0, 1, 255] addresses.
+            switch (randomAddress & 0xFF) {
+                case 0:
+                case 1:
+                case 255:
+                    // Try selecting a different address
+                    continue;
+            }
+
+            // Avoid selecting commonly used subnets.
+            switch (randomAddress & 0xFFFFFF00) {
+                case 0xC0A80000: // 192.168.0.0/24
+                case 0xC0A80100: // 192.168.1.0/24
+                case 0xC0A85800: // 192.168.88.0/24
+                case 0xC0A86400: // 192.168.100.0/24
+                    continue;
+            }
+
+            // Avoid 10.0.0.0 - 10.10.255.255
+            if (randomAddress >= 0x0A000000 && randomAddress <= 0x0A0AFFFF) {
+                continue;
+            }
+
+            final InetAddress address = intToInet4AddressHTH(randomAddress);
+            final IpPrefix prefix = new IpPrefix(address, PREFIX_LENGTH);
+            if (getConflictPrefix(prefix) != null) {
+                // Prefix is conflicting with another prefix used in the system, find another one.
+                continue;
+            }
+            return new LinkAddress(address, PREFIX_LENGTH);
+        }
+        // Could not find a prefix, return null and let caller try another range.
+        return null;
+    }
+
+    /** Get random int which could be used to generate random address. */
+    // TODO: get rid of this function and mock getRandomPrefixIndex in tests.
+    @VisibleForTesting
+    public int getRandomInt() {
+        return mRandom.nextInt();
+    }
+
+    /** Release downstream record for IpServer. */
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        mDownstreams.remove(new Ipv4PrefixRequest(request));
+    }
+
+    /** Clear current upstream prefixes records. */
+    public void clearUpstreamPrefixes() {
+        mUpstreamPrefixMap.clear();
+    }
+
+    private IpPrefix getConflictWithUpstream(final IpPrefix prefix) {
+        for (int i = 0; i < mUpstreamPrefixMap.size(); i++) {
+            final List<IpPrefix> list = mUpstreamPrefixMap.valueAt(i);
+            for (IpPrefix upstream : list) {
+                if (isConflictPrefix(prefix, upstream)) return upstream;
+            }
+        }
+        return null;
+    }
+
+    private boolean isConflictWithUpstream(final IpPrefix prefix) {
+        return getConflictWithUpstream(prefix) != null;
+    }
+
+    private boolean isConflictPrefix(final IpPrefix prefix1, final IpPrefix prefix2) {
+        if (prefix2.getPrefixLength() < prefix1.getPrefixLength()) {
+            return prefix2.contains(prefix1.getAddress());
+        }
+
+        return prefix1.contains(prefix2.getAddress());
+    }
+
+    // InUse Prefixes are prefixes of mCachedAddresses which are active downstream addresses, last
+    // downstream addresses(reserved for next time) and static addresses(e.g. bluetooth, wifi p2p).
+    private IpPrefix getInUseDownstreamPrefix(final IpPrefix prefix) {
+        for (int i = 0; i < mCachedAddresses.size(); i++) {
+            final IpPrefix downstream = asIpPrefix(mCachedAddresses.valueAt(i));
+            if (isConflictPrefix(prefix, downstream)) return downstream;
+        }
+
+        // IpServer may use manually-defined address (mStaticIpv4ServerAddr) which does not include
+        // in mCachedAddresses.
+        for (LinkAddress downstream : mDownstreams.values()) {
+            final IpPrefix target = asIpPrefix(downstream);
+
+            if (isConflictPrefix(prefix, target)) return target;
+        }
+
+        return null;
+    }
+
+    private static IpPrefix asIpPrefix(LinkAddress addr) {
+        return new IpPrefix(addr.getAddress(), addr.getPrefixLength());
+    }
+
+    private static final class Ipv4PrefixRequest {
+        private final IIpv4PrefixRequest mRequest;
+
+        Ipv4PrefixRequest(IIpv4PrefixRequest request) {
+            mRequest = request;
+        }
+
+        public IIpv4PrefixRequest getRequest() {
+            return mRequest;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) return true;
+            if (!(obj instanceof Ipv4PrefixRequest)) return false;
+            return Objects.equals(
+                    mRequest.asBinder(), ((Ipv4PrefixRequest) obj).mRequest.asBinder());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(mRequest.asBinder());
+        }
+    }
+
+    private static class AddressKey {
+        private final int mTetheringType;
+        private final int mScope;
+
+        private AddressKey(int type, int scope) {
+            mTetheringType = type;
+            mScope = scope;
+        }
+
+        @Override
+        public int hashCode() {
+            return (mTetheringType << 16) + mScope;
+        }
+
+        @Override
+        public boolean equals(@Nullable Object obj) {
+            if (!(obj instanceof AddressKey)) return false;
+            final AddressKey other = (AddressKey) obj;
+
+            return mTetheringType == other.mTetheringType && mScope == other.mScope;
+        }
+
+        @Override
+        public String toString() {
+            return "AddressKey(" + mTetheringType + ", " + mScope + ")";
+        }
+    }
+
+    // TODO: dump PrivateAddressCoordinator when dumping RoutingCoordinatorService and apply
+    // indentation.
+    void dump(final PrintWriter pw) {
+        pw.println("mTetheringPrefixes:");
+        for (IpPrefix prefix : mTetheringPrefixes) {
+            pw.println(prefix);
+        }
+
+        pw.println("mUpstreamPrefixMap:");
+        for (int i = 0; i < mUpstreamPrefixMap.size(); i++) {
+            pw.println(mUpstreamPrefixMap.keyAt(i) + " - " + mUpstreamPrefixMap.valueAt(i));
+        }
+
+        pw.println("mDownstreams:");
+        for (LinkAddress downstream : mDownstreams.values()) {
+            pw.println(downstream);
+        }
+
+        pw.println("mCachedAddresses:");
+        for (int i = 0; i < mCachedAddresses.size(); i++) {
+            pw.println(mCachedAddresses.keyAt(i) + " - " + mCachedAddresses.valueAt(i));
+        }
+    }
+}
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..c8fdf72
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
@@ -0,0 +1,345 @@
+/*
+ * 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);
+    }
+
+    /**
+     * Remove a scheduled message.
+     *
+     * @param what the message to be removed
+     */
+    public void removeDelayedMessage(int what) {
+        ensureRunningOnCorrectThread();
+        mTaskQueue.removeIf(task -> task instanceof MessageTask
+                && ((MessageTask) task).mMessage.what == what);
+    }
+
+    /**
+     * 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/RoutingCoordinatorManager.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
index 02e3643..f5af30c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
@@ -17,17 +17,27 @@
 package com.android.net.module.util;
 
 import android.content.Context;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 import android.os.IBinder;
 import android.os.RemoteException;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 /**
  * A manager class for talking to the routing coordinator service.
  *
  * This class should only be used by the connectivity and tethering module. This is enforced
  * by the build rules. Do not change build rules to gain access to this class from elsewhere.
+ *
+ * This class has following functionalities:
+ * - Manage routes and forwarding for networks.
+ * - Manage IPv4 prefix allocation for network interfaces.
+ *
  * @hide
  */
 public class RoutingCoordinatorManager {
@@ -154,4 +164,77 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    // PrivateAddressCoordinator methods:
+
+    /** Update the prefix of an upstream. */
+    public void updateUpstreamPrefix(LinkProperties lp, NetworkCapabilities nc, Network network) {
+        try {
+            mService.updateUpstreamPrefix(lp, nc, network);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    public void removeUpstreamPrefix(Network network) {
+        try {
+            mService.removeUpstreamPrefix(network);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Remove the deprecated upstream networks if any. */
+    public void maybeRemoveDeprecatedUpstreams() {
+        try {
+            mService.maybeRemoveDeprecatedUpstreams();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Request an IPv4 address for the downstream. Return the last time used address for the
+     * provided (interfaceType, scope) pair if possible.
+     *
+     * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+     * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Nullable
+    public LinkAddress requestStickyDownstreamAddress(
+            int interfaceType,
+            int scope,
+            IIpv4PrefixRequest request) {
+        try {
+            return mService.requestStickyDownstreamAddress(interfaceType, scope, request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Request an IPv4 address for the downstream.
+     *
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        try {
+            return mService.requestDownstreamAddress(request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Release the IPv4 address allocated for the downstream. */
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        try {
+            mService.releaseDownstream(request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
index c75b860..51eb47c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
@@ -19,8 +19,13 @@
 import static com.android.net.module.util.NetdUtils.toRouteInfoParcel;
 
 import android.annotation.NonNull;
+import android.content.Context;
 import android.net.INetd;
 
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -28,8 +33,10 @@
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
 
 import java.util.Objects;
+import java.util.function.Supplier;
 
 /**
  * Class to coordinate routing across multiple clients.
@@ -45,8 +52,22 @@
     private static final String TAG = RoutingCoordinatorService.class.getSimpleName();
     private final INetd mNetd;
 
-    public RoutingCoordinatorService(@NonNull INetd netd) {
+    private final Object mPrivateAddressCoordinatorLock = new Object();
+    @GuardedBy("mPrivateAddressCoordinatorLock")
+    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
+
+    public RoutingCoordinatorService(@NonNull INetd netd,
+                                     @NonNull Supplier<Network[]> getAllNetworksSupplier,
+                                     @NonNull Context context) {
+        this(netd, getAllNetworksSupplier, new PrivateAddressCoordinator.Dependencies(context));
+    }
+
+    @VisibleForTesting
+    public RoutingCoordinatorService(@NonNull INetd netd,
+                                     @NonNull Supplier<Network[]> getAllNetworksSupplier,
+                                     @NonNull PrivateAddressCoordinator.Dependencies pacDeps) {
         mNetd = netd;
+        mPrivateAddressCoordinator = new PrivateAddressCoordinator(getAllNetworksSupplier, pacDeps);
     }
 
     /**
@@ -225,4 +246,91 @@
             }
         }
     }
+
+    // PrivateAddressCoordinator methods:
+
+    /** Update the prefix of an upstream. */
+    @Override
+    public void updateUpstreamPrefix(LinkProperties lp, NetworkCapabilities nc, Network network) {
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.updateUpstreamPrefix(lp, nc, network);
+                    }
+                });
+    }
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    @Override
+    public void removeUpstreamPrefix(Network network) {
+        Objects.requireNonNull(network);
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.removeUpstreamPrefix(network);
+                    }
+                });
+    }
+
+    /** Remove the deprecated upstream networks if any. */
+    @Override
+    public void maybeRemoveDeprecatedUpstreams() {
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.maybeRemoveDeprecatedUpstreams();
+                    }
+                });
+    }
+
+    /**
+     * Request an IPv4 address for the downstream. Return the last time used address for the
+     * provided (interfaceType, scope) pair if possible.
+     *
+     * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+     * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Override
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, int scope,
+            IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        return BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        return mPrivateAddressCoordinator.requestStickyDownstreamAddress(
+                                interfaceType, scope, request);
+                    }
+                });
+    }
+
+    /**
+     * Request an IPv4 address for the downstream.
+     *
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Override
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        return BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        return mPrivateAddressCoordinator.requestDownstreamAddress(request);
+                    }
+                });
+    }
+
+    /** Release the IPv4 address allocated for the downstream. */
+    @Override
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.releaseDownstream(request);
+                    }
+                });
+    }
 }
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..4a5dd4f
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/ServiceConnectivityJni.java
@@ -0,0 +1,63 @@
+/*
+ * 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 java.io.IOException;
+
+/**
+ * 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 IOException if the timerfd creation is failed.
+     */
+    public static native int createTimerFd() throws IOException;
+
+    /**
+     * Set given time to the timerfd.
+     *
+     * @param timeMs target time
+     * @throws IOException if setting expiration time is failed.
+     */
+    public static native void setTimerFdTime(int fd, long timeMs) throws IOException;
+
+    /** 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/SyncStateMachine.java b/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
index da184d3..fc0161b 100644
--- a/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
+++ b/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
@@ -225,7 +225,8 @@
             consideredState = mStateInfo.get(consideredState.parent);
         }
         if (null == consideredState) {
-            Log.wtf(mName, "Message " + msg.what + " was not handled");
+            final String state = mCurrentState == null ? "null" : mCurrentState.getName();
+            Log.wtf(mName, "Message " + msg.what + " was not handled. Current state: " + state);
         }
 
         performTransitions();
diff --git a/staticlibs/device/com/android/net/module/util/TimerFdUtils.java b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
new file mode 100644
index 0000000..10bc595
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/TimerFdUtils.java
@@ -0,0 +1,54 @@
+/*
+ * 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.Process;
+import android.util.Log;
+
+import java.io.IOException;
+
+/**
+ * Contains mostly timerfd functionality.
+ */
+public class TimerFdUtils {
+    private static final String TAG = TimerFdUtils.class.getSimpleName();
+
+    /**
+     * Create a timerfd
+     */
+    static int createTimerFileDescriptor() {
+        try {
+            return ServiceConnectivityJni.createTimerFd();
+        } catch (IOException e) {
+            Log.e(TAG, "createTimerFd failed", e);
+            return -1;
+        }
+    }
+
+    /**
+     * Set expiration time to timerfd
+     */
+    static boolean setExpirationTime(int fd, long expirationTimeMs) {
+        try {
+            ServiceConnectivityJni.setTimerFdTime(fd, expirationTimeMs);
+        } catch (IOException e) {
+            Log.e(TAG, "setExpirationTime failed", e);
+            return false;
+        }
+        return true;
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NetlinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkMessage.java
index 781a04e..dfb2053 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkMessage.java
@@ -150,6 +150,8 @@
                 return (NetlinkMessage) RtNetlinkNeighborMessage.parse(nlmsghdr, byteBuffer);
             case NetlinkConstants.RTM_NEWNDUSEROPT:
                 return (NetlinkMessage) NduseroptMessage.parse(nlmsghdr, byteBuffer);
+            case NetlinkConstants.RTM_NEWPREFIX:
+                return (NetlinkMessage) RtNetlinkPrefixMessage.parse(nlmsghdr, byteBuffer);
             default: return null;
         }
     }
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..e2544d3 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;
@@ -469,4 +470,31 @@
             // 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;
+        }
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
new file mode 100644
index 0000000..7591d5c
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+
+/**
+ * This class wraps the static methods of {@link android.system.Os} for mocking and testing.
+ */
+public class OsAccess {
+    /**
+     * Constant indicating that the {@code if_nametoindex()} function could not find the network
+     * interface index corresponding to the given interface name.
+     */
+    public static int INVALID_INTERFACE_INDEX = 0;
+
+    /** Wraps {@link Os#if_nametoindex(String)}. */
+    public int if_nametoindex(@NonNull String name) {
+        return Os.if_nametoindex(name);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
index 0c49edc..1afe3b8 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
@@ -16,6 +16,15 @@
 
 package com.android.net.module.util.netlink;
 
+import static android.system.OsConstants.AF_UNSPEC;
+
+import static com.android.net.module.util.NetworkStackConstants.ETHER_ADDR_LEN;
+import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
+import static com.android.net.module.util.netlink.NetlinkConstants.RTM_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;
 import android.system.OsConstants;
 
@@ -24,6 +33,7 @@
 import androidx.annotation.VisibleForTesting;
 
 import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 
 /**
  * A NetlinkMessage subclass for rtnetlink link messages.
@@ -46,29 +56,55 @@
 
     public static final short IN6_ADDR_GEN_MODE_NONE = 1;
 
-    private int mMtu;
-    @NonNull
-    private StructIfinfoMsg mIfinfomsg;
-    @Nullable
-    private MacAddress mHardwareAddress;
-    @Nullable
-    private String mInterfaceName;
+    // The maximum buffer size to hold an interface name including the null-terminator '\0'.
+    private static final int IFNAMSIZ = 16;
+    // The default value of MTU, which means the MTU is unspecified.
+    private static final int DEFAULT_MTU = 0;
 
-    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr header) {
-        super(header);
-        mIfinfomsg = null;
-        mMtu = 0;
-        mHardwareAddress = null;
-        mInterfaceName = null;
+    @NonNull
+    private final StructIfinfoMsg mIfinfomsg;
+    private final int mMtu;
+    @Nullable
+    private final MacAddress mHardwareAddress;
+    @Nullable
+    private final String mInterfaceName;
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance.
+     *
+     * <p>This method validates the arguments and returns {@code null} if any of them are invalid.
+     * nlmsghdr's nlmsg_len will be updated to the correct length before creation.
+     *
+     * @param nlmsghdr The Netlink message header. Must not be {@code null}.
+     * @param ifinfomsg The interface information message. Must not be {@code null}.
+     * @param mtu The Maximum Transmission Unit (MTU) value for the link.
+     * @param hardwareAddress The hardware address (MAC address) of the link. May be {@code null}.
+     * @param interfaceName The name of the interface. May be {@code null}.
+     * @return A new {@link RtNetlinkLinkMessage} instance, or {@code null} if the input arguments
+     *         are invalid.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage build(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
+        if (mtu < 0) {
+            return null;
+        }
+        if (interfaceName != null
+                && (interfaceName.isEmpty() || interfaceName.length() + 1 > IFNAMSIZ)) {
+            return null;
+        }
+
+        nlmsghdr.nlmsg_len = calculateMessageLength(mtu, hardwareAddress, interfaceName);
+        return new RtNetlinkLinkMessage(nlmsghdr, ifinfomsg, mtu, hardwareAddress, interfaceName);
     }
 
-    @VisibleForTesting
-    public RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
-            int mtu, @NonNull StructIfinfoMsg ifinfomsg, @NonNull MacAddress hardwareAddress,
-            @NonNull String interfaceName) {
+    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
         super(nlmsghdr);
-        mMtu = mtu;
         mIfinfomsg = ifinfomsg;
+        mMtu = mtu;
         mHardwareAddress = hardwareAddress;
         mInterfaceName = interfaceName;
     }
@@ -102,33 +138,46 @@
     @Nullable
     public static RtNetlinkLinkMessage parse(@NonNull final StructNlMsgHdr header,
             @NonNull final ByteBuffer byteBuffer) {
-        final RtNetlinkLinkMessage linkMsg = new RtNetlinkLinkMessage(header);
-
-        linkMsg.mIfinfomsg = StructIfinfoMsg.parse(byteBuffer);
-        if (linkMsg.mIfinfomsg == null) return null;
+        final StructIfinfoMsg ifinfoMsg = StructIfinfoMsg.parse(byteBuffer);
+        if (ifinfoMsg == null) {
+            return null;
+        }
 
         // IFLA_MTU
+        int mtu = DEFAULT_MTU;
         final int baseOffset = byteBuffer.position();
         StructNlAttr nlAttr = StructNlAttr.findNextAttrOfType(IFLA_MTU, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mMtu = nlAttr.getValueAsInt(0 /* default value */);
+            mtu = nlAttr.getValueAsInt(DEFAULT_MTU);
         }
 
         // IFLA_ADDRESS
+        MacAddress hardwareAddress = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_ADDRESS, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mHardwareAddress = nlAttr.getValueAsMacAddress();
+            hardwareAddress = nlAttr.getValueAsMacAddress();
         }
 
         // IFLA_IFNAME
+        String interfaceName = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_IFNAME, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mInterfaceName = nlAttr.getValueAsString();
+            interfaceName = nlAttr.getValueAsString();
         }
 
-        return linkMsg;
+        return new RtNetlinkLinkMessage(header, ifinfoMsg, mtu, hardwareAddress, interfaceName);
+    }
+
+    /**
+     *  Write a rtnetlink link message to {@link byte} array.
+     */
+    public byte[] pack(ByteOrder order) {
+        byte[] bytes = new byte[mHeader.nlmsg_len];
+        ByteBuffer buffer = ByteBuffer.wrap(bytes).order(order);
+        pack(buffer);
+        return bytes;
     }
 
     /**
@@ -136,10 +185,10 @@
      */
     @VisibleForTesting
     protected void pack(ByteBuffer byteBuffer) {
-        getHeader().pack(byteBuffer);
+        mHeader.pack(byteBuffer);
         mIfinfomsg.pack(byteBuffer);
 
-        if (mMtu != 0) {
+        if (mMtu != DEFAULT_MTU) {
             final StructNlAttr mtu = new StructNlAttr(IFLA_MTU, mMtu);
             mtu.pack(byteBuffer);
         }
@@ -153,11 +202,172 @@
         }
     }
 
+    /**
+     *  Calculate the byte length of the packed buffer.
+     */
+    private static int calculateMessageLength(int mtu, MacAddress hardwareAddress,
+            String interfaceName) {
+        int length = StructNlMsgHdr.STRUCT_SIZE + StructIfinfoMsg.STRUCT_SIZE;
+
+        if (mtu != DEFAULT_MTU) {
+            length += NetlinkConstants.alignedLengthOf(StructNlAttr.NLA_HEADERLEN + Integer.BYTES);
+        }
+        if (hardwareAddress != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    StructNlAttr.NLA_HEADERLEN + ETHER_ADDR_LEN);
+        }
+        if (interfaceName != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    // The string should be end with '\0', so the length should plus 1.
+                    StructNlAttr.NLA_HEADERLEN + interfaceName.length() + 1);
+        }
+
+        return length;
+    }
+
+    /**
+     * Create a link message to set the operational state (up or down) of a network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param isUp           {@code true} to set the interface up, {@code false} to set it down.
+     * @return A `RtNetlinkLinkMessage` instance configured to set the link state.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp) {
+        return createSetLinkStateMessage(interfaceName, sequenceNumber, isUp, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex,
+                                    isUp ? IFF_UP : 0, IFF_UP), DEFAULT_MTU, null, null);
+    }
+
+    /**
+     * Create a link message to rename the network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param newName        The new name of the network interface.
+     * @return A `RtNetlinkLinkMessage` instance configured to rename the network interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName) {
+        return createSetLinkNameMessage(interfaceName, sequenceNumber, newName, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
+                DEFAULT_MTU, null, newName);
+    }
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance that can be used to get the link information
+     * of a network interface.
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param sequenceNumber The sequence number for the Netlink message.
+     * @return An `RtNetlinkLinkMessage` instance representing the request to query the interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createGetLinkMessage(@NonNull String interfaceName,
+            int sequenceNumber) {
+        return createGetLinkMessage(interfaceName, sequenceNumber, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createGetLinkMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST, 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, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, /*type*/ 0, interfaceIndex,
+                        flagsBits, changeBits),
+                DEFAULT_MTU, /*hardwareAddress*/ null, /*interfaceName*/ null);
+    }
+
     @Override
     public String toString() {
         return "RtNetlinkLinkMessage{ "
                 + "nlmsghdr{" + mHeader.toString(OsConstants.NETLINK_ROUTE) + "}, "
-                + "Ifinfomsg{" + mIfinfomsg.toString() + "}, "
+                + "Ifinfomsg{" + mIfinfomsg + "}, "
                 + "Hardware Address{" + mHardwareAddress + "}, "
                 + "MTU{" + mMtu + "}, "
                 + "Ifname{" + mInterfaceName + "} "
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkPrefixMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkPrefixMessage.java
new file mode 100644
index 0000000..30c63fb
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkPrefixMessage.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import android.net.IpPrefix;
+import android.system.OsConstants;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.net.Inet6Address;
+import java.nio.ByteBuffer;
+
+/**
+ * A NetlinkMessage subclass for rtnetlink address messages.
+ *
+ * RtNetlinkPrefixMessage.parse() must be called with a ByteBuffer that contains exactly one
+ * netlink message.
+ *
+ * see also:
+ *
+ *     include/uapi/linux/rtnetlink.h
+ *
+ * @hide
+ */
+public class RtNetlinkPrefixMessage extends NetlinkMessage {
+    public static final short PREFIX_ADDRESS       = 1;
+    public static final short PREFIX_CACHEINFO     = 2;
+
+    @NonNull
+    private StructPrefixMsg mPrefixmsg;
+    @NonNull
+    private IpPrefix mPrefix;
+    private long mPreferredLifetime;
+    private long mValidLifetime;
+
+    @VisibleForTesting
+    public RtNetlinkPrefixMessage(@NonNull final StructNlMsgHdr header,
+            @NonNull final StructPrefixMsg prefixmsg,
+            @NonNull final IpPrefix prefix,
+            long preferred, long valid) {
+        super(header);
+        mPrefixmsg = prefixmsg;
+        mPrefix = prefix;
+        mPreferredLifetime = preferred;
+        mValidLifetime = valid;
+    }
+
+    private RtNetlinkPrefixMessage(@NonNull StructNlMsgHdr header) {
+        this(header, null, null, 0 /* preferredLifetime */, 0 /* validLifetime */);
+    }
+
+    @NonNull
+    public StructPrefixMsg getPrefixMsg() {
+        return mPrefixmsg;
+    }
+
+    @NonNull
+    public IpPrefix getPrefix() {
+        return mPrefix;
+    }
+
+    public long getPreferredLifetime() {
+        return mPreferredLifetime;
+    }
+
+    public long getValidLifetime() {
+        return mValidLifetime;
+    }
+
+    /**
+     * Parse rtnetlink prefix message from {@link ByteBuffer}. This method must be called with a
+     * ByteBuffer that contains exactly one netlink message.
+     *
+     * RTM_NEWPREFIX Message Format:
+     *  +----------+- - -+-------------+- - -+---------------------+-----------------------+
+     *  | nlmsghdr | Pad |  prefixmsg  | Pad | PREFIX_ADDRESS attr | PREFIX_CACHEINFO attr |
+     *  +----------+- - -+-------------+- - -+---------------------+-----------------------+
+     *
+     * @param header netlink message header.
+     * @param byteBuffer the ByteBuffer instance that wraps the raw netlink message bytes.
+     */
+    @Nullable
+    public static RtNetlinkPrefixMessage parse(@NonNull final StructNlMsgHdr header,
+            @NonNull final ByteBuffer byteBuffer) {
+        try {
+            final RtNetlinkPrefixMessage msg = new RtNetlinkPrefixMessage(header);
+            msg.mPrefixmsg = StructPrefixMsg.parse(byteBuffer);
+
+            // PREFIX_ADDRESS
+            final int baseOffset = byteBuffer.position();
+            StructNlAttr nlAttr = StructNlAttr.findNextAttrOfType(PREFIX_ADDRESS, byteBuffer);
+            if (nlAttr == null) return null;
+            final Inet6Address addr = (Inet6Address) nlAttr.getValueAsInetAddress();
+            if (addr == null) return null;
+            msg.mPrefix = new IpPrefix(addr, msg.mPrefixmsg.prefix_len);
+
+            // PREFIX_CACHEINFO
+            byteBuffer.position(baseOffset);
+            nlAttr = StructNlAttr.findNextAttrOfType(PREFIX_CACHEINFO, byteBuffer);
+            if (nlAttr == null) return null;
+            final ByteBuffer buffer = nlAttr.getValueAsByteBuffer();
+            if (buffer == null) return null;
+            final StructPrefixCacheInfo cacheinfo = StructPrefixCacheInfo.parse(buffer);
+            msg.mPreferredLifetime = cacheinfo.preferred_time;
+            msg.mValidLifetime = cacheinfo.valid_time;
+
+            return msg;
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    /**
+     * Write a rtnetlink prefix message to {@link ByteBuffer}.
+     */
+    @VisibleForTesting
+    protected void pack(ByteBuffer byteBuffer) {
+        getHeader().pack(byteBuffer);
+        mPrefixmsg.pack(byteBuffer);
+
+        // PREFIX_ADDRESS attribute
+        final StructNlAttr prefixAddress =
+                new StructNlAttr(PREFIX_ADDRESS, mPrefix.getRawAddress());
+        prefixAddress.pack(byteBuffer);
+
+        // PREFIX_CACHEINFO attribute
+        final StructPrefixCacheInfo cacheinfo =
+                new StructPrefixCacheInfo(mPreferredLifetime, mValidLifetime);
+        final StructNlAttr prefixCacheinfo =
+                new StructNlAttr(PREFIX_CACHEINFO, cacheinfo.writeToBytes());
+        prefixCacheinfo.pack(byteBuffer);
+    }
+
+    @Override
+    public String toString() {
+        return "RtNetlinkPrefixMessage{ "
+                + "nlmsghdr{" + mHeader.toString(OsConstants.NETLINK_ROUTE) + "}, "
+                + "prefixmsg{" + mPrefixmsg.toString() + "}, "
+                + "IP Prefix{" + mPrefix + "}, "
+                + "preferred lifetime{" + mPreferredLifetime + "}, "
+                + "valid lifetime{" + mValidLifetime + "} "
+                + "}";
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
index 5272366..7cc95de 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
@@ -32,10 +32,11 @@
     // Already aligned.
     public static final int STRUCT_SIZE = 16;
 
-    public static final short NLM_F_REQUEST = 0x0001;
-    public static final short NLM_F_MULTI   = 0x0002;
-    public static final short NLM_F_ACK     = 0x0004;
-    public static final short NLM_F_ECHO    = 0x0008;
+    public static final short NLM_F_REQUEST     = 0x0001;
+    public static final short NLM_F_MULTI       = 0x0002;
+    public static final short NLM_F_ACK         = 0x0004;
+    public static final short NLM_F_ECHO        = 0x0008;
+    public static final short NLM_F_REQUEST_ACK = NLM_F_REQUEST | NLM_F_ACK;
     // Flags for a GET request.
     public static final short NLM_F_ROOT    = 0x0100;
     public static final short NLM_F_MATCH   = 0x0200;
diff --git a/staticlibs/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/DnsPacket.java b/staticlibs/framework/com/android/net/module/util/DnsPacket.java
index 63106a1..b0c5e2e 100644
--- a/staticlibs/framework/com/android/net/module/util/DnsPacket.java
+++ b/staticlibs/framework/com/android/net/module/util/DnsPacket.java
@@ -50,7 +50,7 @@
  *
  * @hide
  */
-public abstract class DnsPacket {
+public class DnsPacket {
     /**
      * Type of the canonical name for an alias. Refer to RFC 1035 section 3.2.2.
      */
@@ -515,7 +515,14 @@
     protected final DnsHeader mHeader;
     protected final List<DnsRecord>[] mRecords;
 
-    protected DnsPacket(@NonNull byte[] data) throws ParseException {
+    /**
+     * Returns the list of DNS records for a given section.
+     */
+    public List<DnsRecord> getRecords(@RecordType int section) {
+        return mRecords[section];
+    }
+
+    public DnsPacket(@NonNull byte[] data) throws ParseException {
         if (null == data) {
             throw new ParseException("Parse header failed, null input data");
         }
@@ -548,7 +555,7 @@
      *
      * Note that authority records section and additional records section is not supported.
      */
-    protected DnsPacket(@NonNull DnsHeader header, @NonNull List<DnsRecord> qd,
+    public DnsPacket(@NonNull DnsHeader header, @NonNull List<DnsRecord> qd,
             @NonNull List<DnsRecord> an) {
         mHeader = Objects.requireNonNull(header);
         mRecords = new List[NUM_SECTIONS];
diff --git a/staticlibs/framework/com/android/net/module/util/HexDump.java b/staticlibs/framework/com/android/net/module/util/HexDump.java
index a22c258..409f611 100644
--- a/staticlibs/framework/com/android/net/module/util/HexDump.java
+++ b/staticlibs/framework/com/android/net/module/util/HexDump.java
@@ -202,7 +202,7 @@
         if (c >= 'A' && c <= 'F') return (c - 'A' + 10);
         if (c >= 'a' && c <= 'f') return (c - 'a' + 10);
 
-        throw new RuntimeException("Invalid hex char '" + c + "'");
+        throw new IllegalArgumentException("Invalid hex char '" + c + "'");
     }
 
     /**
diff --git a/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
new file mode 100644
index 0000000..96d995a
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.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.net.module.util;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.LruCache;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.Objects;
+import java.util.function.LongSupplier;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * A thread-safe LRU cache that stores key-value pairs with an expiry time.
+ *
+ * <p>This cache uses an {@link LruCache} to store entries and evicts the least
+ * recently used entries when the cache reaches its maximum capacity. It also
+ * supports an expiry time for each entry, allowing entries to be automatically
+ * removed from the cache after a certain duration.
+ *
+ * @param <K> The type of keys used to identify cached entries.
+ * @param <V> The type of values stored in the cache.
+ *
+ * @hide
+ */
+public class LruCacheWithExpiry<K, V> {
+    private final LongSupplier mTimeSupplier;
+    private final long mExpiryDurationMs;
+    @GuardedBy("mMap")
+    private final LruCache<K, CacheValue<V>> mMap;
+    private final Predicate<V> mShouldCacheValue;
+
+    /**
+     * Constructs a new {@link LruCacheWithExpiry} with the specified parameters.
+     *
+     * @param timeSupplier     The {@link java.util.function.LongSupplier} to use for
+     *                         determining timestamps.
+     * @param expiryDurationMs The expiry duration for cached entries in milliseconds.
+     * @param maxSize          The maximum number of entries to hold in the cache.
+     * @param shouldCacheValue A {@link Predicate} that determines whether a given value should be
+     *                         cached. This can be used to filter out certain values from being
+     *                         stored in the cache.
+     */
+    public LruCacheWithExpiry(@NonNull LongSupplier timeSupplier, long expiryDurationMs,
+            int maxSize, Predicate<V> shouldCacheValue) {
+        mTimeSupplier = timeSupplier;
+        mExpiryDurationMs = expiryDurationMs;
+        mMap = new LruCache<>(maxSize);
+        mShouldCacheValue = shouldCacheValue;
+    }
+
+    /**
+     * Retrieves a value from the cache, associated with the given key.
+     *
+     * @param key The key to look up in the cache.
+     * @return The cached value, or {@code null} if not found or expired.
+     */
+    @Nullable
+    public V get(@NonNull K key) {
+        synchronized (mMap) {
+            final CacheValue<V> value = mMap.get(key);
+            if (value != null && !isExpired(value.timestamp)) {
+                return value.entry;
+            } else {
+                mMap.remove(key); // Remove expired entries
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Retrieves a value from the cache, associated with the given key.
+     * If the entry is not found in the cache or has expired, computes it using the provided
+     * {@code supplier} and stores the result in the cache.
+     *
+     * @param key      The key to look up in the cache.
+     * @param supplier The {@link Supplier} to compute the value if not found or expired.
+     * @return The cached or computed value, or {@code null} if the {@code supplier} returns null.
+     */
+    @Nullable
+    public V getOrCompute(@NonNull K key, @NonNull Supplier<V> supplier) {
+        synchronized (mMap) {
+            final V cachedValue = get(key);
+            if (cachedValue != null) {
+                return cachedValue;
+            }
+
+            // Entry not found or expired, compute it
+            final V computedValue = supplier.get();
+            if (computedValue != null && mShouldCacheValue.test(computedValue)) {
+                put(key, computedValue);
+            }
+            return computedValue;
+        }
+    }
+
+    /**
+     * Stores a value in the cache, associated with the given key.
+     *
+     * @param key   The key to associate with the value.
+     * @param value The value to store in the cache.
+     */
+    public void put(@NonNull K key, @NonNull V value) {
+        Objects.requireNonNull(value);
+        synchronized (mMap) {
+            mMap.put(key, new CacheValue<>(mTimeSupplier.getAsLong(), value));
+        }
+    }
+
+    /**
+     * 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() {
+        synchronized (mMap) {
+            mMap.evictAll();
+        }
+    }
+
+    private boolean isExpired(long timestamp) {
+        return mTimeSupplier.getAsLong() > timestamp + mExpiryDurationMs;
+    }
+
+    private static class CacheValue<V> {
+        public final long timestamp;
+        @NonNull
+        public final V entry;
+
+        CacheValue(long timestamp, V entry) {
+            this.timestamp = timestamp;
+            this.entry = entry;
+        }
+    }
+}
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
index f1ff2e4..4878334 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
@@ -100,6 +100,15 @@
     public static final int IPV4_ADDR_LEN = 4;
     public static final int IPV4_FLAG_MF = 0x2000;
     public static final int IPV4_FLAG_DF = 0x4000;
+    public static final int IPV4_PROTOCOL_IGMP = 2;
+    public static final int IPV4_IGMP_MIN_SIZE = 8;
+    public static final int IPV4_IGMP_GROUP_RECORD_SIZE = 8;
+    public static final int IPV4_IGMP_TYPE_V1_REPORT = 0x12;
+    public static final int IPV4_IGMP_TYPE_V2_JOIN_REPORT = 0x16;
+    public static final int IPV4_IGMP_TYPE_V2_LEAVE_REPORT = 0x17;
+    public static final int IPV4_IGMP_TYPE_V3_REPORT = 0x22;
+    public static final int IPV4_OPTION_TYPE_ROUTER_ALERT = 0x94;
+    public static final int IPV4_OPTION_LEN_ROUTER_ALERT = 4;
     // getSockOpt() for v4 MTU
     public static final int IP_MTU = 14;
     public static final Inet4Address IPV4_ADDR_ALL = makeInet4Address(
@@ -111,6 +120,8 @@
             (byte) 0, (byte) 0, (byte) 0, (byte) 0,
             (byte) 0, (byte) 0, (byte) 0, (byte) 0,
             (byte) 0, (byte) 0, (byte) 0, (byte) 0 });
+    public static final Inet4Address IPV4_ADDR_ALL_HOST_MULTICAST =
+            (Inet4Address) InetAddresses.parseNumericAddress("224.0.0.1");
 
     /**
      * CLAT constants
diff --git a/staticlibs/framework/com/android/net/module/util/PermissionUtils.java b/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
index 0d7d96f..0fa91d5 100644
--- a/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
@@ -192,6 +192,8 @@
 
     /**
      * Enforces that the given package name belongs to the given uid.
+     * Note: b/377758490 - Figure out how to correct this to avoid mis-usage.
+     * Meanwhile, avoid calling this method from the networkstack.
      *
      * @param context {@link android.content.Context} for the process.
      * @param uid User ID to check the package ownership for.
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/Android.bp b/staticlibs/native/bpfmapjni/Android.bp
index 969ebd4..9a58a93 100644
--- a/staticlibs/native/bpfmapjni/Android.bp
+++ b/staticlibs/native/bpfmapjni/Android.bp
@@ -26,6 +26,7 @@
     header_libs: [
         "bpf_headers",
         "jni_headers",
+        "libbase_headers",
     ],
     shared_libs: [
         "liblog",
diff --git a/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp b/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
index 1923ceb..d862f6b 100644
--- a/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
+++ b/staticlibs/native/bpfmapjni/com_android_net_module_util_BpfMap.cpp
@@ -24,37 +24,38 @@
 #include "nativehelper/scoped_primitive_array.h"
 #include "nativehelper/scoped_utf_chars.h"
 
-#define BPF_FD_JUST_USE_INT
+#include <android-base/unique_fd.h>
 #include "BpfSyscallWrappers.h"
-
 #include "bpf/KernelUtils.h"
 
 namespace android {
 
+using ::android::base::unique_fd;
+
 static jint com_android_net_module_util_BpfMap_nativeBpfFdGet(JNIEnv *env, jclass clazz,
         jstring path, jint mode, jint keySize, jint valueSize) {
     ScopedUtfChars pathname(env, path);
 
-    jint fd = -1;
+    unique_fd fd;
     switch (mode) {
       case 0:
-        fd = bpf::mapRetrieveRW(pathname.c_str());
+        fd.reset(bpf::mapRetrieveRW(pathname.c_str()));
         break;
       case BPF_F_RDONLY:
-        fd = bpf::mapRetrieveRO(pathname.c_str());
+        fd.reset(bpf::mapRetrieveRO(pathname.c_str()));
         break;
       case BPF_F_WRONLY:
-        fd = bpf::mapRetrieveWO(pathname.c_str());
+        fd.reset(bpf::mapRetrieveWO(pathname.c_str()));
         break;
       case BPF_F_RDONLY|BPF_F_WRONLY:
-        fd = bpf::mapRetrieveExclusiveRW(pathname.c_str());
+        fd.reset(bpf::mapRetrieveExclusiveRW(pathname.c_str()));
         break;
       default:
         errno = EINVAL;
         break;
     }
 
-    if (fd < 0) {
+    if (!fd.ok()) {
         jniThrowErrnoException(env, "nativeBpfFdGet", errno);
         return -1;
     }
@@ -62,18 +63,16 @@
     if (bpf::isAtLeastKernelVersion(4, 14, 0)) {
         // These likely fail with -1 and set errno to EINVAL on <4.14
         if (bpf::bpfGetFdKeySize(fd) != keySize) {
-            close(fd);
             jniThrowErrnoException(env, "nativeBpfFdGet KeySize", EBADFD);
             return -1;
         }
         if (bpf::bpfGetFdValueSize(fd) != valueSize) {
-            close(fd);
             jniThrowErrnoException(env, "nativeBpfFdGet ValueSize", EBADFD);
             return -1;
         }
     }
 
-    return fd;
+    return fd.release();
 }
 
 static void com_android_net_module_util_BpfMap_nativeWriteToMapEntry(JNIEnv *env, jobject self,
diff --git a/staticlibs/native/serviceconnectivityjni/Android.bp b/staticlibs/native/serviceconnectivityjni/Android.bp
new file mode 100644
index 0000000..18246dd
--- /dev/null
+++ b/staticlibs/native/serviceconnectivityjni/Android.bp
@@ -0,0 +1,49 @@
+// 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_fwk_core_networking",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+cc_library_static {
+    name: "libserviceconnectivityjni",
+    srcs: [
+        "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",
+        "-Wno-unused-parameter",
+    ],
+    sdk_version: "current",
+    min_sdk_version: "30",
+    apex_available: [
+        "com.android.tethering",
+        "//apex_available:platform",
+    ],
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
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/tests/unit/Android.bp b/staticlibs/tests/unit/Android.bp
index 8c54e6a..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",
@@ -55,6 +56,7 @@
         // For mockito extended
         "libdexmakerjvmtiagent",
         "libstaticjvmtiagent",
+        "libcom_android_net_moduletests_util_jni",
     ],
     jarjar_rules: "jarjar-rules.txt",
     test_suites: ["device-tests"],
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index b5a941b..419b338 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -25,8 +25,14 @@
     get_apf_capabilities,
     get_apf_counter,
     get_apf_counters_from_dumpsys,
+    get_ipv4_addresses,
+    get_ipv6_addresses,
     get_hardware_address,
     is_send_raw_packet_downstream_supported,
+    is_packet_capture_supported,
+    start_capture_packets,
+    stop_capture_packets,
+    get_matched_packet_counts,
     send_raw_packet_downstream,
 )
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
@@ -112,6 +118,55 @@
       get_hardware_address(self.mock_ad, "wlan0")
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv4_addresses_success(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = """
+54: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+    inet 192.168.195.162/24 brd 192.168.195.255 scope global wlan0
+       valid_lft forever preferred_lft forever
+    inet 192.168.200.1/24 brd 192.168.200.255 scope global wlan0
+       valid_lft forever preferred_lft forever
+"""
+    ip_addresses = get_ipv4_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, ["192.168.195.162", "192.168.200.1"])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv4_addresses_not_found(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = ""
+    ip_addresses = get_ipv4_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, [])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv6_addresses_success(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = """
+54: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+    inet6 fe80::10a3:5dff:fe52:de32/64 scope link
+        valid_lft forever preferred_lft forever
+    inet6 2001:b400:e53f:164e:9c1e:780e:d1:4658/64 scope global dynamic mngtmpaddr noprefixroute
+        valid_lft 6995sec preferred_lft 6995sec
+    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")
+    asserts.assert_equal(ip_addresses,
+                         ["fe80::10a3:5dff:fe52:de32",
+                          "2001:b400:e53f:164e:9c1e:780e:d1:4658",
+                          "fe80::3aff:2199:2d8e:20d1"])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv6_address_not_found(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = ""
+    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, [])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
   def test_send_raw_packet_downstream_success(
       self, mock_adb_shell: MagicMock
   ) -> None:
@@ -157,6 +212,144 @@
         "Send raw packet should not be supported.",
     )
 
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "success"  # Successful command output
+      start_capture_packets(
+          self.mock_ad, TEST_IFACE_NAME
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture start"
+          f" {TEST_IFACE_NAME}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          start_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Start capturing packets should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          start_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Start capturing packets should not be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "success"  # Successful command output
+      stop_capture_packets(
+          self.mock_ad, TEST_IFACE_NAME
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture stop"
+          f" {TEST_IFACE_NAME}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          stop_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Stop capturing packets should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          stop_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Stop capturing packets should not be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "10"  # Successful command output
+      get_matched_packet_counts(
+          self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture matched-packet-counts"
+          f" {TEST_IFACE_NAME} {TEST_PACKET_IN_HEX}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          get_matched_packet_counts(
+              self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Get matched packet counts should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          get_matched_packet_counts(
+              self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Get matched packet counts should not be supported.",
+      )
+
   @parameterized.parameters(
       ("2,2048,1", ApfCapabilities(2, 2048, 1)),  # Valid input
       ("3,1024,0", ApfCapabilities(3, 1024, 0)),  # Valid input
diff --git a/staticlibs/tests/unit/host/python/assert_utils_test.py b/staticlibs/tests/unit/host/python/assert_utils_test.py
index 7a33373..1d85a12 100644
--- a/staticlibs/tests/unit/host/python/assert_utils_test.py
+++ b/staticlibs/tests/unit/host/python/assert_utils_test.py
@@ -14,7 +14,9 @@
 
 from mobly import asserts
 from mobly import base_test
-from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError, expect_with_retry
+from net_tests_utils.host.python.assert_utils import (
+    UnexpectedBehaviorError, UnexpectedExceptionError, expect_with_retry, expect_throws
+)
 
 
 class TestAssertUtils(base_test.BaseTestClass):
@@ -92,3 +94,22 @@
           retry_interval_sec=0,
       )
     asserts.assert_true(retry_action_called, "retry_action not called.")
+
+  def test_expect_exception_throws(self):
+      def raise_unexpected_behavior_error():
+          raise UnexpectedBehaviorError()
+
+      expect_throws(raise_unexpected_behavior_error, UnexpectedBehaviorError)
+
+  def test_unexpect_exception_throws(self):
+      def raise_value_error():
+          raise ValueError()
+
+      with asserts.assert_raises(UnexpectedExceptionError):
+          expect_throws(raise_value_error, UnexpectedBehaviorError)
+
+  def test_no_exception_throws(self):
+      def raise_no_error():
+          return
+
+      expect_throws(raise_no_error, UnexpectedBehaviorError)
\ No newline at end of file
diff --git a/staticlibs/tests/unit/host/python/packet_utils_test.py b/staticlibs/tests/unit/host/python/packet_utils_test.py
new file mode 100644
index 0000000..8ad9576
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/packet_utils_test.py
@@ -0,0 +1,72 @@
+#  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 fa6a310..498dbaf 100644
--- a/staticlibs/tests/unit/host/python/run_tests.py
+++ b/staticlibs/tests/unit/host/python/run_tests.py
@@ -18,6 +18,7 @@
 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
 
 
@@ -31,5 +32,5 @@
   sys.argv.pop(1)
   # TODO: make the tests can be executed without manually list classes.
   suite_runner.run_suite(
-      [TestAssertUtils, TestAdbUtils, TestApfUtils], sys.argv
+      [TestAssertUtils, TestAdbUtils, TestApfUtils, TestPacketUtils], sys.argv
   )
diff --git a/staticlibs/tests/unit/jni/Android.bp b/staticlibs/tests/unit/jni/Android.bp
new file mode 100644
index 0000000..c444159
--- /dev/null
+++ b/staticlibs/tests/unit/jni/Android.bp
@@ -0,0 +1,39 @@
+// 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_fwk_core_networking",
+    // See: http://go/android-license-faq
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+cc_library_shared {
+    name: "libcom_android_net_moduletests_util_jni",
+    cflags: [
+        "-Wall",
+        "-Werror",
+        "-Wno-unused-parameter",
+        "-Wthread-safety",
+    ],
+    srcs: [
+        "com_android_net_moduletests_util/onload.cpp",
+    ],
+    static_libs: [
+        "libserviceconnectivityjni",
+    ],
+    shared_libs: [
+        "liblog",
+        "libnativehelper",
+    ],
+}
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
new file mode 100644
index 0000000..af4810f
--- /dev/null
+++ b/staticlibs/tests/unit/jni/com_android_net_moduletests_util/onload.cpp
@@ -0,0 +1,42 @@
+/*
+ * 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 "jni.h"
+#include <nativehelper/JNIHelp.h>
+
+#define LOG_TAG "NetworkStaticLibTestsJni"
+#include <android/log.h>
+
+namespace android {
+
+int register_com_android_net_module_util_ServiceConnectivityJni(JNIEnv *env,
+                                                      char const *class_name);
+
+extern "C" jint JNI_OnLoad(JavaVM *vm, void *) {
+  JNIEnv *env;
+  if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
+    __android_log_print(ANDROID_LOG_FATAL, LOG_TAG, "ERROR: GetEnv failed");
+    return JNI_ERR;
+  }
+
+  if (register_com_android_net_module_util_ServiceConnectivityJni(
+          env, "com/android/net/moduletests/util/ServiceConnectivityJni") < 0)
+    return JNI_ERR;
+
+  return JNI_VERSION_1_6;
+}
+
+}; // namespace android
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/CleanupTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/CleanupTest.kt
index 851d09a..bde55c3 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/CleanupTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/CleanupTest.kt
@@ -17,14 +17,19 @@
 package com.android.net.module.util
 
 import android.util.Log
+import com.android.testutils.TryTestConfig
 import com.android.testutils.tryTest
+import java.util.function.Consumer
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import org.junit.After
+import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.junit.runners.JUnit4
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-import kotlin.test.assertTrue
-import kotlin.test.fail
 
 private val TAG = CleanupTest::class.simpleName
 
@@ -34,6 +39,18 @@
     class TestException2 : Exception()
     class TestException3 : Exception()
 
+    private var originalDiagnosticsCollector: Consumer<Throwable>? = null
+
+    @Before
+    fun setUp() {
+        originalDiagnosticsCollector = TryTestConfig.swapDiagnosticsCollector(null)
+    }
+
+    @After
+    fun tearDown() {
+        TryTestConfig.swapDiagnosticsCollector(originalDiagnosticsCollector)
+    }
+
     @Test
     fun testNotThrow() {
         var x = 1
@@ -220,4 +237,74 @@
         assertTrue(thrown.suppressedExceptions[1] is TestException3)
         assert(x == 7)
     }
+
+    @Test
+    fun testNoErrorReportingWhenCaught() {
+        var error: Throwable? = null
+        TryTestConfig.swapDiagnosticsCollector {
+            error = it
+        }
+        var x = 1
+        tryTest {
+            x = 2
+            throw TestException1()
+            x = 3
+        }.catch<TestException1> {
+            x = 4
+        } cleanup {
+            x = 5
+        }
+
+        assertEquals(5, x)
+        assertNull(error)
+    }
+
+    @Test
+    fun testErrorReportingInTry() {
+        var error: Throwable? = null
+        TryTestConfig.swapDiagnosticsCollector {
+            assertNull(error)
+            error = it
+        }
+        var x = 1
+        assertFailsWith<TestException1> {
+            tryTest {
+                x = 2
+                throw TestException1()
+                x = 3
+            } cleanupStep {
+                throw TestException2()
+                x = 4
+            } cleanup {
+                x = 5
+            }
+        }
+
+        assertEquals(5, x)
+        assertTrue(error is TestException1)
+    }
+
+    @Test
+    fun testErrorReportingInCatch() {
+        var error: Throwable? = null
+        TryTestConfig.swapDiagnosticsCollector {
+            assertNull(error)
+            error = it
+        }
+        var x = 1
+        assertFailsWith<TestException2> {
+            tryTest {
+                throw TestException1()
+                x = 2
+            }.catch<TestException1> {
+                throw TestException2()
+                x = 3
+            } cleanup {
+                x = 4
+            }
+        }
+
+        assertEquals(4, x)
+        assertTrue(error is TestException2)
+    }
 }
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 4ed3afd..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
@@ -183,17 +183,101 @@
 
     @Test
     fun testGetIndexForValue() {
-        val sparseArray = SparseArray<String>();
-        sparseArray.put(5, "hello");
-        sparseArray.put(10, "abcd");
-        sparseArray.put(20, null);
+        val sparseArray = SparseArray<String>()
+        sparseArray.put(5, "hello")
+        sparseArray.put(10, "abcd")
+        sparseArray.put(20, null)
 
-        val value1 = "abcd";
+        val value1 = "abcd"
         val value1Copy = String(value1.toCharArray())
-        val value2 = null;
+        val value2 = null
 
-        assertEquals(1, CollectionUtils.getIndexForValue(sparseArray, value1));
-        assertEquals(1, CollectionUtils.getIndexForValue(sparseArray, value1Copy));
-        assertEquals(2, CollectionUtils.getIndexForValue(sparseArray, value2));
+        assertEquals(1, CollectionUtils.getIndexForValue(sparseArray, value1))
+        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/HandlerUtilsTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
index f2c902f..845a2c3 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
@@ -19,11 +19,14 @@
 import android.os.HandlerThread
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.DevSdkIgnoreRunner.MonitorThreadLeak
+import com.android.testutils.waitForIdle
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import org.junit.After
 import org.junit.Test
 import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
 
 const val THREAD_BLOCK_TIMEOUT_MS = 1000L
 const val TEST_REPEAT_COUNT = 100
@@ -52,6 +55,24 @@
         }
     }
 
+    @Test
+    fun testIsRunningOnHandlerThread() {
+        assertFalse(HandlerUtils.isRunningOnHandlerThread(handler))
+        handler.post{
+            assertTrue(HandlerUtils.isRunningOnHandlerThread(handler))
+        }
+        handler.waitForIdle(THREAD_BLOCK_TIMEOUT_MS)
+    }
+
+    @Test
+    fun testEnsureRunningOnHandlerThread() {
+        assertFailsWith<IllegalStateException>{ HandlerUtils.ensureRunningOnHandlerThread(handler) }
+        handler.post{
+            HandlerUtils.ensureRunningOnHandlerThread(handler)
+        }
+        handler.waitForIdle(THREAD_BLOCK_TIMEOUT_MS)
+    }
+
     @After
     fun tearDown() {
         handlerThread.quitSafely()
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/HexDumpTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/HexDumpTest.java
index 5a15585..f81978a 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/HexDumpTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/HexDumpTest.java
@@ -18,6 +18,7 @@
 
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
@@ -50,6 +51,11 @@
     }
 
     @Test
+    public void testInvalidHexStringToByteArray() {
+        assertThrows(IllegalArgumentException.class, () -> HexDump.hexStringToByteArray("abxX"));
+    }
+
+    @Test
     public void testIntegerToByteArray() {
         assertArrayEquals(new byte[]{(byte) 0xff, (byte) 0x00, (byte) 0x00, (byte) 0x04},
                 HexDump.toByteArray((int) 0xff000004));
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/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
index b04561c..035ce0f 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
@@ -16,7 +16,9 @@
 
 package com.android.net.module.util
 
+import android.content.Context
 import android.net.INetd
+import android.net.Network
 import android.os.Build
 import android.util.Log
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
@@ -34,7 +36,9 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
 class RoutingCoordinatorServiceTest {
     val mNetd = mock(INetd::class.java)
-    val mService = RoutingCoordinatorService(mNetd)
+    val mGetAllNetworksSupplier = { emptyArray<Network>() }
+    val mContext = mock(Context::class.java)
+    val mService = RoutingCoordinatorService(mNetd, mGetAllNetworksSupplier, mContext)
 
     @Test
     fun testInterfaceForward() {
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/netlink/RtNetlinkLinkMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
index 9db63db..8104e3a 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
@@ -24,24 +24,28 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
 
 import android.net.MacAddress;
 import android.system.OsConstants;
 
 import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.net.module.util.HexDump;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 
-@RunWith(AndroidJUnit4.class)
+@RunWith(MockitoJUnitRunner.class)
 @SmallTest
 public class RtNetlinkLinkMessageTest {
+    @Mock
+    private OsAccess mOsAccess;
 
     // An example of the full RTM_NEWLINK message.
     private static final String RTM_NEWLINK_HEX =
@@ -124,14 +128,14 @@
     }
 
     private static final String RTM_NEWLINK_PACK_HEX =
-            "34000000100000000000000000000000"   // struct nlmsghr
+            "40000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
             + "08000400DC050000"                 // IFLA_MTU
             + "0A00010092C3E3C9374E0000"         // IFLA_ADDRESS
             + "0A000300776C616E30000000";        // IFLA_IFNAME(wlan0)
 
     @Test
-    public void testPackRtmNewLink() {
+    public void testParseAndPackRtmNewLink() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_PACK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
         final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
@@ -145,6 +149,21 @@
         assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBuffer.array()));
     }
 
+    @Test
+    public void testPackRtmNewLink() {
+        final RtNetlinkLinkMessage linkMsg = RtNetlinkLinkMessage.build(
+                // nlmsg_len will be updated inside create() method, so it's ok to set 0 here.
+                new StructNlMsgHdr(0 /*nlmsg_len*/, (short) 0x10, (short) 0, 0),
+                new StructIfinfoMsg((byte) 0, (short) 1, 0x1e, 0x1002, 0),
+                1500,
+                MacAddress.fromString("92:c3:e3:c9:37:4e"),
+                "wlan0");
+        assertNotNull(linkMsg);
+
+        final byte[] packBytes = linkMsg.pack(ByteOrder.LITTLE_ENDIAN);
+        assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBytes));
+    }
+
     private static final String RTM_NEWLINK_TRUNCATED_HEX =
             "54000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
@@ -171,6 +190,144 @@
     }
 
     @Test
+    public void testCreateSetLinkUpMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                + "00000000080000000100000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = true;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkDownMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                        + "00000000080000000000000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkStateMessage_InvalidInterface() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNull(msg);
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage() {
+        final String expectedHexBytes =
+                "2C000000100005006824000000000000"   // struct nlmsghdr
+                + "00000000080000000000000000000000" // struct ifinfomsg
+                + "0A000300776C616E31000000";        // IFLA_IFNAME(wlan1)
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InterfaceNotFound() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InvalidNewName() {
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final String[] invalidNames = {"", "interface_name_longer_than_limit"};
+        for (String invalidName : invalidNames) {
+            assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                    interfaceName, sequenceNumber, invalidName, mOsAccess));
+        }
+    }
+
+    @Test
+    public void testCreateGetLinkMessage() {
+        final String expectedHexBytes =
+                "20000000120001006824000000000000"    // struct nlmsghdr
+                + "00000000080000000000000000000000"; // 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.createGetLinkMessage(
+                interfaceName, sequenceNumber, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetInterfaceFlagsMessage() {
+        final String expectedHexBytes =
+                "20000000100001006824000000000000"    // 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 testToString() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkPrefixMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkPrefixMessageTest.java
new file mode 100644
index 0000000..b1779cb
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkPrefixMessageTest.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import static android.system.OsConstants.NETLINK_ROUTE;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import android.net.IpPrefix;
+import android.system.OsConstants;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.net.module.util.HexDump;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RtNetlinkPrefixMessageTest {
+    private static final IpPrefix TEST_PREFIX = new IpPrefix("2001:db8:1:1::/64");
+
+    // An example of the full RTM_NEWPREFIX message.
+    private static final String RTM_NEWPREFIX_HEX =
+            "3C000000340000000000000000000000"            // struct nlmsghr
+            + "0A0000002F00000003400300"                  // struct prefixmsg
+            + "1400010020010DB8000100010000000000000000"  // PREFIX_ADDRESS
+            + "0C000200803A0900008D2700";                 // PREFIX_CACHEINFO
+
+    private ByteBuffer toByteBuffer(final String hexString) {
+        return ByteBuffer.wrap(HexDump.hexStringToByteArray(hexString));
+    }
+
+    @Test
+    public void testParseRtmNewPrefix() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNotNull(msg);
+        assertTrue(msg instanceof RtNetlinkPrefixMessage);
+        final RtNetlinkPrefixMessage prefixmsg = (RtNetlinkPrefixMessage) msg;
+
+        final StructNlMsgHdr hdr = prefixmsg.getHeader();
+        assertNotNull(hdr);
+        assertEquals(60, hdr.nlmsg_len);
+        assertEquals(NetlinkConstants.RTM_NEWPREFIX, hdr.nlmsg_type);
+        assertEquals(0, hdr.nlmsg_flags);
+        assertEquals(0, hdr.nlmsg_seq);
+        assertEquals(0, hdr.nlmsg_pid);
+
+        final StructPrefixMsg prefixmsgHdr = prefixmsg.getPrefixMsg();
+        assertNotNull(prefixmsgHdr);
+        assertEquals((byte) OsConstants.AF_INET6, prefixmsgHdr.prefix_family);
+        assertEquals(3, prefixmsgHdr.prefix_type);
+        assertEquals(64, prefixmsgHdr.prefix_len);
+        assertEquals(0x03, prefixmsgHdr.prefix_flags);
+        assertEquals(0x2F, prefixmsgHdr.prefix_ifindex);
+
+        assertEquals(prefixmsg.getPrefix(), TEST_PREFIX);
+        assertEquals(604800L, prefixmsg.getPreferredLifetime());
+        assertEquals(2592000L, prefixmsg.getValidLifetime());
+    }
+
+    @Test
+    public void testPackRtmNewPrefix() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNotNull(msg);
+        assertTrue(msg instanceof RtNetlinkPrefixMessage);
+        final RtNetlinkPrefixMessage prefixmsg = (RtNetlinkPrefixMessage) msg;
+
+        final ByteBuffer packBuffer = ByteBuffer.allocate(60);
+        packBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        prefixmsg.pack(packBuffer);
+        assertEquals(RTM_NEWPREFIX_HEX, HexDump.toHexString(packBuffer.array()));
+    }
+
+    private static final String RTM_NEWPREFIX_WITHOUT_PREFIX_ADDRESS_HEX =
+            "24000000340000000000000000000000"            // struct nlmsghr
+            + "0A0000002F00000003400300"                  // struct prefixmsg
+            + "0C000200803A0900008D2700";                 // PREFIX_CACHEINFO
+
+    @Test
+    public void testParseRtmNewPrefix_withoutPrefixAddressAttribute() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_WITHOUT_PREFIX_ADDRESS_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNull(msg);
+    }
+
+    private static final String RTM_NEWPREFIX_WITHOUT_PREFIX_CACHEINFO_HEX =
+            "30000000340000000000000000000000"             // struct nlmsghr
+            + "0A0000002F00000003400300"                   // struct prefixmsg
+            + "140001002A0079E10ABCF6050000000000000000";  // PREFIX_ADDRESS
+
+    @Test
+    public void testParseRtmNewPrefix_withoutPrefixCacheinfoAttribute() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_WITHOUT_PREFIX_CACHEINFO_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNull(msg);
+    }
+
+    private static final String RTM_NEWPREFIX_TRUNCATED_PREFIX_ADDRESS_HEX =
+            "3C000000340000000000000000000000"            // struct nlmsghr
+            + "0A0000002F00000003400300"                  // struct prefixmsg
+            + "140001002A0079E10ABCF605000000000000"      // PREFIX_ADDRESS (truncated)
+            + "0C000200803A0900008D2700";                 // PREFIX_CACHEINFO
+
+    @Test
+    public void testParseRtmNewPrefix_truncatedPrefixAddressAttribute() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_TRUNCATED_PREFIX_ADDRESS_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNull(msg);
+    }
+
+    private static final String RTM_NEWPREFIX_TRUNCATED_PREFIX_CACHEINFO_HEX =
+            "3C000000340000000000000000000000"            // struct nlmsghr
+            + "0A0000002F00000003400300"                  // struct prefixmsg
+            + "140001002A0079E10ABCF6050000000000000000"  // PREFIX_ADDRESS
+            + "0C000200803A0900008D";                     // PREFIX_CACHEINFO (truncated)
+
+    @Test
+    public void testParseRtmNewPrefix_truncatedPrefixCacheinfoAttribute() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_TRUNCATED_PREFIX_CACHEINFO_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNull(msg);
+    }
+
+    @Test
+    public void testToString() {
+        final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWPREFIX_HEX);
+        byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+        final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
+        assertNotNull(msg);
+        assertTrue(msg instanceof RtNetlinkPrefixMessage);
+        final RtNetlinkPrefixMessage prefixmsg = (RtNetlinkPrefixMessage) msg;
+        final String expected = "RtNetlinkPrefixMessage{ "
+                + "nlmsghdr{StructNlMsgHdr{ nlmsg_len{60}, nlmsg_type{52(RTM_NEWPREFIX)}, "
+                + "nlmsg_flags{0()}, nlmsg_seq{0}, nlmsg_pid{0} }}, "
+                + "prefixmsg{prefix_family: 10, prefix_ifindex: 47, prefix_type: 3, "
+                + "prefix_len: 64, prefix_flags: 3}, "
+                + "IP Prefix{2001:db8:1:1::/64}, "
+                + "preferred lifetime{604800}, valid lifetime{2592000} }";
+        assertEquals(expected, prefixmsg.toString());
+    }
+}
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 8c71a91..ec486fb 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -33,6 +33,7 @@
     ],
     static_libs: [
         "androidx.test.ext.junit",
+        "collector-device-lib",
         "kotlin-reflect",
         "libnanohttpd",
         "net-tests-utils-host-device-common",
@@ -92,17 +93,15 @@
     libs: ["tradefed"],
     test_suites: [
         "ats",
+        "automotive-general-tests",
         "device-tests",
         "general-tests",
         "cts",
         "mts-networking",
-        "mcts-networking",
         "mts-tethering",
         "mcts-tethering",
-        "mcts-wifi",
-        "mcts-dnsresolver",
     ],
-    data: [":ConnectivityTestPreparer"],
+    device_common_data: [":ConnectivityTestPreparer"],
 }
 
 python_library_host {
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
new file mode 100644
index 0000000..78b34a8
--- /dev/null
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
@@ -0,0 +1,138 @@
+/*
+ * 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.testutils.connectivitypreparer
+
+import android.Manifest.permission.MODIFY_PHONE_STATE
+import android.Manifest.permission.READ_PHONE_STATE
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager.FEATURE_TELEPHONY_IMS
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.os.Build
+import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+import android.os.ParcelFileDescriptor
+import android.os.PersistableBundle
+import android.telephony.CarrierConfigManager
+import android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED
+import android.telephony.SubscriptionManager
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.ArrayTrackRecord
+import com.android.testutils.runAsShell
+import kotlin.test.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val CONFIG_CHANGE_TIMEOUT_MS = 10_000L
+private val TAG = CarrierConfigSetupTest::class.simpleName
+
+@RunWith(AndroidJUnit4::class)
+class CarrierConfigSetupTest {
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val pm by lazy { context.packageManager }
+    private val carrierConfigManager by lazy {
+        context.getSystemService(CarrierConfigManager::class.java)
+    }
+
+    @Test
+    fun testSetCarrierConfig() {
+        if (!shouldDisableIwlan()) return
+        overrideAllSubscriptions(PersistableBundle().apply {
+            putBoolean(CarrierConfigManager.KEY_CARRIER_WFC_IMS_AVAILABLE_BOOL, false)
+        })
+    }
+
+    @Test
+    fun testClearCarrierConfig() {
+        // set/clear are in different test runs so it is difficult to share state between them.
+        // The conditions to disable IWLAN should not change over time (in particular
+        // force_iwlan_mms is a readonly flag), so just perform the same check again on teardown.
+        // CarrierConfigManager overrides are cleared on reboot by default anyway, so any missed
+        // cleanup should not be too damaging.
+        if (!shouldDisableIwlan()) return
+        overrideAllSubscriptions(null)
+    }
+
+    private class ConfigChangedReceiver : BroadcastReceiver() {
+        val receivedSubIds = ArrayTrackRecord<Int>()
+        override fun onReceive(context: Context, intent: Intent) {
+            if (intent.action != ACTION_CARRIER_CONFIG_CHANGED) return
+            val subIdx = intent.getIntExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, -1)
+            // It is possible this is a configuration change for a different setting, so the test
+            // may not wait for long enough. Unfortunately calling CarrierConfigManager to check
+            // if the config was applied does not help because it will always return the override,
+            // even if it was not applied to the subscription yet.
+            // In practice, it is very unlikely that a different broadcast arrives, and then a test
+            // flakes because of the iwlan behavior in the time it takes for the config to be
+            // applied.
+            Log.d(TAG, "Received config change for sub $subIdx")
+            receivedSubIds.add(subIdx)
+        }
+    }
+
+    private fun overrideAllSubscriptions(bundle: PersistableBundle?) {
+        runAsShell(READ_PHONE_STATE, MODIFY_PHONE_STATE) {
+            val receiver = ConfigChangedReceiver()
+            context.registerReceiver(receiver, IntentFilter(ACTION_CARRIER_CONFIG_CHANGED))
+            val subscriptions = context.getSystemService(SubscriptionManager::class.java)
+                .activeSubscriptionInfoList
+            subscriptions?.forEach { subInfo ->
+                Log.d(TAG, "Overriding config for subscription $subInfo")
+                carrierConfigManager.overrideConfig(subInfo.subscriptionId, bundle)
+            }
+            // Don't wait after each update before the next one, but expect all updates to be done
+            // eventually
+            subscriptions?.forEach { subInfo ->
+                assertNotNull(receiver.receivedSubIds.poll(CONFIG_CHANGE_TIMEOUT_MS, pos = 0) {
+                    it == subInfo.subscriptionId
+                }, "Config override broadcast not received for subscription $subInfo")
+            }
+        }
+    }
+
+    private fun shouldDisableIwlan(): Boolean {
+        // IWLAN on U 24Q2 release (U QPR3) causes cell data to reconnect when Wi-Fi is toggled due
+        // to the implementation of the force_iwlan_mms feature, which does not work well with
+        // multinetworking tests. Disable the feature on such builds (b/368477391).
+        // The behavior changed in more recent releases (V) so only U 24Q2 is affected.
+        return pm.hasSystemFeature(FEATURE_TELEPHONY_IMS) && pm.hasSystemFeature(FEATURE_WIFI) &&
+                Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE &&
+                isForceIwlanMmsEnabled()
+    }
+
+    private fun isForceIwlanMmsEnabled(): Boolean {
+        val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+        val flagEnabledRegex = Regex(
+            """telephony/com\.android\.internal\.telephony\.flags\.force_iwlan_mms:""" +
+                    """.*ENABLED \(system\)""")
+        ParcelFileDescriptor.AutoCloseInputStream(
+            // If the command fails (for example if printflags is missing) this will return false
+            // and the IWLAN disable will be skipped, which should be fine at it only helps with
+            // flakiness.
+            // This uses "sh -c" to cover that case as if "printflags" is used directly and the
+            // binary is missing, the remote end will crash and the InputStream EOF is never
+            // reached, so the read would hang.
+            uiAutomation.executeShellCommand("sh -c printflags")).bufferedReader().use { reader ->
+                return reader.lines().anyMatch {
+                    it.contains(flagEnabledRegex)
+                }
+        }
+    }
+}
\ No newline at end of file
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 e634f0e..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
@@ -16,27 +16,180 @@
 
 package com.android.testutils.connectivitypreparer
 
+import android.Manifest.permission.NETWORK_SETTINGS
 import android.content.pm.PackageManager.FEATURE_TELEPHONY
 import android.content.pm.PackageManager.FEATURE_WIFI
+import android.net.InetAddresses.parseNumericAddress
+import android.net.Network
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
 import android.telephony.TelephonyManager
+import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.HexDump
+import com.android.net.module.util.NetworkStackConstants.IPV4_ADDR_ANY
+import com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_ANY
+import com.android.testutils.AutoReleaseNetworkCallbackRule
 import com.android.testutils.ConnectUtil
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.runAsShell
+import com.android.testutils.tryTest
+import java.io.IOException
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.util.Random
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
 import kotlin.test.fail
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
+private const val QUIC_SOCKET_TIMEOUT_MS = 5_000
+private const val QUIC_RETRY_COUNT = 5
+
 @RunWith(AndroidJUnit4::class)
 class ConnectivityCheckTest {
+    @get:Rule
+    val networkCallbackRule = AutoReleaseNetworkCallbackRule()
+
+    private val logTag = ConnectivityCheckTest::class.simpleName
     private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
     private val pm by lazy { context.packageManager }
     private val connectUtil by lazy { ConnectUtil(context) }
 
+    // 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)
+    )
+
     @Test
     fun testCheckWifiSetup() {
         if (!pm.hasSystemFeature(FEATURE_WIFI)) return
         connectUtil.ensureWifiValidated()
+
+        val (wifiNetwork, wifiSsid) = runAsShell(NETWORK_SETTINGS) {
+            val cb = networkCallbackRule.requestNetwork(
+                NetworkRequest.Builder()
+                    .addTransportType(TRANSPORT_WIFI)
+                    .addCapability(NET_CAPABILITY_INTERNET)
+                    .build()
+            )
+            val capChanged = cb.eventuallyExpect<CapabilitiesChanged>(from = 0)
+            val network = capChanged.network
+            val ssid = capChanged.caps.ssid
+            assertFalse(ssid.isNullOrEmpty(), "No SSID for wifi network $network")
+            // Expect a global IPv6 address, and native or stacked IPv4
+            val lpChange = cb.history.poll(
+                pos = 0,
+                timeoutMs = 30_000L
+            ) {
+                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 and default route" +
+                        if (ipv6Unsupported(ssid)) {
+                            ""
+                        } else {
+                            " and a global IPv6 address and default route"
+                        }
+            )
+
+            Pair(network, ssid)
+        }
+
+        // Checking QUIC is more important on Wi-Fi than cellular, as it finds firewall
+        // configuration problems on Wi-Fi, but cellular is not actionable by the test lab.
+        checkQuic(wifiNetwork, wifiSsid, ipv6 = false)
+        if (!ipv6Unsupported(wifiSsid)) {
+            checkQuic(wifiNetwork, wifiSsid, ipv6 = true)
+        }
+    }
+
+    /**
+     * Check that QUIC is working on the specified network.
+     *
+     * Some tests require QUIC (UDP), and some lab networks have been observed to not let it
+     * through due to firewalling. Ensure that devices are setup on a network that has the proper
+     * allowlists before trying to run the tests.
+     */
+    private fun checkQuic(network: Network, ssid: String, ipv6: Boolean) {
+        // Same endpoint as used in MultinetworkApiTest in CTS
+        val hostname = "connectivitycheck.android.com"
+        val targetAddrs = network.getAllByName(hostname)
+        val bindAddr = if (ipv6) IPV6_ADDR_ANY else IPV4_ADDR_ANY
+        if (targetAddrs.isEmpty()) {
+            Log.d(logTag, "No addresses found for $hostname")
+            return
+        }
+
+        val socket = DatagramSocket(0, bindAddr)
+        tryTest {
+            socket.soTimeout = QUIC_SOCKET_TIMEOUT_MS
+            network.bindSocket(socket)
+
+            // For reference see Version-Independent Properties of QUIC:
+            // https://datatracker.ietf.org/doc/html/rfc8999
+            // This packet just contains a long header with an unsupported version number, to force
+            // a version-negotiation packet in response.
+            val connectionId = ByteArray(8).apply { Random().nextBytes(this) }
+            val quicData = byteArrayOf(
+                // long header
+                0xc0.toByte(),
+                // version number (should be an unknown version for the server)
+                0xaa.toByte(), 0xda.toByte(), 0xca.toByte(), 0xca.toByte(),
+                // destination connection ID length
+                0x08,
+            ) + connectionId + byteArrayOf(
+                // source connection ID length
+                0x00,
+            ) + ByteArray(1185) // Ensure the packet is 1200 bytes long
+            val targetAddr = targetAddrs.firstOrNull { it.javaClass == bindAddr.javaClass }
+                ?: fail("No ${bindAddr.javaClass} found for $hostname " +
+                        "(got ${targetAddrs.joinToString()})")
+            repeat(QUIC_RETRY_COUNT) { i ->
+                socket.send(DatagramPacket(quicData, quicData.size, targetAddr, 443))
+
+                val receivedPacket = DatagramPacket(ByteArray(1500), 1500)
+                try {
+                    socket.receive(receivedPacket)
+                } catch (e: IOException) {
+                    Log.d(logTag, "No response from $hostname ($targetAddr) on QUIC try $i", e)
+                    return@repeat
+                }
+
+                val receivedConnectionId = receivedPacket.data.copyOfRange(7, 7 + 8)
+                if (connectionId.contentEquals(receivedConnectionId)) {
+                    return@tryTest
+                } else {
+                    val headerBytes = receivedPacket.data.copyOfRange(
+                        0, receivedPacket.length.coerceAtMost(15))
+                    Log.d(logTag, "Received invalid connection ID on QUIC try $i: " +
+                            HexDump.toHexString(headerBytes))
+                }
+            }
+            fail("QUIC is not working on SSID $ssid connecting to $targetAddr " +
+                    "with local source port ${socket.localPort}: check the firewall for UDP port " +
+                    "443 access."
+            )
+        } cleanup {
+            socket.close()
+        }
     }
 
     @Test
@@ -53,12 +206,16 @@
         if (tm.simState == TelephonyManager.SIM_STATE_ABSENT) {
             fail("The device has no SIM card inserted. $commonError")
         } else if (tm.simState != TelephonyManager.SIM_STATE_READY) {
-            fail("The device is not setup with a usable SIM card. Sim state was ${tm.simState}. " +
-                    commonError)
+            fail(
+                "The device is not setup with a usable SIM card. Sim state was ${tm.simState}. " +
+                    commonError
+            )
         }
-        assertTrue(tm.isDataConnectivityPossible,
+        assertTrue(
+            tm.isDataConnectivityPossible,
             "The device has a SIM card, but it does not supports data connectivity. " +
-            "Check the data plan, and verify that mobile data is working. " + commonError)
+            "Check the data plan, and verify that mobile data is working. " + commonError
+        )
         connectUtil.ensureCellularValidated()
     }
 }
diff --git a/staticlibs/testutils/devicetests/NSResponder.kt b/staticlibs/testutils/devicetests/NSResponder.kt
index f7619cd..f094407 100644
--- a/staticlibs/testutils/devicetests/NSResponder.kt
+++ b/staticlibs/testutils/devicetests/NSResponder.kt
@@ -35,12 +35,12 @@
 private const val NS_TYPE = 135.toShort()
 
 /**
- * A class that can be used to reply to Neighbor Solicitation packets on a [TapPacketReader].
+ * A class that can be used to reply to Neighbor Solicitation packets on a [PollPacketReader].
  */
 class NSResponder(
-    reader: TapPacketReader,
-    table: Map<Inet6Address, MacAddress>,
-    name: String = NSResponder::class.java.simpleName
+        reader: PollPacketReader,
+        table: Map<Inet6Address, MacAddress>,
+        name: String = NSResponder::class.java.simpleName
 ) : PacketResponder(reader, Icmpv6Filter(), name) {
     companion object {
         private val TAG = NSResponder::class.simpleName
@@ -49,7 +49,7 @@
     // Copy the map if not already immutable (toMap) to make sure it is not modified
     private val table = table.toMap()
 
-    override fun replyToPacket(packet: ByteArray, reader: TapPacketReader) {
+    override fun replyToPacket(packet: ByteArray, reader: PollPacketReader) {
         if (packet.size < IPV6_HEADER_LENGTH) {
             return
         }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt b/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
index cf0490c..f4c8657 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
@@ -30,17 +30,17 @@
 private val ARP_REPLY_IPV4 = byteArrayOf(0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x02)
 
 /**
- * A class that can be used to reply to ARP packets on a [TapPacketReader].
+ * A class that can be used to reply to ARP packets on a [PollPacketReader].
  */
 class ArpResponder(
-    reader: TapPacketReader,
-    table: Map<Inet4Address, MacAddress>,
-    name: String = ArpResponder::class.java.simpleName
+        reader: PollPacketReader,
+        table: Map<Inet4Address, MacAddress>,
+        name: String = ArpResponder::class.java.simpleName
 ) : PacketResponder(reader, ArpRequestFilter(), name) {
     // Copy the map if not already immutable (toMap) to make sure it is not modified
     private val table = table.toMap()
 
-    override fun replyToPacket(packet: ByteArray, reader: TapPacketReader) {
+    override fun replyToPacket(packet: ByteArray, reader: PollPacketReader) {
         val targetIp = InetAddress.getByAddress(
                 packet.copyFromIndexWithLength(ARP_TARGET_IPADDR_OFFSET, 4))
                 as Inet4Address
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
index 93422ad..be6947f 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
@@ -98,10 +98,10 @@
         cellRequestCb = null
     }
 
-    private fun addCallback(
-        cb: TestableNetworkCallback,
-        registrar: (TestableNetworkCallback) -> Unit
-    ): TestableNetworkCallback {
+    private fun <T> addCallback(
+        cb: T,
+        registrar: (NetworkCallback) -> Unit
+    ): T where T : NetworkCallback {
         registrar(cb)
         cbToCleanup.add(cb)
         return cb
@@ -142,17 +142,24 @@
     /**
      * File a callback for a NetworkRequest.
      *
-     * This will fail tests (throw) if the cell network cannot be obtained, or if it was already
-     * requested.
-     *
      * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
      * otherwise it will be automatically unrequested after the test.
      */
     @JvmOverloads
     fun registerNetworkCallback(
+        request: NetworkRequest
+    ): TestableNetworkCallback = registerNetworkCallback(request, TestableNetworkCallback())
+
+    /**
+     * File a callback for a NetworkRequest.
+     *
+     * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
+     * otherwise it will be automatically unrequested after the test.
+     */
+    fun <T> registerNetworkCallback(
         request: NetworkRequest,
-        cb: TestableNetworkCallback = TestableNetworkCallback()
-    ) = addCallback(cb) { cm.registerNetworkCallback(request, it) }
+        cb: T
+    ) where T : NetworkCallback = addCallback(cb) { cm.registerNetworkCallback(request, it) }
 
     /**
      * @see ConnectivityManager.registerDefaultNetworkCallback
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..a93ae3e
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.os.PersistableBundle
+import android.telephony.CarrierConfigManager
+import android.util.Log
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.modules.utils.build.SdkLevel.isAtLeastU
+import com.android.testutils.runAsShell
+import com.android.testutils.tryTest
+import kotlin.test.assertNotNull
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+private val TAG = CarrierConfigRule::class.simpleName
+
+/**
+ * A [TestRule] that helps set [CarrierConfigManager] overrides for tests and clean up the test
+ * configuration automatically on teardown.
+ */
+class CarrierConfigRule : TestRule {
+    private val ccm by lazy { InstrumentationRegistry.getInstrumentation().context.getSystemService(
+        CarrierConfigManager::class.java
+    ) }
+
+    // Map of (subId) -> (original values of overridden settings)
+    private val originalConfigs = mutableMapOf<Int, PersistableBundle>()
+
+    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()
+            }
+        }
+    }
+
+    /**
+     * 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) {
+            ccm.overrideConfig(subId, config)
+        }
+    }
+
+    /**
+     * 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 overrideConfig with null, as it would reset configs that may
+                    // have been set by target preparers such as
+                    // ConnectivityTestTargetPreparer / CarrierConfigSetupTest.
+                    ccm.overrideConfig(subId, config)
+                } catch (e: Throwable) {
+                    Log.e(TAG, "Error resetting carrier config for subId $subId")
+                }
+            }
+            originalConfigs.clear()
+        }
+    }
+}
+
+private fun CarrierConfigManager.getConfigForSubIdCompat(
+    subId: Int,
+    keys: Set<String>
+): PersistableBundle {
+    return if (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/ConnectUtil.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
index 3857810..d60ab59 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectUtil.kt
@@ -53,6 +53,10 @@
 private const val WIFI_ERROR_BUSY = 2
 
 class ConnectUtil(private val context: Context) {
+    companion object {
+        @JvmStatic
+        val VIRTUAL_SSIDS = listOf("VirtWifi", "AndroidWifi")
+    }
     private val TAG = ConnectUtil::class.java.simpleName
 
     private val cm = context.getSystemService(ConnectivityManager::class.java)
@@ -207,9 +211,8 @@
      */
     private fun maybeConfigureVirtualNetwork(scanResults: List<ScanResult>): WifiConfiguration? {
         // Virtual wifi networks used on the emulator and cloud testing infrastructure
-        val virtualSsids = listOf("VirtWifi", "AndroidWifi")
         Log.d(TAG, "Wifi scan results: $scanResults")
-        val virtualScanResult = scanResults.firstOrNull { virtualSsids.contains(it.SSID) }
+        val virtualScanResult = scanResults.firstOrNull { VIRTUAL_SSIDS.contains(it.SSID) }
                 ?: return null
 
         // Only add the virtual configuration if the virtual AP is detected in scans
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
new file mode 100644
index 0000000..c7d6850
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -0,0 +1,463 @@
+/*
+ * 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.testutils
+
+import android.Manifest.permission.NETWORK_SETTINGS
+import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
+import android.content.pm.PackageManager.FEATURE_TELEPHONY
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.device.collectors.BaseMetricListener
+import android.device.collectors.DataRecord
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN
+import android.util.Log
+import androidx.annotation.RequiresApi
+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.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
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import kotlin.test.assertNull
+import org.json.JSONObject
+import org.junit.AssumptionViolatedException
+import org.junit.runner.Description
+import org.junit.runner.Result
+import org.junit.runner.notification.Failure
+
+/**
+ * A diagnostics collector that outputs diagnostics files as test artifacts.
+ *
+ * <p>Collects diagnostics automatically by default on non-local builds. Can be enabled/disabled
+ * manually with:
+ * ```
+ * atest MyModule -- \
+ *     --module-arg MyModule:instrumentation-arg:connectivity-diagnostics-on-failure:=false
+ * ```
+ */
+class ConnectivityDiagnosticsCollector : BaseMetricListener() {
+    companion object {
+        private const val ARG_RUN_ON_FAILURE = "connectivity-diagnostics-on-failure"
+        private const val COLLECTOR_DIR = "run_listeners/connectivity_diagnostics"
+        private const val FILENAME_SUFFIX = "_conndiag.txt"
+        private const val MAX_DUMPS = 20
+
+        private val TAG = ConnectivityDiagnosticsCollector::class.simpleName
+        @JvmStatic
+        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 {
+        createAndEmptyDirectory(COLLECTOR_DIR)
+    }
+    private val outputFiles = mutableSetOf<String>()
+    private val cbHelper = NetworkCallbackHelper()
+    private val networkCallback = MonitoringNetworkCallback()
+
+    inner class MonitoringNetworkCallback : NetworkCallback() {
+        val currentMobileDataNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentVpnNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentWifiNetworks = mutableMapOf<Network, NetworkCapabilities>()
+
+        override fun onLost(network: Network) {
+            currentWifiNetworks.remove(network)
+            currentMobileDataNetworks.remove(network)
+        }
+
+        override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
+            if (nc.hasTransport(TRANSPORT_VPN)) {
+                currentVpnNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_WIFI)) {
+                currentWifiNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_CELLULAR)) {
+                currentMobileDataNetworks[network] = nc
+            }
+        }
+    }
+
+    override fun onSetUp() {
+        assertNull(instance, "ConnectivityDiagnosticsCollectors were set up multiple times")
+        instance = this
+        TryTestConfig.swapDiagnosticsCollector { throwable ->
+            if (runOnFailure(throwable)) {
+                collectTestFailureDiagnostics(throwable)
+            }
+        }
+    }
+
+    override fun onCleanUp() {
+        instance = null
+    }
+
+    override fun onTestRunStart(runData: DataRecord?, description: Description?) {
+        runAsShell(NETWORK_SETTINGS) {
+            cbHelper.registerNetworkCallback(
+                NetworkRequest.Builder()
+                    .addCapability(NET_CAPABILITY_INTERNET)
+                    .addTransportType(TRANSPORT_WIFI)
+                    .addTransportType(TRANSPORT_CELLULAR)
+                    .build(),
+                networkCallback
+            )
+        }
+    }
+
+    override fun onTestRunEnd(runData: DataRecord?, result: Result?) {
+        // onTestRunEnd is called regardless of success/failure, and the Result contains summary of
+        // run/failed/ignored... tests.
+        cbHelper.unregisterAll()
+    }
+
+    override fun onTestFail(testData: DataRecord, description: Description, failure: Failure) {
+        // TODO: find a way to disable this behavior only on local runs, to avoid slowing them down
+        // 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: " +
+                "atest MyModule -- " +
+                "--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 -U -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.
+        // Flush the contents of the buffer to a file when the test ends, even when successful.
+        if (buffer.size() == 0) return
+        if (outputFiles.size >= MAX_DUMPS) return
+
+        // Flush any data that the test added to the buffer for dumping
+        val baseFilename = "${description.className}#${description.methodName}_testdump"
+        flushBufferToFileMetric(testData, baseFilename)
+    }
+
+    private fun runOnFailure(exception: Throwable): Boolean {
+        // Assumption failures (assumeTrue/assumeFalse) are not actual failures
+        if (exception is AssumptionViolatedException) return false
+
+        // Do not run on local builds (which have ro.build.version.incremental set to eng.username)
+        // to avoid slowing down local runs.
+        val enabledByDefault = !Build.VERSION.INCREMENTAL.startsWith("eng.")
+        return argsBundle.getString(ARG_RUN_ON_FAILURE)?.toBooleanStrictOrNull() ?: enabledByDefault
+    }
+
+    private fun flushBufferToFileMetric(testData: DataRecord, baseFilename: String) {
+        var filename = baseFilename
+        // In case a method was run multiple times (typically retries), append a number
+        var i = 2
+        while (outputFiles.contains(filename)) {
+            filename = baseFilename + "_$i"
+            i++
+        }
+        val outFile = File(collectorDir, filename + FILENAME_SUFFIX)
+        outputFiles.add(filename)
+        FileOutputStream(outFile).use { fos ->
+            failureHeader?.let {
+                fos.write(it.toByteArray())
+                fos.write("\n".toByteArray())
+            }
+            fos.write(buffer.toByteArray())
+            stopTcpdumpIfRunning(fos)
+        }
+        failureHeader = null
+        buffer.reset()
+        val fileKey = "${ConnectivityDiagnosticsCollector::class.qualifiedName}_$filename"
+        testData.addFileMetric(fileKey, outFile)
+    }
+
+    private fun maybeCollectFailureHeader() {
+        if (failureHeader != null) {
+            Log.i(TAG, "Connectivity diagnostics failure header already collected, skipping")
+            return
+        }
+
+        val instr = InstrumentationRegistry.getInstrumentation()
+        val ctx = instr.context
+        val pm = ctx.packageManager
+        val hasWifi = pm.hasSystemFeature(FEATURE_WIFI)
+        val hasMobileData = pm.hasSystemFeature(FEATURE_TELEPHONY)
+        val tm = if (hasMobileData) ctx.getSystemService(TelephonyManager::class.java) else null
+        // getAdoptedShellPermissions is S+. Optimistically assume that tests are not holding on
+        // shell permissions during failure/cleanup on R.
+        val canUseShell = !isAtLeastS() ||
+                instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty()
+        val headerObj = JSONObject()
+        failureHeaderExtras.forEach { (k, v) -> headerObj.put(k, v) }
+        failureHeaderExtras.clear()
+        if (canUseShell) {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) {
+                headerObj.apply {
+                    put("deviceSerial", Build.getSerial())
+                    // The network callback filed on start cannot get the WifiInfo as it would need
+                    // to keep NETWORK_SETTINGS permission throughout the test run. Try to
+                    // obtain it while holding the permission at the end of the test.
+                    val wifiInfo = networkCallback.currentWifiNetworks.keys.firstOrNull()?.let {
+                        getWifiInfo(it)
+                    }
+                    put("ssid", wifiInfo?.ssid)
+                    put("bssid", wifiInfo?.bssid)
+                    put("simState", tm?.simState ?: SIM_STATE_UNKNOWN)
+                    put("mccMnc", tm?.simOperator)
+                }
+            }
+        } else {
+            Log.w(
+                TAG,
+                "The test is still holding shell permissions, cannot collect privileged " +
+                    "device info"
+            )
+            headerObj.put("shellPermissionsUnavailable", true)
+        }
+        failureHeader = headerObj.apply {
+            put("time", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now()))
+            put(
+                "wifiEnabled",
+                hasWifi && ctx.getSystemService(WifiManager::class.java).isWifiEnabled
+            )
+            put("connectedWifiCount", networkCallback.currentWifiNetworks.size)
+            put("validatedWifiCount", networkCallback.currentWifiNetworks.filterValues {
+                it.hasCapability(NET_CAPABILITY_VALIDATED)
+            }.size)
+            put("mobileDataConnectivityPossible", tm?.isDataConnectivityPossible ?: false)
+            put("connectedMobileDataCount", networkCallback.currentMobileDataNetworks.size)
+            put("validatedMobileDataCount",
+                networkCallback.currentMobileDataNetworks.filterValues {
+                    it.hasCapability(NET_CAPABILITY_VALIDATED)
+                }.size
+            )
+        }.toString()
+    }
+
+    private class WifiInfoCallback : NetworkCallback {
+        private val network: Network
+        val wifiInfoFuture = CompletableFuture<WifiInfo?>()
+        constructor(network: Network) : super() {
+            this.network = network
+        }
+        @RequiresApi(Build.VERSION_CODES.S)
+        constructor(network: Network, flags: Int) : super(flags) {
+            this.network = network
+        }
+        override fun onCapabilitiesChanged(net: Network, nc: NetworkCapabilities) {
+            if (network == net) {
+                wifiInfoFuture.complete(nc.transportInfo as? WifiInfo)
+            }
+        }
+    }
+
+    private fun getWifiInfo(network: Network): WifiInfo? {
+        // Get the SSID via network callbacks, as the Networks are obtained via callbacks, and
+        // synchronous calls (CM#getNetworkCapabilities) and callbacks should not be mixed.
+        // A new callback needs to be filed and received while holding NETWORK_SETTINGS permission.
+        val cb = if (isAtLeastS()) {
+            WifiInfoCallback(network, FLAG_INCLUDE_LOCATION_INFO)
+        } else {
+            WifiInfoCallback(network)
+        }
+        cbHelper.registerNetworkCallback(
+            NetworkRequest.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_INTERNET).build(),
+            cb
+        )
+        return try {
+            cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS)
+        } catch (e: TimeoutException) {
+            null
+        } finally {
+            cbHelper.unregisterNetworkCallback(cb)
+        }
+    }
+
+    /**
+     * Add connectivity diagnostics to the test data dump.
+     *
+     * <p>This collects a set of diagnostics that are relevant to connectivity test failures.
+     * <p>The dump will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectTestFailureDiagnostics(exceptionContext: Throwable? = null) {
+        maybeCollectFailureHeader()
+        collectDumpsysConnectivity(exceptionContext)
+    }
+
+    /**
+     * Add dumpsys connectivity to the test data dump.
+     *
+     * <p>The dump will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectDumpsysConnectivity(exceptionContext: Throwable? = null) {
+        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) {
+        Log.i(TAG, "Collecting dumpsys $dumpsysCmd for test artifacts")
+        PrintWriter(buffer).let {
+            it.println("--- Dumpsys $dumpsysCmd at ${ZonedDateTime.now()} ---")
+            maybeWriteExceptionContext(it, exceptionContext)
+            it.flush()
+        }
+        ParcelFileDescriptor.AutoCloseInputStream(
+            InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
+                "dumpsys $dumpsysCmd"
+            )
+        ).use {
+            it.copyTo(buffer)
+        }
+    }
+
+    /**
+     * Add a key->value attribute to the failure data, to be written to the diagnostics file.
+     *
+     * <p>This is to be called by tests that know they will fail.
+     */
+    fun addFailureAttribute(key: String, value: Any) {
+        failureHeaderExtras[key] = value
+    }
+
+    private fun maybeWriteExceptionContext(writer: PrintWriter, exceptionContext: Throwable?) {
+        if (exceptionContext == null) return
+        writer.println("At: ")
+        exceptionContext.printStackTrace(writer)
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
index 68248ca..044b410 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
@@ -18,6 +18,7 @@
 
 import android.Manifest.permission.READ_DEVICE_CONFIG
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.provider.DeviceConfig
 import android.util.Log
 import com.android.modules.utils.build.SdkLevel
@@ -87,8 +88,9 @@
                     }
                     throw e
                 } cleanupStep {
-                    runAsShell(WRITE_DEVICE_CONFIG) {
+                    runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
                         originalConfig.forEach { (key, value) ->
+                            Log.i(TAG, "Resetting config \"${key.second}\" to \"$value\"")
                             DeviceConfig.setProperty(
                                     key.first, key.second, value, false /* makeDefault */)
                         }
@@ -115,7 +117,8 @@
      */
     fun setConfig(namespace: String, key: String, value: String?): String? {
         Log.i(TAG, "Setting config \"$key\" to \"$value\"")
-        val readWritePermissions = arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG)
+        val readWritePermissions =
+            arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG)
 
         val keyPair = Pair(namespace, key)
         val existingValue = runAsShell(*readWritePermissions) {
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DeviceInfoUtils.java b/staticlibs/testutils/devicetests/com/android/testutils/DeviceInfoUtils.java
index ce55fdc..31879af 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DeviceInfoUtils.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DeviceInfoUtils.java
@@ -16,6 +16,10 @@
 
 package com.android.testutils;
 
+import static com.android.modules.utils.build.SdkLevel.isAtLeastS;
+
+import android.os.Build;
+import android.os.SystemProperties;
 import android.os.VintfRuntimeInfo;
 import android.text.TextUtils;
 import android.util.Pair;
@@ -173,4 +177,14 @@
         final KVersion from = DeviceInfoUtils.getMajorMinorSubminorVersion(version);
         return current.isAtLeast(from);
     }
+
+    /**
+     * Check if the current build is a debuggable build.
+     */
+    public static boolean isDebuggable() {
+        if (isAtLeastS()) {
+            return Build.isDebuggable();
+        }
+        return SystemProperties.getInt("ro.debuggable", 0) == 1;
+    }
 }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java b/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java
new file mode 100644
index 0000000..8608344
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java
@@ -0,0 +1,202 @@
+/*
+ * 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.testutils;
+
+import static android.net.DnsResolver.CLASS_IN;
+
+import static com.android.net.module.util.DnsPacket.TYPE_SVCB;
+import static com.android.net.module.util.DnsPacketUtils.DnsRecordParser.domainNameToLabels;
+import static com.android.net.module.util.NetworkStackConstants.IPV4_ADDR_LEN;
+import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN;
+
+import static org.junit.Assert.fail;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import android.net.InetAddresses;
+
+import androidx.annotation.NonNull;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class DnsSvcbUtils {
+    private static final Pattern SVC_PARAM_PATTERN = Pattern.compile("([a-z0-9-]+)=?(.*)");
+
+    /**
+     * Returns a DNS SVCB response with given hostname `hostname` and given SVCB records
+     * `records`. Each record must contain the service priority, the target name, and the service
+     * parameters.
+     *     E.g. "1 doh.google alpn=h2,h3 port=443 ipv4hint=192.0.2.1 dohpath=/dns-query{?dns}"
+     */
+    @NonNull
+    public static byte[] makeSvcbResponse(String hostname, String[] records) throws IOException {
+        if (records == null) throw new NullPointerException();
+        if (!hostname.startsWith("_dns.")) throw new UnsupportedOperationException();
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        // Write DNS header.
+        os.write(shortsToByteArray(
+                0x1234,         /* Transaction ID */
+                0x8100,         /* Flags */
+                1,              /* qdcount */
+                records.length, /* ancount */
+                0,              /* nscount */
+                0               /* arcount */
+        ));
+        // Write Question.
+        // - domainNameToLabels() doesn't support the hostname starting with "_", so divide
+        //   the writing into two steps.
+        os.write(new byte[] { 0x04, '_', 'd', 'n', 's' });
+        os.write(domainNameToLabels(hostname.substring(5)));
+        os.write(shortsToByteArray(TYPE_SVCB, CLASS_IN));
+        // Write Answer section.
+        for (String r : records) {
+            os.write(makeSvcbRecord(r));
+        }
+        return os.toByteArray();
+    }
+
+    @NonNull
+    private static byte[] makeSvcbRecord(String representation) throws IOException {
+        if (representation == null) return new byte[0];
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        os.write(shortsToByteArray(
+                0xc00c, /* Pointer to qname in question section */
+                TYPE_SVCB,
+                CLASS_IN,
+                0, 16, /* TTL = 16 */
+                0 /* Data Length = 0 */
+
+        ));
+        final String[] strings = representation.split(" +");
+        // SvcPriority and TargetName are mandatory in the representation.
+        if (strings.length < 3) {
+            fail("Invalid SVCB representation: " + representation);
+        }
+        // Write SvcPriority, TargetName, and SvcParams.
+        os.write(shortsToByteArray(Short.parseShort(strings[0])));
+        os.write(domainNameToLabels(strings[1]));
+        for (int i = 2; i < strings.length; i++) {
+            try {
+                os.write(svcParamToByteArray(strings[i]));
+            } catch (UnsupportedEncodingException e) {
+                throw new IOException(e);
+            }
+        }
+        // Update rdata length.
+        final byte[] out = os.toByteArray();
+        ByteBuffer.wrap(out).putShort(10, (short) (out.length - 12));
+        return out;
+    }
+
+    @NonNull
+    private static byte[] svcParamToByteArray(String svcParam) throws IOException {
+        final Matcher matcher = SVC_PARAM_PATTERN.matcher(svcParam);
+        if (!matcher.matches() || matcher.groupCount() != 2) {
+            fail("Invalid SvcParam: " + svcParam);
+        }
+        final String svcParamkey = matcher.group(1);
+        final String svcParamValue = matcher.group(2);
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        os.write(svcParamKeyToBytes(svcParamkey));
+        switch (svcParamkey) {
+            case "mandatory":
+                final String[] keys = svcParamValue.split(",");
+                os.write(shortsToByteArray(keys.length));
+                for (String v : keys) {
+                    os.write(svcParamKeyToBytes(v));
+                }
+                break;
+            case "alpn":
+                os.write(shortsToByteArray((svcParamValue.length() + 1)));
+                for (String v : svcParamValue.split(",")) {
+                    os.write(v.length());
+                    // TODO: support percent-encoding per RFC 7838.
+                    os.write(v.getBytes(US_ASCII));
+                }
+                break;
+            case "no-default-alpn":
+                os.write(shortsToByteArray(0));
+                break;
+            case "port":
+                os.write(shortsToByteArray(2));
+                os.write(shortsToByteArray(Short.parseShort(svcParamValue)));
+                break;
+            case "ipv4hint":
+                final String[] v4Addrs = svcParamValue.split(",");
+                os.write(shortsToByteArray((v4Addrs.length * IPV4_ADDR_LEN)));
+                for (String v : v4Addrs) {
+                    os.write(InetAddresses.parseNumericAddress(v).getAddress());
+                }
+                break;
+            case "ech":
+                os.write(shortsToByteArray(svcParamValue.length()));
+                os.write(svcParamValue.getBytes(US_ASCII));  // base64 encoded
+                break;
+            case "ipv6hint":
+                final String[] v6Addrs = svcParamValue.split(",");
+                os.write(shortsToByteArray((v6Addrs.length * IPV6_ADDR_LEN)));
+                for (String v : v6Addrs) {
+                    os.write(InetAddresses.parseNumericAddress(v).getAddress());
+                }
+                break;
+            case "dohpath":
+                os.write(shortsToByteArray(svcParamValue.length()));
+                // TODO: support percent-encoding, since this is a URI template.
+                os.write(svcParamValue.getBytes(US_ASCII));
+                break;
+            default:
+                os.write(shortsToByteArray(svcParamValue.length()));
+                os.write(svcParamValue.getBytes(US_ASCII));
+                break;
+        }
+        return os.toByteArray();
+    }
+
+    @NonNull
+    private static byte[] svcParamKeyToBytes(String key) {
+        switch (key) {
+            case "mandatory": return shortsToByteArray(0);
+            case "alpn": return shortsToByteArray(1);
+            case "no-default-alpn": return shortsToByteArray(2);
+            case "port": return shortsToByteArray(3);
+            case "ipv4hint": return shortsToByteArray(4);
+            case "ech": return shortsToByteArray(5);
+            case "ipv6hint": return shortsToByteArray(6);
+            case "dohpath": return shortsToByteArray(7);
+            default:
+                if (!key.startsWith("key")) fail("Invalid SvcParamKey " + key);
+                return shortsToByteArray(Short.parseShort(key.substring(3)));
+        }
+    }
+
+    @NonNull
+    private static byte[] shortsToByteArray(int... values) {
+        final ByteBuffer out = ByteBuffer.allocate(values.length * 2);
+        for (int value: values) {
+            if (value < 0 || value > 0xffff) {
+                throw new AssertionError("not an unsigned short: " + value);
+            }
+            out.putShort((short) value);
+        }
+        return out.array();
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt b/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
index 1f82a35..e49c0c7 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
@@ -18,72 +18,56 @@
 
 import android.net.DnsResolver
 import android.net.InetAddresses
-import android.os.Looper
+import android.net.Network
 import android.os.Handler
+import android.os.Looper
 import com.android.internal.annotations.GuardedBy
-import java.net.InetAddress
-import java.util.concurrent.Executor
-import org.mockito.invocation.InvocationOnMock
+import com.android.net.module.util.DnsPacket
 import org.mockito.Mockito.any
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.doAnswer
+import org.mockito.invocation.InvocationOnMock
+import org.mockito.stubbing.Answer
+import java.net.InetAddress
+import java.net.UnknownHostException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executor
+import java.util.concurrent.TimeUnit
 
-const val TYPE_UNSPECIFIED = -1
-// TODO: Integrate with NetworkMonitorTest.
-class FakeDns(val mockResolver: DnsResolver) {
-    class DnsEntry(val hostname: String, val type: Int, val addresses: List<InetAddress>) {
-        fun match(host: String, type: Int) = hostname.equals(host) && type == type
-    }
+// Nonexistent DNS query type to represent "A and/or AAAA queries".
+// TODO: deduplicate this with DnsUtils.TYPE_ADDRCONFIG.
+private const val TYPE_ADDRCONFIG = -1
 
-    @GuardedBy("answers")
-    val answers = ArrayList<DnsEntry>()
+class FakeDns(val network: Network, val dnsResolver: DnsResolver) {
+    private val HANDLER_TIMEOUT_MS = 1000
 
-    fun getAnswer(hostname: String, type: Int): DnsEntry? = synchronized(answers) {
-        return answers.firstOrNull { it.match(hostname, type) }
-    }
-
-    fun setAnswer(hostname: String, answer: Array<String>, type: Int) = synchronized(answers) {
-        val ans = DnsEntry(hostname, type, generateAnswer(answer))
-        // Replace or remove the existing one.
-        when (val index = answers.indexOfFirst { it.match(hostname, type) }) {
-            -1 -> answers.add(ans)
-            else -> answers[index] = ans
+    /** Data class to record the Dns entry.  */
+    class DnsEntry (val hostname: String, val type: Int, val answerSupplier: AnswerSupplier) {
+        // Full match or partial match that target host contains the entry hostname to support
+        // random private dns probe hostname.
+        fun matches(hostname: String, type: Int): Boolean {
+            return hostname.endsWith(this.hostname) && type == this.type
         }
     }
 
-    private fun generateAnswer(answer: Array<String>) =
-            answer.filterNotNull().map { InetAddresses.parseNumericAddress(it) }
+    /**
+     * Whether queries on [network] will be answered when private DNS is enabled. Queries that
+     * bypass private DNS by using [network.privateDnsBypassingCopy] are always answered.
+     */
+    var nonBypassPrivateDnsWorking: Boolean = true
 
-    fun startMocking() {
-        // Mock DnsResolver.query() w/o type
-        doAnswer {
-            mockAnswer(it, 1, -1, 3, 5)
-        }.`when`(mockResolver).query(any() /* network */, any() /* domain */, anyInt() /* flags */,
-                any() /* executor */, any() /* cancellationSignal */, any() /*callback*/)
-        // Mock DnsResolver.query() w/ type
-        doAnswer {
-            mockAnswer(it, 1, 2, 4, 6)
-        }.`when`(mockResolver).query(any() /* network */, any() /* domain */, anyInt() /* nsType */,
-                anyInt() /* flags */, any() /* executor */, any() /* cancellationSignal */,
-        any() /*callback*/)
+    @GuardedBy("answers")
+    private val answers = mutableListOf<DnsEntry>()
+
+    interface AnswerSupplier {
+        /** Supplies the answer to one DnsResolver query method call.  */
+        @Throws(DnsResolver.DnsException::class)
+        fun get(): Array<String>?
     }
 
-    private fun mockAnswer(
-        it: InvocationOnMock,
-        posHos: Int,
-        posType: Int,
-        posExecutor: Int,
-        posCallback: Int
-    ) {
-        val hostname = it.arguments[posHos] as String
-        val executor = it.arguments[posExecutor] as Executor
-        val callback = it.arguments[posCallback] as DnsResolver.Callback<List<InetAddress>>
-        var type = if (posType != -1) it.arguments[posType] as Int else TYPE_UNSPECIFIED
-        val answer = getAnswer(hostname, type)
-
-        if (answer != null && !answer.addresses.isNullOrEmpty()) {
-            Handler(Looper.getMainLooper()).post({ executor.execute({
-                    callback.onAnswer(answer.addresses, 0); }) })
+    private class InstantAnswerSupplier(val answers: Array<String>?) : AnswerSupplier {
+        override fun get(): Array<String>? {
+            return answers
         }
     }
 
@@ -91,4 +75,177 @@
     fun clearAll() = synchronized(answers) {
         answers.clear()
     }
+
+    /** Returns the answer for a given name and type on the given mock network.  */
+    private fun getAnswer(mockNetwork: Network, hostname: String, type: Int):
+            CompletableFuture<Array<String>?> {
+        if (!checkQueryNetwork(mockNetwork)) {
+            return CompletableFuture.completedFuture(null)
+        }
+        val answerSupplier: AnswerSupplier? = synchronized(answers) {
+            answers.firstOrNull({e: DnsEntry -> e.matches(hostname, type)})?.answerSupplier
+        }
+        if (answerSupplier == null) {
+            return CompletableFuture.completedFuture(null)
+        }
+        if (answerSupplier is InstantAnswerSupplier) {
+            // Save latency waiting for a query thread if the answer is hardcoded.
+            return CompletableFuture.completedFuture<Array<String>?>(answerSupplier.get())
+        }
+        val answerFuture = CompletableFuture<Array<String>?>()
+        // Don't worry about ThreadLeadMonitor: these threads terminate immediately, so they won't
+        // leak, and ThreadLeakMonitor won't monitor them anyway, since they have one-time names
+        // such as "Thread-42".
+        Thread {
+            try {
+                answerFuture.complete(answerSupplier.get())
+            } catch (e: DnsResolver.DnsException) {
+                answerFuture.completeExceptionally(e)
+            }
+        }.start()
+        return answerFuture
+    }
+
+    /** Sets the answer for a given name and type.  */
+    fun setAnswer(hostname: String, answer: Array<String>?, type: Int) = setAnswer(
+            hostname, InstantAnswerSupplier(answer), type)
+
+    /** Sets the answer for a given name and type.  */
+    fun setAnswer(
+            hostname: String, answerSupplier: AnswerSupplier, type: Int) = synchronized (answers) {
+        val ans = DnsEntry(hostname, type, answerSupplier)
+        // Replace or remove the existing one.
+        when (val index = answers.indexOfFirst { it.matches(hostname, type) }) {
+            -1 -> answers.add(ans)
+            else -> answers[index] = ans
+        }
+    }
+
+    private fun checkQueryNetwork(mockNetwork: Network): Boolean {
+        // Queries on the wrong network do not work.
+        // Queries that bypass private DNS work.
+        // Queries that do not bypass private DNS work only if nonBypassPrivateDnsWorking is true.
+        return mockNetwork == network.privateDnsBypassingCopy ||
+                mockNetwork == network && nonBypassPrivateDnsWorking
+    }
+
+    /** Simulates a getAllByName call for the specified name on the specified mock network.  */
+    private fun getAllByName(mockNetwork: Network, hostname: String): Array<InetAddress>? {
+        val answer = stringsToInetAddresses(queryAllTypes(mockNetwork, hostname)
+            .get(HANDLER_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS))
+        if (answer == null || answer.size == 0) {
+            throw UnknownHostException(hostname)
+        }
+        return answer.toTypedArray()
+    }
+
+    // Regardless of the type, depends on what the responses contained in the network.
+    private fun queryAllTypes(
+        mockNetwork: Network, hostname: String
+    ): CompletableFuture<Array<String>?> {
+        val aFuture = getAnswer(mockNetwork, hostname, DnsResolver.TYPE_A)
+                .exceptionally { emptyArray() }
+        val aaaaFuture = getAnswer(mockNetwork, hostname, DnsResolver.TYPE_AAAA)
+                .exceptionally { emptyArray() }
+        val combinedFuture = CompletableFuture<Array<String>?>()
+        aFuture.thenAcceptBoth(aaaaFuture) { res1: Array<String>?, res2: Array<String>? ->
+            var answer: Array<String> = arrayOf()
+            if (res1 != null) answer += res1
+            if (res2 != null) answer += res2
+            combinedFuture.complete(answer)
+        }
+        return combinedFuture
+    }
+
+    /** Starts mocking DNS queries.  */
+    fun startMocking() {
+        // Queries on mNetwork using getAllByName.
+        doAnswer {
+            getAllByName(it.mock as Network, it.getArgument(0))
+        }.`when`(network).getAllByName(any())
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 3, posCallback = 5,
+                posType = -1)
+        }.`when`(dnsResolver).query(any(), any(), anyInt(), any(), any(), any())
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query with QueryType.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 4, posCallback = 6,
+                posType = 2)
+        }.`when`(dnsResolver).query(any(), any(), anyInt(), anyInt(), any(), any(), any())
+
+        // Queries using rawQuery. Currently, mockQuery only supports TYPE_SVCB.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 5, posCallback = 7,
+                posType = 3)
+        }.`when`(dnsResolver).rawQuery(any(), any(), anyInt(), anyInt(), anyInt(), any(), any(),
+            any())
+    }
+
+    private fun stringsToInetAddresses(addrs: Array<String>?): List<InetAddress>? {
+        if (addrs == null) return null
+        val out: MutableList<InetAddress> = ArrayList()
+        for (addr in addrs) {
+            out.add(InetAddresses.parseNumericAddress(addr))
+        }
+        return out
+    }
+
+    // Mocks all the DnsResolver query methods used in this test.
+    private fun mockQuery(
+        invocation: InvocationOnMock, posNetwork: Int, posHostname: Int,
+        posExecutor: Int, posCallback: Int, posType: Int
+    ): Answer<*>? {
+        val hostname = invocation.getArgument<String>(posHostname)
+        val executor = invocation.getArgument<Executor>(posExecutor)
+        val network = invocation.getArgument<Network>(posNetwork)
+        val qtype = if (posType != -1) invocation.getArgument(posType) else TYPE_ADDRCONFIG
+        val answerFuture: CompletableFuture<Array<String>?> = if (posType != -1) getAnswer(
+            network,
+            hostname,
+            invocation.getArgument(posType)
+        ) else queryAllTypes(network, hostname)
+
+        // Discriminate between different callback types to avoid unchecked cast warnings when
+        // calling the onAnswer methods.
+        val inetAddressCallback: DnsResolver.Callback<List<InetAddress>> =
+            invocation.getArgument(posCallback)
+        val byteArrayCallback: DnsResolver.Callback<ByteArray> =
+            invocation.getArgument(posCallback)
+        val callback: DnsResolver.Callback<*> = invocation.getArgument(posCallback)
+
+        answerFuture.whenComplete { answer: Array<String>?, exception: Throwable? ->
+            // Use getMainLooper() because that's what android.net.DnsResolver currently uses.
+            Handler(Looper.getMainLooper()).post {
+                executor.execute {
+                    if (exception != null) {
+                        if (exception !is DnsResolver.DnsException) {
+                            throw java.lang.AssertionError(
+                                "Test error building DNS response",
+                                exception
+                            )
+                        }
+                        callback.onError((exception as DnsResolver.DnsException?)!!)
+                        return@execute
+                    }
+                    if (answer != null && answer.size > 0) {
+                        when (qtype) {
+                            DnsResolver.TYPE_A, DnsResolver.TYPE_AAAA, TYPE_ADDRCONFIG ->
+                                inetAddressCallback.onAnswer(stringsToInetAddresses(answer)!!, 0)
+                            DnsPacket.TYPE_SVCB ->
+                                byteArrayCallback.onAnswer(
+                                    DnsSvcbUtils.makeSvcbResponse(hostname, answer), 0)
+                            else -> throw UnsupportedOperationException(
+                                "Unsupported qtype $qtype, update this fake"
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        // If the future does not complete or has no answer do nothing. The timeout should fire.
+        return null
+    }
 }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt b/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
index 8b88224..5729452 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
@@ -28,8 +28,6 @@
 import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
 import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
 import com.android.net.module.util.TrackRecord
-import com.android.testutils.IPv6UdpFilter
-import com.android.testutils.TapPacketReader
 import java.net.Inet6Address
 import java.net.InetAddress
 import kotlin.test.assertEquals
@@ -246,7 +244,7 @@
             as Inet6Address
 }
 
-fun TapPacketReader.pollForMdnsPacket(
+fun PollPacketReader.pollForMdnsPacket(
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS,
     predicate: (TestDnsPacket) -> Boolean
 ): TestDnsPacket? {
@@ -264,7 +262,7 @@
     }
 }
 
-fun TapPacketReader.pollForProbe(
+fun PollPacketReader.pollForProbe(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
@@ -272,7 +270,7 @@
     it.isProbeFor("$serviceName.$serviceType.local")
 }
 
-fun TapPacketReader.pollForAdvertisement(
+fun PollPacketReader.pollForAdvertisement(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
@@ -280,19 +278,19 @@
     it.isReplyFor("$serviceName.$serviceType.local")
 }
 
-fun TapPacketReader.pollForQuery(
+fun PollPacketReader.pollForQuery(
     recordName: String,
     vararg requiredTypes: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
 ): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, *requiredTypes) }
 
-fun TapPacketReader.pollForReply(
+fun PollPacketReader.pollForReply(
     recordName: String,
     type: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
 ): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isReplyFor(recordName, type) }
 
-fun TapPacketReader.pollForReply(
+fun PollPacketReader.pollForReply(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt b/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
index 964c6c6..62d0e82 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
@@ -21,24 +21,24 @@
 private const val POLL_FREQUENCY_MS = 1000L
 
 /**
- * A class that can be used to reply to packets from a [TapPacketReader].
+ * A class that can be used to reply to packets from a [PollPacketReader].
  *
  * A reply thread will be created to reply to incoming packets asynchronously.
- * The receiver creates a new read head on the [TapPacketReader], to read packets, so it does not
- * affect packets obtained through [TapPacketReader.popPacket].
+ * The receiver creates a new read head on the [PollPacketReader], to read packets, so it does not
+ * affect packets obtained through [PollPacketReader.popPacket].
  *
- * @param reader a [TapPacketReader] to obtain incoming packets and reply to them.
+ * @param reader a [PollPacketReader] to obtain incoming packets and reply to them.
  * @param packetFilter A filter to apply to incoming packets.
  * @param name Name to use for the internal responder thread.
  */
 abstract class PacketResponder(
-    private val reader: TapPacketReader,
-    private val packetFilter: Predicate<ByteArray>,
-    name: String
+        private val reader: PollPacketReader,
+        private val packetFilter: Predicate<ByteArray>,
+        name: String
 ) {
     private val replyThread = ReplyThread(name)
 
-    protected abstract fun replyToPacket(packet: ByteArray, reader: TapPacketReader)
+    protected abstract fun replyToPacket(packet: ByteArray, reader: PollPacketReader)
 
     /**
      * Start the [PacketResponder].
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java b/staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
similarity index 91%
rename from staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java
rename to staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
index b25b9f2..dbc7eb0 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
@@ -35,19 +35,19 @@
 import kotlin.LazyKt;
 
 /**
- * A packet reader that runs on a TAP interface.
+ * A packet reader that can poll for received packets and send responses on a fd.
  *
  * It also implements facilities to reply to received packets.
  */
-public class TapPacketReader extends PacketReader {
-    private final FileDescriptor mTapFd;
+public class PollPacketReader extends PacketReader {
+    private final FileDescriptor mFd;
     private final ArrayTrackRecord<byte[]> mReceivedPackets = new ArrayTrackRecord<>();
     private final Lazy<ArrayTrackRecord<byte[]>.ReadHead> mReadHead =
             LazyKt.lazy(mReceivedPackets::newReadHead);
 
-    public TapPacketReader(Handler h, FileDescriptor tapFd, int maxPacketSize) {
+    public PollPacketReader(Handler h, FileDescriptor fd, int maxPacketSize) {
         super(h, maxPacketSize);
-        mTapFd = tapFd;
+        mFd = fd;
     }
 
 
@@ -63,7 +63,7 @@
 
     @Override
     protected FileDescriptor createFd() {
-        return mTapFd;
+        return mFd;
     }
 
     @Override
@@ -119,7 +119,7 @@
     }
 
     /*
-     * Send a response on the TAP interface.
+     * Send a response on the fd.
      *
      * The passed ByteBuffer is flipped after use.
      *
@@ -127,7 +127,7 @@
      * @throws IOException if the interface can't be written to.
      */
     public void sendResponse(final ByteBuffer packet) throws IOException {
-        try (FileOutputStream out = new FileOutputStream(mTapFd)) {
+        try (FileOutputStream out = new FileOutputStream(mFd)) {
             byte[] packetBytes = new byte[packet.limit()];
             packet.get(packetBytes);
             packet.flip();  // So we can reuse it in the future.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java b/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
index 51d57bc..6709555 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
@@ -62,18 +62,18 @@
     private static final String TAG = "RouterAdvertisementResponder";
     private static final Inet6Address DNS_SERVER =
             (Inet6Address) InetAddresses.parseNumericAddress("2001:4860:4860::64");
-    private final TapPacketReader mPacketReader;
+    private final PollPacketReader mPacketReader;
     // Maps IPv6 address to MacAddress and isRouter boolean.
     private final Map<Inet6Address, Pair<MacAddress, Boolean>> mNeighborMap = new ArrayMap<>();
     private final IpPrefix mPrefix;
 
-    public RouterAdvertisementResponder(TapPacketReader packetReader, IpPrefix prefix) {
+    public RouterAdvertisementResponder(PollPacketReader packetReader, IpPrefix prefix) {
         super(packetReader, RouterAdvertisementResponder::isRsOrNs, TAG);
         mPacketReader = packetReader;
         mPrefix = Objects.requireNonNull(prefix);
     }
 
-    public RouterAdvertisementResponder(TapPacketReader packetReader) {
+    public RouterAdvertisementResponder(PollPacketReader packetReader) {
         this(packetReader, makeRandomPrefix());
     }
 
@@ -148,7 +148,7 @@
                 buildSllaOption(srcMac));
     }
 
-    private static void sendResponse(TapPacketReader reader, ByteBuffer buffer) {
+    private static void sendResponse(PollPacketReader reader, ByteBuffer buffer) {
         try {
             reader.sendResponse(buffer);
         } catch (IOException e) {
@@ -158,7 +158,7 @@
         }
     }
 
-    private void replyToRouterSolicitation(TapPacketReader reader, MacAddress dstMac) {
+    private void replyToRouterSolicitation(PollPacketReader reader, MacAddress dstMac) {
         for (Map.Entry<Inet6Address, Pair<MacAddress, Boolean>> it : mNeighborMap.entrySet()) {
             final boolean isRouter = it.getValue().second;
             if (!isRouter) {
@@ -169,7 +169,7 @@
         }
     }
 
-    private void replyToNeighborSolicitation(TapPacketReader reader, MacAddress dstMac,
+    private void replyToNeighborSolicitation(PollPacketReader reader, MacAddress dstMac,
             Inet6Address dstIp, Inet6Address targetIp) {
         final Pair<MacAddress, Boolean> neighbor = mNeighborMap.get(targetIp);
         if (neighbor == null) {
@@ -190,7 +190,7 @@
     }
 
     @Override
-    protected void replyToPacket(byte[] packet, TapPacketReader reader) {
+    protected void replyToPacket(byte[] packet, PollPacketReader reader) {
         final ByteBuffer buf = ByteBuffer.wrap(packet);
         // Messages are filtered by parent class, so it is safe to assume that packet is either an
         // RS or NS.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
index d5e91c2..7b970d3 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
@@ -57,6 +57,7 @@
      * @param enabled The desired state (true for enabled, false for disabled) of the feature flag.
      */
     @Target(AnnotationTarget.FUNCTION)
+    @Repeatable
     @Retention(AnnotationRetention.RUNTIME)
     annotation class FeatureFlag(val name: String, val enabled: Boolean = true)
 
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
index 701666c..adf7619 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
@@ -31,9 +31,9 @@
 private const val HANDLER_TIMEOUT_MS = 10_000L
 
 /**
- * A [TestRule] that sets up a [TapPacketReader] on a [TestNetworkInterface] for use in the test.
+ * A [TestRule] that sets up a [PollPacketReader] on a [TestNetworkInterface] for use in the test.
  *
- * @param maxPacketSize Maximum size of packets read in the [TapPacketReader] buffer.
+ * @param maxPacketSize Maximum size of packets read in the [PollPacketReader] buffer.
  * @param autoStart Whether to initialize the interface and start the reader automatically for every
  *                  test. If false, each test must either call start() and stop(), or be annotated
  *                  with TapPacketReaderTest before using the reader or interface.
@@ -50,21 +50,21 @@
     // referenced before they could be initialized (typically if autoStart is false and the test
     // does not call start or use @TapPacketReaderTest).
     lateinit var iface: TestNetworkInterface
-    lateinit var reader: TapPacketReader
+    lateinit var reader: PollPacketReader
 
     @Volatile
     private var readerRunning = false
 
     /**
      * Indicates that the [TapPacketReaderRule] should initialize its [TestNetworkInterface] and
-     * start the [TapPacketReader] before the test, and tear them down afterwards.
+     * start the [PollPacketReader] before the test, and tear them down afterwards.
      *
      * For use when [TapPacketReaderRule] is created with autoStart = false.
      */
     annotation class TapPacketReaderTest
 
     /**
-     * Initialize the tap interface and start the [TapPacketReader].
+     * Initialize the tap interface and start the [PollPacketReader].
      *
      * Tests using this method must also call [stop] before exiting.
      * @param handler Handler to run the reader on. Callers are responsible for safely terminating
@@ -85,13 +85,13 @@
         }
         val usedHandler = handler ?: HandlerThread(
                 TapPacketReaderRule::class.java.simpleName).apply { start() }.threadHandler
-        reader = TapPacketReader(usedHandler, iface.fileDescriptor.fileDescriptor, maxPacketSize)
+        reader = PollPacketReader(usedHandler, iface.fileDescriptor.fileDescriptor, maxPacketSize)
         reader.startAsyncForTest()
         readerRunning = true
     }
 
     /**
-     * Stop the [TapPacketReader].
+     * Stop the [PollPacketReader].
      *
      * Tests calling [start] must call this method before exiting. If a handler was specified in
      * [start], all messages on that handler must also be processed after calling this method and
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
index 84fb47b..341d55f 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestNetworkTracker.kt
@@ -29,7 +29,6 @@
 import android.os.Binder
 import android.os.Build
 import androidx.annotation.RequiresApi
-import com.android.modules.utils.build.SdkLevel.isAtLeastR
 import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.TimeUnit
@@ -137,7 +136,6 @@
 
         network = try {
             if (lp != null) {
-                assertTrue(isAtLeastR(), "Cannot specify TestNetwork LinkProperties before R")
                 tnm.setupTestNetwork(lp, true /* isMetered */, binder)
             } else {
                 tnm.setupTestNetwork(iface.interfaceName, binder)
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
index 8dc1bc4..bfbbc34 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestableNetworkAgent.kt
@@ -14,19 +14,34 @@
  * limitations under the License.
  */
 
-package com.android.testutils;
+package com.android.testutils
 
 import android.content.Context
+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.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,6 +57,8 @@
 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
@@ -65,6 +82,92 @@
     conf: NetworkAgentConfig
 ) : NetworkAgent(context, looper, TestableNetworkAgent::class.java.simpleName /* tag */,
         nc, lp, TEST_NETWORK_SCORE, conf, Provider(context, looper)) {
+    companion object {
+
+        /**
+         * 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/java/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
index 435fdd8..a99359b 100644
--- a/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
+++ b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
@@ -18,16 +18,20 @@
 
 import com.android.ddmlib.testrunner.TestResult
 import com.android.tradefed.config.Option
+import com.android.tradefed.invoker.ExecutionFiles.FilesKey
 import com.android.tradefed.invoker.TestInformation
+import com.android.tradefed.log.LogUtil.CLog
 import com.android.tradefed.result.CollectingTestListener
 import com.android.tradefed.result.ddmlib.DefaultRemoteAndroidTestRunner
 import com.android.tradefed.targetprep.BaseTargetPreparer
 import com.android.tradefed.targetprep.TargetSetupError
 import com.android.tradefed.targetprep.suite.SuiteApkInstaller
+import java.io.File
 
 private const val CONNECTIVITY_CHECKER_APK = "ConnectivityTestPreparer.apk"
 private const val CONNECTIVITY_PKG_NAME = "com.android.testutils.connectivitypreparer"
 private const val CONNECTIVITY_CHECK_CLASS = "$CONNECTIVITY_PKG_NAME.ConnectivityCheckTest"
+private const val CARRIER_CONFIG_SETUP_CLASS = "$CONNECTIVITY_PKG_NAME.CarrierConfigSetupTest"
 
 // As per the <instrumentation> defined in the checker manifest
 private const val CONNECTIVITY_CHECK_RUNNER_NAME = "androidx.test.runner.AndroidJUnitRunner"
@@ -47,7 +51,41 @@
  * --test-arg com.android.testutils.ConnectivityTestTargetPreparer:ignore-wifi-check:true".
  */
 open class ConnectivityTestTargetPreparer : BaseTargetPreparer() {
-    private val installer = SuiteApkInstaller()
+    private val installer = ApkInstaller()
+
+    private class ApkInstaller : SuiteApkInstaller() {
+        override fun getLocalPathForFilename(
+            testInfo: TestInformation,
+            apkFileName: String
+        ): File {
+            if (apkFileName == CONNECTIVITY_CHECKER_APK) {
+                // For the connectivity checker APK, explicitly look for it in the directory of the
+                // host-side preparer.
+                // This preparer is part of the net-tests-utils-host-common library, which includes
+                // the checker APK via device_common_data in its build rule. Both need to be at the
+                // same version so that the preparer calls the right test methods in the checker
+                // APK.
+                // The default strategy for finding test files is to do a recursive search in test
+                // directories, which may find wrong files in wrong directories. In particular,
+                // if some MTS test includes the checker APK, and that test is linked to a module
+                // that boards the train at a version different from this target preparer, there
+                // could be a version difference between the APK and the host-side preparer.
+                // Explicitly looking for the APK in the host-side preparer directory ensures that
+                // it uses the version that was packaged together with the host-side preparer.
+                val testsDir = testInfo.executionFiles().get(FilesKey.TESTS_DIRECTORY)
+                val f = File(testsDir, "net-tests-utils-host-common/$CONNECTIVITY_CHECKER_APK")
+                if (f.isFile) {
+                    return f
+                }
+                // When running locally via atest, device_common_data does cause the APK to be put
+                // into the test temp directory, so recursive search is still used to find it in the
+                // directory of the test module that is being run. This is fine because atest runs
+                // are on local trees that do not have versioning problems.
+                CLog.i("APK not found at $f, falling back to recursive search")
+            }
+            return super.getLocalPathForFilename(testInfo, apkFileName)
+        }
+    }
 
     @Option(
         name = IGNORE_WIFI_CHECK,
@@ -84,27 +122,28 @@
         installer.setShouldGrantPermission(true)
         installer.setUp(testInfo)
 
-        val testMethods = mutableListOf<String>()
+        val testMethods = mutableListOf<Pair<String, String>>()
         if (!ignoreWifiCheck) {
-            testMethods.add("testCheckWifiSetup")
+            testMethods.add(CONNECTIVITY_CHECK_CLASS to "testCheckWifiSetup")
         }
         if (!ignoreMobileDataCheck) {
-            testMethods.add("testCheckTelephonySetup")
+            testMethods.add(CARRIER_CONFIG_SETUP_CLASS to "testSetCarrierConfig")
+            testMethods.add(CONNECTIVITY_CHECK_CLASS to "testCheckTelephonySetup")
         }
 
         testMethods.forEach {
-            runTestMethod(testInfo, it)
+            runTestMethod(testInfo, it.first, it.second)
         }
     }
 
-    private fun runTestMethod(testInfo: TestInformation, method: String) {
+    private fun runTestMethod(testInfo: TestInformation, clazz: String, method: String) {
         val runner = DefaultRemoteAndroidTestRunner(
             CONNECTIVITY_PKG_NAME,
             CONNECTIVITY_CHECK_RUNNER_NAME,
             testInfo.device.iDevice
         )
         runner.runOptions = "--no-hidden-api-checks"
-        runner.setMethodName(CONNECTIVITY_CHECK_CLASS, method)
+        runner.setMethodName(clazz, method)
 
         val receiver = CollectingTestListener()
         if (!testInfo.device.runInstrumentationTests(runner, receiver)) {
@@ -177,7 +216,7 @@
                 .contains(":deny")
         }
 
-    private fun refreshTime(testInfo: TestInformation,) {
+    private fun refreshTime(testInfo: TestInformation) {
         // Forces a synchronous time refresh using the network. Time is fetched synchronously but
         // this does not guarantee that system time is updated when it returns.
         // This avoids flakes where the system clock rolls back, for example when using test
@@ -187,6 +226,9 @@
 
     override fun tearDown(testInfo: TestInformation, e: Throwable?) {
         if (isTearDownDisabled) return
+        if (!ignoreMobileDataCheck) {
+            runTestMethod(testInfo, CARRIER_CONFIG_SETUP_CLASS, "testClearCarrierConfig")
+        }
         installer.tearDown(testInfo, e)
         setUpdaterNetworkingEnabled(
             testInfo,
diff --git a/staticlibs/testutils/host/python/apf_test_base.py b/staticlibs/testutils/host/python/apf_test_base.py
index 9a30978..2552aa3 100644
--- a/staticlibs/testutils/host/python/apf_test_base.py
+++ b/staticlibs/testutils/host/python/apf_test_base.py
@@ -15,7 +15,7 @@
 from mobly import asserts
 from net_tests_utils.host.python import adb_utils, apf_utils, assert_utils, multi_devices_test_base, tether_utils
 from net_tests_utils.host.python.tether_utils import UpstreamType
-
+import time
 
 class ApfTestBase(multi_devices_test_base.MultiDevicesTestBase):
 
@@ -39,6 +39,7 @@
     )
 
     # Fetch device properties and storing them locally for later use.
+    # TODO: refactor to separate instances to store client and server device
     self.server_iface_name, client_network = (
         tether_utils.setup_hotspot_and_client_for_upstream_type(
             self.serverDevice, self.clientDevice, UpstreamType.NONE
@@ -50,6 +51,21 @@
     self.server_mac_address = apf_utils.get_hardware_address(
         self.serverDevice, self.server_iface_name
     )
+    self.client_mac_address = apf_utils.get_hardware_address(
+        self.clientDevice, self.client_iface_name
+    )
+    self.server_ipv4_addresses = apf_utils.get_ipv4_addresses(
+        self.serverDevice, self.server_iface_name
+    )
+    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.serverDevice, self.server_iface_name
+    )
+    self.client_ipv6_addresses = apf_utils.get_ipv6_addresses(
+        self.clientDevice, self.client_iface_name
+    )
 
     # Enable doze mode to activate APF.
     adb_utils.set_doze_mode(self.clientDevice, True)
@@ -81,4 +97,19 @@
         > count_before_test
     )
 
-    # TODO: Verify the packet is not actually received.
+  def send_packet_and_expect_reply_received(
+      self, send_packet: str, counter_name: str, receive_packet: str
+  ) -> None:
+    try:
+        apf_utils.start_capture_packets(self.serverDevice, self.server_iface_name)
+
+        self.send_packet_and_expect_counter_increased(send_packet, counter_name)
+
+        assert_utils.expect_with_retry(
+            lambda: apf_utils.get_matched_packet_counts(
+                self.serverDevice, self.server_iface_name, receive_packet
+            )
+            == 1
+        )
+    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 c3330d2..55ac860 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -83,6 +83,69 @@
   ad.log.debug("Getting apf counters: " + str(result))
   return result
 
+def get_ipv4_addresses(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> list[str]:
+  """Retrieves the IPv4 addresses of a given interface on an Android device.
+
+  This function executes an ADB shell command (`ip -4 address show`) to get the
+  network interface information and extracts the IPv4 address from the output.
+  If devices have no IPv4 address, raise PatternNotFoundException.
+
+  Args:
+      ad: The Android device object.
+      iface_name: The name of the network interface (e.g., "wlan0").
+
+  Returns:
+      The IPv4 addresses of the interface as a list of string.
+      Return empty list if no IPv4 address.
+  """
+  # output format:
+  # 54: wlan2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
+  #     inet 192.168.195.162/24 brd 192.168.195.255 scope global wlan2
+  #         valid_lft forever preferred_lft forever
+  #     inet 192.168.1.1/24 brd 192.168.1.255 scope global wlan2
+  #         valid_lft forever preferred_lft forever
+  output = adb_utils.adb_shell(ad, f"ip -4 address show {iface_name}")
+  pattern = r"inet\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/\d+"
+  matches = re.findall(pattern, output)
+
+  if matches:
+    return matches
+  else:
+    return []
+
+def get_ipv6_addresses(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> list[str]:
+  """Retrieves the IPv6 addresses of a given interface on an Android device.
+
+  This function executes an ADB shell command (`ip -6 address show`) to get the
+  network interface information and extracts the IPv6 address from the output.
+  If devices have no IPv6 address, raise PatternNotFoundException.
+
+  Args:
+      ad: The Android device object.
+      iface_name: The name of the network interface (e.g., "wlan0").
+
+  Returns:
+      The IPv6 addresses of the interface as a list of string.
+      Return empty list if no IPv6 address.
+  """
+  # output format
+  # 54: wlan2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+  #     inet6 fe80::10a3:5dff:fe52:de32/64 scope link
+  #         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}")
+  pattern = r"inet6\s+([0-9a-fA-F:]+)\/\d+"
+  matches = re.findall(pattern, output)
+
+  if matches:
+    return matches
+  else:
+    return []
 
 def get_hardware_address(
     ad: android_device.AndroidDevice, iface_name: str
@@ -115,6 +178,30 @@
         "Cannot get hardware address for " + iface_name
     )
 
+def is_packet_capture_supported(
+        ad: android_device.AndroidDevice,
+) -> bool:
+
+  try:
+    # Invoke the shell command with empty argument and see how NetworkStack respond.
+    # If supported, an IllegalArgumentException with help page will be printed.
+    assert_utils.expect_throws(
+      lambda: start_capture_packets(ad, ""),
+      assert_utils.UnexpectedBehaviorError
+    )
+    assert_utils.expect_throws(
+      lambda: stop_capture_packets(ad, ""),
+      assert_utils.UnexpectedBehaviorError
+    )
+    assert_utils.expect_throws(
+      lambda: get_matched_packet_counts(ad, "", ""),
+      assert_utils.UnexpectedBehaviorError
+    )
+  except assert_utils.UnexpectedExceptionError:
+    return False
+
+  # If no UnsupportOperationException is thrown, regard it as supported
+  return True
 
 def is_send_raw_packet_downstream_supported(
     ad: android_device.AndroidDevice,
@@ -161,25 +248,92 @@
         representation of a packet starting from L2 header.
   """
 
-  cmd = (
-      "cmd network_stack send-raw-packet-downstream"
-      f" {iface_name} {packet_in_hex}"
-  )
+  cmd = f"cmd network_stack send-raw-packet-downstream {iface_name} {packet_in_hex}"
 
   # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
-  try:
-    output = adb_utils.adb_shell(ad, cmd)
-  except AdbError as e:
-    output = str(e.stdout)
-  if output:
-    if "Unknown command" in output:
-      raise UnsupportedOperationException(
-          "send-raw-packet-downstream command is not supported."
-      )
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output:
     raise assert_utils.UnexpectedBehaviorError(
-        f"Got unexpected output: {output} for command: {cmd}."
+      f"Got unexpected output: {adb_output} for command: {cmd}."
     )
 
+def start_capture_packets(
+        ad: android_device.AndroidDevice,
+        iface_name: str
+) -> None:
+  """Starts packet capturing on a specified network interface.
+
+  This function initiates packet capture on the given network interface of an
+  Android device using an ADB shell command. It handles potential errors
+  related to unsupported commands or unexpected output.
+  This command only supports downstream tethering interface.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+  """
+  cmd = f"cmd network_stack capture start {iface_name}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output != "success":
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected output: {adb_output} for command: {cmd}."
+    )
+
+def stop_capture_packets(
+        ad: android_device.AndroidDevice,
+        iface_name: str
+) -> None:
+  """Stops packet capturing on a specified network interface.
+
+  This function terminates packet capture on the given network interface of an
+  Android device using an ADB shell command. It handles potential errors
+  related to unsupported commands or unexpected output.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+  """
+  cmd = f"cmd network_stack capture stop {iface_name}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output != "success":
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected output: {adb_output} for command: {cmd}."
+    )
+
+def get_matched_packet_counts(
+        ad: android_device.AndroidDevice,
+        iface_name: str,
+        packet_in_hex: str
+) -> int:
+  """Gets the number of captured packets matching a specific hexadecimal pattern.
+
+  This function retrieves the count of captured packets on the specified
+  network interface that match a given hexadecimal pattern. It uses an ADB
+  shell command and handles potential errors related to unsupported commands,
+  unexpected output, or invalid output format.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+    packet_in_hex: The hexadecimal string representing the packet pattern.
+
+  Returns:
+    The number of matched packets as an integer.
+  """
+  cmd = f"cmd network_stack capture matched-packet-counts {iface_name} {packet_in_hex}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  try:
+    return int(adb_output)
+  except ValueError as e:
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected exception: {e} for command: {cmd}."
+    )
 
 @dataclass
 class ApfCapabilities:
@@ -241,3 +395,19 @@
       f"Supported apf version {caps.apf_version_supported} < expected version"
       f" {expected_version}",
   )
+
+class AdbOutputHandler:
+  def __init__(self, ad, cmd):
+    self._ad = ad
+    self._cmd = cmd
+
+  def get_output(self) -> str:
+    try:
+      return adb_utils.adb_shell(self._ad, self._cmd)
+    except AdbError as e:
+      output = str(e.stdout)
+      if "Unknown command" in output:
+        raise UnsupportedOperationException(
+          f"{self._cmd} is not supported."
+        )
+      return output
\ No newline at end of file
diff --git a/staticlibs/testutils/host/python/assert_utils.py b/staticlibs/testutils/host/python/assert_utils.py
index da1bb9e..40094a2 100644
--- a/staticlibs/testutils/host/python/assert_utils.py
+++ b/staticlibs/testutils/host/python/assert_utils.py
@@ -19,6 +19,8 @@
 class UnexpectedBehaviorError(Exception):
   """Raised when there is an unexpected behavior during applying a procedure."""
 
+class UnexpectedExceptionError(Exception):
+  """Raised when there is an unexpected exception throws during applying a procedure"""
 
 def expect_with_retry(
     predicate: Callable[[], bool],
@@ -41,3 +43,17 @@
   raise UnexpectedBehaviorError(
       "Predicate didn't become true after " + str(max_retries) + " retries."
   )
+
+def expect_throws(runnable: callable, exception_class) -> None:
+  try:
+    runnable()
+    raise UnexpectedBehaviorError("Expected an exception, but none was thrown")
+  except exception_class:
+    pass
+  except UnexpectedBehaviorError as e:
+    raise e
+  except Exception as e:
+      raise UnexpectedExceptionError(
+        f"Expected exception of type {exception_class.__name__}, "
+        f"but got {type(e).__name__}: {e}"
+      )
\ No newline at end of file
diff --git a/staticlibs/testutils/host/python/packet_utils.py b/staticlibs/testutils/host/python/packet_utils.py
new file mode 100644
index 0000000..b613f03
--- /dev/null
+++ b/staticlibs/testutils/host/python/packet_utils.py
@@ -0,0 +1,70 @@
+#  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 702b596..c63de1f 100644
--- a/staticlibs/testutils/host/python/tether_utils.py
+++ b/staticlibs/testutils/host/python/tether_utils.py
@@ -108,3 +108,6 @@
     server.unregisterAll()
   # Teardown the hotspot.
   server.stopAllTethering()
+  # Some test cases would disable wifi, e.g. cellular upstream tests.
+  # Reconnect to it if feasible.
+  server.reconnectWifiIfSupported()
diff --git a/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt b/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
index 9f28234..45c69c9 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
@@ -20,6 +20,7 @@
 
 import com.android.testutils.FunctionalUtils.ThrowingRunnable
 import com.android.testutils.FunctionalUtils.ThrowingSupplier
+import java.util.function.Consumer
 import javax.annotation.CheckReturnValue
 
 /**
@@ -73,18 +74,34 @@
  * });
  */
 
+object TryTestConfig {
+    private var diagnosticsCollector: Consumer<Throwable>? = null
+
+    /**
+     * Set the diagnostics collector to be used in case of failure in [tryTest].
+     *
+     * @return The previous collector.
+     */
+    fun swapDiagnosticsCollector(collector: Consumer<Throwable>?): Consumer<Throwable>? {
+        val oldCollector = diagnosticsCollector
+        diagnosticsCollector = collector
+        return oldCollector
+    }
+
+    fun reportError(e: Throwable) {
+        diagnosticsCollector?.accept(e)
+    }
+}
+
 @CheckReturnValue
 fun <T> tryTest(block: () -> T) = TryExpr(
         try {
             Result.success(block())
         } catch (e: Throwable) {
             Result.failure(e)
-        })
+        }, skipErrorReporting = false)
 
-// Some downstream branches have an older kotlin that doesn't know about value classes.
-// TODO : Change this to "value class" when aosp no longer merges into such branches.
-@Suppress("INLINE_CLASS_DEPRECATED")
-inline class TryExpr<T>(val result: Result<T>) {
+class TryExpr<T>(val result: Result<T>, val skipErrorReporting: Boolean) {
     inline infix fun <reified E : Throwable> catch(block: (E) -> T): TryExpr<T> {
         val originalException = result.exceptionOrNull()
         if (originalException !is E) return this
@@ -92,23 +109,32 @@
             Result.success(block(originalException))
         } catch (e: Throwable) {
             Result.failure(e)
-        })
+        }, this.skipErrorReporting)
     }
 
     @CheckReturnValue
     inline infix fun cleanupStep(block: () -> Unit): TryExpr<T> {
+        // Report errors before the cleanup step, but after catch blocks that may suppress it
+        val originalException = result.exceptionOrNull()
+        var nextSkipErrorReporting = skipErrorReporting
+        if (!skipErrorReporting && originalException != null) {
+            TryTestConfig.reportError(originalException)
+            nextSkipErrorReporting = true
+        }
         try {
             block()
         } catch (e: Throwable) {
-            val originalException = result.exceptionOrNull()
-            return TryExpr(if (null == originalException) {
-                Result.failure(e)
+            return if (null == originalException) {
+                if (!skipErrorReporting) {
+                    TryTestConfig.reportError(e)
+                }
+                TryExpr(Result.failure(e), skipErrorReporting = true)
             } else {
                 originalException.addSuppressed(e)
-                Result.failure(originalException)
-            })
+                TryExpr(Result.failure(originalException), true)
+            }
         }
-        return this
+        return TryExpr(result, nextSkipErrorReporting)
     }
 
     inline infix fun cleanup(block: () -> Unit): T = cleanupStep(block).result.getOrThrow()
diff --git a/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt b/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
index 1883387..176546a 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
@@ -20,11 +20,14 @@
 
 import com.android.testutils.FunctionalUtils.ThrowingRunnable
 import java.lang.reflect.Modifier
+import java.util.concurrent.TimeUnit
+import java.util.function.BooleanSupplier
 import kotlin.system.measureTimeMillis
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
 import kotlin.test.assertTrue
+import kotlin.test.fail
 
 private const val TAG = "Connectivity unit test"
 
@@ -118,4 +121,25 @@
     val actualSet: HashSet<T> = HashSet(actual)
     assertEquals(actualSet.size, actual.size, "actual list contains duplicates")
     assertEquals(expectedSet, actualSet)
+}
+
+@JvmOverloads
+fun assertEventuallyTrue(
+    descr: String,
+    timeoutMs: Long,
+    pollIntervalMs: Long = 10L,
+    fn: BooleanSupplier
+) {
+    // This should use SystemClock.elapsedRealtime() since nanoTime does not include time in deep
+    // sleep, but this is a host-device library and SystemClock is Android-specific (not available
+    // on host). When waiting for a condition during tests the device would generally not go into
+    // deep sleep, and the polling sleep would go over the timeout anyway in that case, so this is
+    // fine.
+    val limit = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
+    while (!fn.asBoolean) {
+        if (System.nanoTime() > limit) {
+            fail(descr)
+        }
+        Thread.sleep(pollIntervalMs)
+    }
 }
\ No newline at end of file
diff --git a/tests/common/Android.bp b/tests/common/Android.bp
index e95a81a..60a02fb 100644
--- a/tests/common/Android.bp
+++ b/tests/common/Android.bp
@@ -61,7 +61,7 @@
 // Combine Connectivity, NetworkStack and Tethering jarjar rules for coverage target.
 // The jarjar files are simply concatenated in the order specified in srcs.
 // jarjar stops at the first matching rule, so order of concatenation affects the output.
-genrule {
+java_genrule {
     name: "ConnectivityCoverageJarJarRules",
     defaults: ["jarjar-rules-combine-defaults"],
     srcs: [
@@ -78,7 +78,7 @@
     name: "ConnectivityCoverageTestsLib",
     min_sdk_version: "30",
     static_libs: [
-        "FrameworksNetTestsLib",
+        "ConnectivityUnitTestsLib",
         "NetdStaticLibTestsLib",
         "NetworkStaticLibTestsLib",
         "NetworkStackTestsLib",
@@ -147,6 +147,7 @@
         // meaning @hide APIs in framework-connectivity are resolved before @SystemApi
         // stubs in framework
         "framework-connectivity.impl",
+        "framework-connectivity-b.impl",
         "framework-connectivity-t.impl",
         "framework-tethering.impl",
         "framework",
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/NetworkAgentConfigTest.kt b/tests/common/java/android/net/NetworkAgentConfigTest.kt
index d640a73..fe869f8 100644
--- a/tests/common/java/android/net/NetworkAgentConfigTest.kt
+++ b/tests/common/java/android/net/NetworkAgentConfigTest.kt
@@ -20,6 +20,7 @@
 import androidx.test.runner.AndroidJUnit4
 import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import com.android.modules.utils.build.SdkLevel.isAtLeastT
+import com.android.modules.utils.build.SdkLevel.isAtLeastV
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.assertParcelingIsLossless
 import org.junit.Assert.assertEquals
@@ -47,6 +48,9 @@
                 setLocalRoutesExcludedForVpn(true)
                 setVpnRequiresValidation(true)
             }
+            if (isAtLeastV()) {
+                setSkipNativeNetworkCreation(true)
+            }
         }.build()
         assertParcelingIsLossless(config)
     }
@@ -71,6 +75,9 @@
                 setLocalRoutesExcludedForVpn(true)
                 setVpnRequiresValidation(true)
             }
+            if (isAtLeastV()) {
+                setSkipNativeNetworkCreation(true)
+            }
         }.build()
 
         assertTrue(config.isExplicitlySelected())
@@ -79,6 +86,9 @@
         assertFalse(config.isPartialConnectivityAcceptable())
         assertTrue(config.isUnvalidatedConnectivityAcceptable())
         assertEquals("TEST_NETWORK", config.getLegacyTypeName())
+        if (isAtLeastV()) {
+            assertTrue(config.shouldSkipNativeNetworkCreation())
+        }
         if (isAtLeastT()) {
             assertTrue(config.areLocalRoutesExcludedForVpn())
             assertTrue(config.isVpnValidationRequired())
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index 0f0e2f1..d694637 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -382,6 +382,7 @@
             netCap.setAllowedUids(allowedUids);
             netCap.setSubscriptionIds(Set.of(TEST_SUBID1, TEST_SUBID2));
             netCap.setUids(uids);
+            netCap.setReservationId(42);
         }
 
         netCap.setOwnerUid(123);
@@ -1493,4 +1494,42 @@
         // 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);
+    }
 }
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 14d5d54..0ac9ce1 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -12,17 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-next_app_data = [":CtsHostsideNetworkTestsAppNext"]
-
-// The above line is put in place to prevent any future automerger merge conflict between aosp,
-// downstream branches. The CtsHostsideNetworkTestsAppNext target will not exist in
-// some downstream branches, but it should exist in aosp and some downstream branches.
-
 package {
     default_team: "trendy_team_fwk_core_networking",
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+java_defaults {
+    name: "CtsHostsideNetworkTestsAllAppDefaults",
+    platform_apis: true,
+    min_sdk_version: "30",
+    // Set target SDK to 10000 so that all the test helper apps are always subject to the most
+    // recent (and possibly most restrictive) target SDK behaviour. Also, this matches the target
+    // SDK of the tests themselves, and of other tests such as CtsNetTestCases.
+    // 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
+    // released devices with errors such as "Requires development platform VanillaIceCream but this
+    // is a release platform".
+    target_sdk_version: "10000",
+}
+
 java_test_host {
     name: "CtsHostsideNetworkTests",
     defaults: ["cts_defaults"],
@@ -46,12 +56,12 @@
         "mts-tethering",
         "sts",
     ],
-    data: [
+    device_common_data: [
         ":CtsHostsideNetworkTestsApp",
         ":CtsHostsideNetworkTestsApp2",
         ":CtsHostsideNetworkCapTestsAppWithoutProperty",
         ":CtsHostsideNetworkCapTestsAppWithProperty",
         ":CtsHostsideNetworkCapTestsAppSdk33",
-    ] + next_app_data,
+    ],
     per_testcase_directory: true,
 }
diff --git a/tests/cts/hostside/aidl/Android.bp b/tests/cts/hostside/aidl/Android.bp
index 31924f0..ca3e77f 100644
--- a/tests/cts/hostside/aidl/Android.bp
+++ b/tests/cts/hostside/aidl/Android.bp
@@ -19,8 +19,7 @@
 
 java_test_helper_library {
     name: "CtsHostsideNetworkTestsAidl",
-    sdk_version: "current",
-    min_sdk_version: "30",
+    sdk_version: "system_current",
     srcs: [
         "com/android/cts/net/hostside/*.aidl",
         "com/android/cts/net/hostside/*.java",
diff --git a/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl b/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl
new file mode 100644
index 0000000..a9f5ed4
--- /dev/null
+++ b/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl
@@ -0,0 +1,23 @@
+/*
+ * 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.cts.net.hostside;
+
+import android.net.TetheringInterface;
+
+interface ITetheringHelper {
+    TetheringInterface getTetheredWifiInterface();
+}
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 2ca9adb..7fff1c2 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -19,9 +19,13 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-java_defaults {
-    name: "CtsHostsideNetworkTestsAppDefaults",
-    platform_apis: true,
+android_test_helper_app {
+    name: "CtsHostsideNetworkTestsApp",
+    defaults: [
+        "cts_support_defaults",
+        "framework-connectivity-test-defaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
+    ],
     static_libs: [
         "CtsHostsideNetworkTestsAidl",
         "androidx.test.ext.junit",
@@ -39,35 +43,4 @@
     srcs: [
         "src/**/*.java",
     ],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "general-tests",
-        "sts",
-    ],
-    min_sdk_version: "30",
-}
-
-android_test_helper_app {
-    name: "CtsHostsideNetworkTestsApp",
-    defaults: [
-        "cts_support_defaults",
-        "framework-connectivity-test-defaults",
-        "CtsHostsideNetworkTestsAppDefaults",
-    ],
-    static_libs: [
-        "NetworkStackApiStableShims",
-    ],
-}
-
-android_test_helper_app {
-    name: "CtsHostsideNetworkTestsAppNext",
-    defaults: [
-        "cts_support_defaults",
-        "framework-connectivity-test-defaults",
-        "CtsHostsideNetworkTestsAppDefaults",
-        "ConnectivityNextEnableDefaults",
-    ],
-    static_libs: [
-        "NetworkStackApiCurrentShims",
-    ],
 }
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
index fe522a0..a39a8d0 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
@@ -29,9 +29,6 @@
 import android.util.Pair;
 
 import com.android.modules.utils.build.SdkLevel;
-import com.android.networkstack.apishim.VpnServiceBuilderShimImpl;
-import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
-import com.android.networkstack.apishim.common.VpnServiceBuilderShim;
 import com.android.testutils.PacketReflector;
 
 import java.io.IOException;
@@ -102,8 +99,7 @@
     }
 
     private void start(String packageName, Intent intent) {
-        Builder builder = new Builder();
-        VpnServiceBuilderShim vpnServiceBuilderShim = VpnServiceBuilderShimImpl.newInstance();
+        VpnService.Builder builder = new VpnService.Builder();
 
         final String addresses = parseIpAndMaskListArgument(packageName, intent, "addresses",
                 builder::addAddress);
@@ -112,11 +108,7 @@
         if (SdkLevel.isAtLeastT() && intent.getBooleanExtra(packageName + ".addRoutesByIpPrefix",
                 false)) {
             addedRoutes = parseIpPrefixListArgument(packageName, intent, "routes", (prefix) -> {
-                try {
-                    vpnServiceBuilderShim.addRoute(builder, prefix);
-                } catch (UnsupportedApiLevelException e) {
-                    throw new RuntimeException(e);
-                }
+                builder.addRoute(prefix);
             });
         } else {
             addedRoutes = parseIpAndMaskListArgument(packageName, intent, "routes",
@@ -127,11 +119,7 @@
         if (SdkLevel.isAtLeastT()) {
             excludedRoutes = parseIpPrefixListArgument(packageName, intent, "excludedRoutes",
                     (prefix) -> {
-                        try {
-                            vpnServiceBuilderShim.excludeRoute(builder, prefix);
-                        } catch (UnsupportedApiLevelException e) {
-                            throw new RuntimeException(e);
-                        }
+                        builder.excludeRoute(prefix);
                     });
         }
 
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java
new file mode 100644
index 0000000..5f5ebb0
--- /dev/null
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java
@@ -0,0 +1,88 @@
+/*
+ * 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.cts.net.hostside;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.TetheringInterface;
+import android.os.ConditionVariable;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+public class TetheringHelperClient {
+    private static final int TIMEOUT_MS = 5000;
+    private static final String PACKAGE = TetheringHelperClient.class.getPackage().getName();
+    private static final String APP2_PACKAGE = PACKAGE + ".app2";
+    private static final String SERVICE_NAME = APP2_PACKAGE + ".TetheringHelperService";
+
+    private Context mContext;
+    private ServiceConnection mServiceConnection;
+    private ITetheringHelper mService;
+
+    public TetheringHelperClient(Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Binds to TetheringHelperService.
+     */
+    public void bind() {
+        if (mService != null) {
+            throw new IllegalStateException("Already bound");
+        }
+
+        final ConditionVariable cv = new ConditionVariable();
+        mServiceConnection = new ServiceConnection() {
+            @Override
+            public void onServiceConnected(ComponentName name, IBinder service) {
+                mService = ITetheringHelper.Stub.asInterface(service);
+                cv.open();
+            }
+            @Override
+            public void onServiceDisconnected(ComponentName name) {
+                mService = null;
+            }
+        };
+
+        final Intent intent = new Intent();
+        intent.setComponent(new ComponentName(APP2_PACKAGE, SERVICE_NAME));
+        mContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
+        cv.block(TIMEOUT_MS);
+        if (mService == null) {
+            throw new IllegalStateException(
+                    "Could not bind to TetheringHelperService after " + TIMEOUT_MS + "ms");
+        }
+    }
+
+    /**
+     * Unbinds from TetheringHelperService.
+     */
+    public void unbind() {
+        if (mService != null) {
+            mContext.unbindService(mServiceConnection);
+        }
+    }
+
+    /**
+     * Returns the tethered Wifi interface as seen from TetheringHelperService.
+     */
+    public TetheringInterface getTetheredWifiInterface() throws RemoteException {
+        return mService.getTetheredWifiInterface();
+    }
+}
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
new file mode 100644
index 0000000..ac60b0f
--- /dev/null
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.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.cts.net.hostside;
+
+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 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 org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+public class TetheringTest {
+    private CtsTetheringUtils mCtsTetheringUtils;
+    private TetheringHelperClient mTetheringHelperClient;
+    private TestTetheringEventCallback mTetheringEventCallback;
+
+    @Before
+    public void setUp() throws Exception {
+        Context targetContext = getInstrumentation().getTargetContext();
+        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();
+    }
+
+    /**
+     * Starts Wifi tethering and tests that the SoftApConfiguration is redacted from
+     * TetheringEventCallback for other apps.
+     */
+    @Test
+    public void testSoftApConfigurationRedactedForOtherUids() throws Exception {
+        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(mTetheringEventCallback, softApConfig);
+        assertNotNull(tetheringInterface);
+        assertEquals(softApConfig, tetheringInterface.getSoftApConfiguration());
+        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 d7631eb..3430196 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
@@ -20,8 +20,12 @@
 import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
+import static android.content.Context.RECEIVER_EXPORTED;
 import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
+import static android.net.ConnectivityManager.BLOCKED_REASON_LOCKDOWN_VPN;
+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.TRANSPORT_TEST;
@@ -46,9 +50,6 @@
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_RESUMED;
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_STARTED;
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_STOPPED;
-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.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.RecorderCallback.CallbackEntry.BLOCKED_STATUS_INT;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
@@ -1209,7 +1210,7 @@
                     AUTOMATIC_ON_OFF_KEEPALIVE_VERSION,
                     AUTOMATIC_ON_OFF_KEEPALIVE_ENABLED, false /* makeDefault */);
             return mode;
-        }, READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG);
+        }, READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG);
 
         final IpSecManager ipSec = mTargetContext.getSystemService(IpSecManager.class);
         SocketKeepalive kp = null;
@@ -1249,7 +1250,7 @@
                                 AUTOMATIC_ON_OFF_KEEPALIVE_VERSION,
                                 origMode, false);
                 mCM.setTestLowTcpPollingTimerForKeepalive(0);
-            }, WRITE_DEVICE_CONFIG, NETWORK_SETTINGS);
+            }, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG, NETWORK_SETTINGS);
         }
     }
 
diff --git a/tests/cts/hostside/app2/Android.bp b/tests/cts/hostside/app2/Android.bp
index cb55c7b..05abcdd 100644
--- a/tests/cts/hostside/app2/Android.bp
+++ b/tests/cts/hostside/app2/Android.bp
@@ -21,20 +21,14 @@
 
 android_test_helper_app {
     name: "CtsHostsideNetworkTestsApp2",
-    defaults: ["cts_support_defaults"],
-    platform_apis: true,
+    defaults: [
+        "cts_support_defaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
+    ],
     static_libs: [
         "androidx.annotation_annotation",
         "CtsHostsideNetworkTestsAidl",
-        "NetworkStackApiStableShims",
     ],
     srcs: ["src/**/*.java"],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "cts",
-        "general-tests",
-        "sts",
-    ],
     sdk_version: "test_current",
-    min_sdk_version: "30",
 }
diff --git a/tests/cts/hostside/app2/AndroidManifest.xml b/tests/cts/hostside/app2/AndroidManifest.xml
index 412b307..6ccaf4f 100644
--- a/tests/cts/hostside/app2/AndroidManifest.xml
+++ b/tests/cts/hostside/app2/AndroidManifest.xml
@@ -42,6 +42,8 @@
             android:debuggable="true">
         <service android:name=".RemoteSocketFactoryService"
              android:exported="true"/>
+        <service android:name=".TetheringHelperService"
+             android:exported="true"/>
     </application>
 
     <!--
diff --git a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java
new file mode 100644
index 0000000..56a8cbb
--- /dev/null
+++ b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java
@@ -0,0 +1,78 @@
+/*
+ * 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.cts.net.hostside.app2;
+
+
+import static android.net.TetheringManager.TETHERING_WIFI;
+
+import android.app.Service;
+import android.content.Intent;
+import android.net.TetheringInterface;
+import android.net.TetheringManager;
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+
+import com.android.cts.net.hostside.ITetheringHelper;
+
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public class TetheringHelperService extends Service {
+    private static final String TAG = TetheringHelperService.class.getSimpleName();
+
+    private ITetheringHelper.Stub mBinder = new ITetheringHelper.Stub() {
+        public TetheringInterface getTetheredWifiInterface() {
+            ArrayBlockingQueue<TetheringInterface> queue = new ArrayBlockingQueue<>(1);
+            TetheringManager.TetheringEventCallback callback =
+                    new TetheringManager.TetheringEventCallback() {
+                        @Override
+                        public void onTetheredInterfacesChanged(
+                                @NonNull Set<TetheringInterface> interfaces) {
+                            for (TetheringInterface iface : interfaces) {
+                                if (iface.getType() == TETHERING_WIFI) {
+                                    queue.offer(iface);
+                                    break;
+                                }
+                            }
+                        }
+                    };
+            TetheringManager tm =
+                    TetheringHelperService.this.getSystemService(TetheringManager.class);
+            tm.registerTetheringEventCallback(Runnable::run, callback);
+            TetheringInterface iface;
+            try {
+                iface = queue.poll(5, TimeUnit.SECONDS);
+            } catch (InterruptedException e) {
+                throw new IllegalStateException("Wait for wifi TetheredInterface interrupted");
+            } finally {
+                tm.unregisterTetheringEventCallback(callback);
+            }
+            if (iface == null) {
+                throw new IllegalStateException(
+                        "No wifi TetheredInterface received after 5 seconds");
+            }
+            return iface;
+        }
+    };
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return mBinder;
+    }
+}
diff --git a/tests/cts/hostside/networkslicingtestapp/Android.bp b/tests/cts/hostside/networkslicingtestapp/Android.bp
index 79ad2e2..0eed51c 100644
--- a/tests/cts/hostside/networkslicingtestapp/Android.bp
+++ b/tests/cts/hostside/networkslicingtestapp/Android.bp
@@ -21,7 +21,6 @@
 
 java_defaults {
     name: "CtsHostsideNetworkCapTestsAppDefaults",
-    platform_apis: true,
     static_libs: [
         "androidx.test.ext.junit",
         "androidx.test.rules",
@@ -29,13 +28,6 @@
         "cts-net-utils",
     ],
     srcs: ["src/**/*.java"],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "cts",
-        "general-tests",
-        "sts",
-    ],
-    min_sdk_version: "30",
 }
 
 android_test_helper_app {
@@ -43,6 +35,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     manifest: "AndroidManifestWithoutProperty.xml",
     sdk_version: "test_current",
@@ -53,6 +46,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     manifest: "AndroidManifestWithProperty.xml",
     sdk_version: "test_current",
@@ -63,6 +57,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     target_sdk_version: "33",
     manifest: "AndroidManifestWithoutProperty.xml",
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java b/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
index 69d61b3..e222ff6 100644
--- a/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
@@ -19,7 +19,6 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 
-import com.android.modules.utils.build.testing.DeviceSdkLevel;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.invoker.TestInformation;
 import com.android.tradefed.targetprep.BuildError;
@@ -36,17 +35,13 @@
 abstract class HostsideNetworkTestCase extends BaseHostJUnit4Test {
     protected static final String TEST_PKG = "com.android.cts.net.hostside";
     protected static final String TEST_APK = "CtsHostsideNetworkTestsApp.apk";
-    protected static final String TEST_APK_NEXT = "CtsHostsideNetworkTestsAppNext.apk";
     protected static final String TEST_APP2_PKG = "com.android.cts.net.hostside.app2";
     protected static final String TEST_APP2_APK = "CtsHostsideNetworkTestsApp2.apk";
 
     @BeforeClassWithInfo
     public static void setUpOnceBase(TestInformation testInfo) throws Exception {
-        DeviceSdkLevel deviceSdkLevel = new DeviceSdkLevel(testInfo.getDevice());
-        String testApk = deviceSdkLevel.isDeviceAtLeastV() ? TEST_APK_NEXT : TEST_APK;
-
         uninstallPackage(testInfo, TEST_PKG, false);
-        installPackage(testInfo, testApk);
+        installPackage(testInfo, TEST_APK);
     }
 
     @AfterClassWithInfo
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java b/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java
new file mode 100644
index 0000000..d73e01a
--- /dev/null
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java
@@ -0,0 +1,53 @@
+/*
+ * 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.cts.net;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.AfterClassWithInfo;
+import com.android.tradefed.testtype.junit4.BeforeClassWithInfo;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class HostsideTetheringTests extends HostsideNetworkTestCase {
+    /**
+     * Set up the test once before running all the tests.
+     */
+    @BeforeClassWithInfo
+    public static void setUpOnce(TestInformation testInfo) throws Exception {
+        uninstallPackage(testInfo, TEST_APP2_PKG, false);
+        installPackage(testInfo, TEST_APP2_APK);
+    }
+
+    /**
+     * Tear down the test once after running all the tests.
+     */
+    @AfterClassWithInfo
+    public static void tearDownOnce(TestInformation testInfo)
+            throws DeviceNotAvailableException {
+        uninstallPackage(testInfo, TEST_APP2_PKG, true);
+    }
+
+    @Test
+    public void testSoftApConfigurationRedactedForOtherApps() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".TetheringTest",
+                "testSoftApConfigurationRedactedForOtherUids");
+    }
+}
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 fa68e3e..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());
     }
 
     /**
@@ -166,6 +166,8 @@
      */
     @Test
     public void testRouterSolicitations() throws Exception {
+        assumeTrue(new DeviceSdkLevel(mDevice).isDeviceAtLeastU());
+
         for (String interfaceDir : mSysctlDirs) {
             String path = IPV6_SYSCTL_DIR + "/" + interfaceDir + "/" + "router_solicitations";
             int value = readIntFromPath(path);
@@ -186,11 +188,10 @@
      */
     @Test
     public void testCongestionControl() throws Exception {
-        final DeviceSdkLevel dsl = new DeviceSdkLevel(mDevice);
-        assumeTrue(dsl.isDeviceAtLeastV());
+        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 40aa1e4..a082a95 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -22,6 +22,7 @@
     main: "run_tests.py",
     srcs: [
         "apfv4_test.py",
+        "apfv6_test.py",
         "connectivity_multi_devices_test.py",
         "run_tests.py",
     ],
@@ -37,7 +38,7 @@
     test_options: {
         unit_test: false,
     },
-    data: [
+    device_common_data: [
         // Package the snippet with the mobly test
         ":connectivity_multi_devices_snippet",
     ],
diff --git a/tests/cts/multidevices/apfv4_test.py b/tests/cts/multidevices/apfv4_test.py
index 7795be5..aa535fd 100644
--- a/tests/cts/multidevices/apfv4_test.py
+++ b/tests/cts/multidevices/apfv4_test.py
@@ -53,7 +53,7 @@
   )  # 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.client_mac_address.replace(":", "")  # Destination MAC
     packet += self.server_mac_address.replace(":", "")  # Source MAC
     packet += blocked_ether_type
 
diff --git a/tests/cts/multidevices/apfv6_test.py b/tests/cts/multidevices/apfv6_test.py
new file mode 100644
index 0000000..fc732d2
--- /dev/null
+++ b/tests/cts/multidevices/apfv6_test.py
@@ -0,0 +1,84 @@
+#  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 net_tests_utils.host.python import apf_test_base, apf_utils, adb_utils, assert_utils, packet_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):
+        arp_request = packet_utils.construct_arp_packet(
+            src_mac=self.server_mac_address,
+            dst_mac=self.client_mac_address,
+            src_ip=self.server_ipv4_addresses[0],
+            dst_ip=self.client_ipv4_addresses[0],
+            op=packet_utils.ARP_REQUEST_OP
+        )
+
+        arp_reply = packet_utils.construct_arp_packet(
+            src_mac=self.client_mac_address,
+            dst_mac=self.server_mac_address,
+            src_ip=self.client_ipv4_addresses[0],
+            dst_ip=self.server_ipv4_addresses[0],
+            op=packet_utils.ARP_REPLY_OP
+        )
+
+        # Add zero padding up to 60 bytes, since APFv6 ARP offload always sent out 60 bytes reply
+        arp_reply = arp_reply.ljust(ARP_OFFLOAD_REPLY_LEN * 2, "0")
+
+        self.send_packet_and_expect_reply_received(
+            arp_request, "DROPPED_ARP_REQUEST_REPLIED", arp_reply
+        )
+
+    def test_broadcast_arp_request_offload(self):
+        arp_request = packet_utils.construct_arp_packet(
+            src_mac=self.server_mac_address,
+            dst_mac=packet_utils.ETHER_BROADCAST_MAC_ADDRESS,
+            src_ip=self.server_ipv4_addresses[0],
+            dst_ip=self.client_ipv4_addresses[0],
+            op=packet_utils.ARP_REQUEST_OP
+        )
+
+        arp_reply = packet_utils.construct_arp_packet(
+            src_mac=self.client_mac_address,
+            dst_mac=self.server_mac_address,
+            src_ip=self.client_ipv4_addresses[0],
+            dst_ip=self.server_ipv4_addresses[0],
+            op=packet_utils.ARP_REPLY_OP
+        )
+
+        # Add zero padding up to 60 bytes, since APFv6 ARP offload always sent out 60 bytes reply
+        arp_reply = arp_reply.ljust(ARP_OFFLOAD_REPLY_LEN * 2, "0")
+
+        self.send_packet_and_expect_reply_received(
+            arp_request, "DROPPED_ARP_REQUEST_REPLIED", arp_reply
+        )
diff --git a/tests/cts/multidevices/run_tests.py b/tests/cts/multidevices/run_tests.py
index 1391d13..a0d0bec 100644
--- a/tests/cts/multidevices/run_tests.py
+++ b/tests/cts/multidevices/run_tests.py
@@ -16,6 +16,7 @@
 
 import sys
 from apfv4_test import ApfV4Test
+from apfv6_test import ApfV6Test
 from connectivity_multi_devices_test import ConnectivityMultiDevicesTest
 from mobly import suite_runner
 
@@ -35,4 +36,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, ApfV4Test, ApfV6Test], sys.argv)
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 49688cc..6da7e9a 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -89,6 +89,11 @@
         ctsNetUtils.expectNetworkIsSystemDefault(network)
     }
 
+    @Rpc(description = "Reconnect to wifi if supported.")
+    fun reconnectWifiIfSupported() {
+        ctsNetUtils.reconnectWifiIfSupported()
+    }
+
     @Rpc(description = "Unregister all connections.")
     fun unregisterAll() {
         cbHelper.unregisterAll()
diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp
index a5ad7f2..1ba581a 100644
--- a/tests/cts/net/Android.bp
+++ b/tests/cts/net/Android.bp
@@ -70,6 +70,14 @@
         ":ConnectivityTestPreparer",
         ":CtsCarrierServicePackage",
     ],
+    errorprone: {
+        enabled: true,
+        // Error-prone checking only warns of problems when building. To make the build fail with
+        // these errors, list the specific error-prone problems below.
+        javacflags: [
+            "-Xep:NullablePrimitive:ERROR",
+        ],
+    },
 }
 
 // Networking CTS tests for development and release. These tests always target the platform SDK
@@ -87,15 +95,10 @@
         "NetworkStackApiCurrentShims",
     ],
     test_suites: [
+        "automotive-general-tests",
         "cts",
-        "mts-dnsresolver",
-        "mts-networking",
         "mts-tethering",
-        "mts-wifi",
-        "mcts-dnsresolver",
-        "mcts-networking",
         "mcts-tethering",
-        "mcts-wifi",
         "general-tests",
     ],
 }
@@ -158,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/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index 24431a6..cb0e575 100644
--- a/tests/cts/net/AndroidTestTemplate.xml
+++ b/tests/cts/net/AndroidTestTemplate.xml
@@ -19,6 +19,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="config-descriptor:metadata" key="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex" />
     <option name="config-descriptor:metadata" key="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk" />
@@ -41,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" />
@@ -56,7 +58,13 @@
              the runner will only run the tests annotated with that annotation, but if it does not,
              the runner will run all the tests. -->
         <option name="include-annotation" value="com.android.testutils.filters.{MODULE}" />
+        <option name="device-listeners" value="com.android.testutils.ConnectivityDiagnosticsCollector" />
     </test>
+    <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
+        <!-- Pattern matching the fileKey used by ConnectivityDiagnosticsCollector when calling addFileMetric -->
+        <option name="pull-pattern-keys" value="com.android.testutils.ConnectivityDiagnosticsCollector.*"/>
+        <option name="collect-on-run-ended-only" value="true" />
+    </metrics_collector>
     <!-- When this test is run in a Mainline context (e.g. with `mts-tradefed`), only enable it if
         one of the Mainline modules below is present on the device used for testing. -->
     <object type="module_controller" class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
diff --git a/tests/cts/net/api23Test/AndroidTest.xml b/tests/cts/net/api23Test/AndroidTest.xml
index 8042d50..fcc73f3 100644
--- a/tests/cts/net/api23Test/AndroidTest.xml
+++ b/tests/cts/net/api23Test/AndroidTest.xml
@@ -18,6 +18,7 @@
     <option name="config-descriptor:metadata" key="parameter" value="not_instant_app" />
     <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
     <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user_on_secondary_display" />
     <option name="not-shardable" value="true" />
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="cleanup-apks" value="true" />
diff --git a/tests/cts/net/jni/NativeMultinetworkJni.cpp b/tests/cts/net/jni/NativeMultinetworkJni.cpp
index f2214a3..1d848ec 100644
--- a/tests/cts/net/jni/NativeMultinetworkJni.cpp
+++ b/tests/cts/net/jni/NativeMultinetworkJni.cpp
@@ -415,9 +415,17 @@
     strlcpy(dst, buf, size);
 }
 
+static jobject create_query_test_result(JNIEnv* env, uint16_t src_port, int attempts, int errnum) {
+    jclass clazz = env->FindClass(
+        "android/net/cts/MultinetworkApiTest$QueryTestResult");
+    jmethodID ctor = env->GetMethodID(clazz, "<init>", "(III)V");
+
+    return env->NewObject(clazz, ctor, src_port, attempts, errnum);
+}
+
 extern "C"
-JNIEXPORT jint Java_android_net_cts_MultinetworkApiTest_runDatagramCheck(
-        JNIEnv*, jclass, jlong nethandle) {
+JNIEXPORT jobject Java_android_net_cts_MultinetworkApiTest_runDatagramCheck(
+        JNIEnv* env, jclass, jlong nethandle, jint src_port) {
     const struct addrinfo kHints = {
         .ai_flags = AI_ADDRCONFIG,
         .ai_family = AF_UNSPEC,
@@ -433,7 +441,7 @@
         LOGD("android_getaddrinfofornetwork(%llu, %s) returned rval=%d errno=%d",
               handle, kHostname, rval, errno);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
 
     // Rely upon getaddrinfo sorting the best destination to the front.
@@ -442,7 +450,7 @@
         LOGD("socket(%d, %d, %d) failed, errno=%d",
               res->ai_family, res->ai_socktype, res->ai_protocol, errno);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
 
     rval = android_setsocknetwork(handle, fd);
@@ -451,7 +459,31 @@
     if (rval != 0) {
         close(fd);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
+    }
+
+    sockaddr_storage src_addr;
+    socklen_t src_addrlen = sizeof(src_addr);
+    if (src_port) {
+        if (res->ai_family == AF_INET6) {
+            *reinterpret_cast<sockaddr_in6*>(&src_addr) = (sockaddr_in6) {
+                .sin6_family = AF_INET6,
+                .sin6_port = htons(src_port),
+                .sin6_addr = in6addr_any,
+            };
+        } else {
+            *reinterpret_cast<sockaddr_in*>(&src_addr) = (sockaddr_in) {
+                .sin_family = AF_INET,
+                .sin_port = htons(src_port),
+                .sin_addr = { .s_addr = INADDR_ANY },
+            };
+        }
+        if (bind(fd, (sockaddr *)&src_addr, src_addrlen) != 0) {
+            LOGD("Error binding to port %d", src_port);
+            close(fd);
+            freeaddrinfo(res);
+            return create_query_test_result(env, 0, 0, errno);
+        }
     }
 
     char addrstr[kSockaddrStrLen+1];
@@ -462,19 +494,28 @@
     if (rval != 0) {
         close(fd);
         freeaddrinfo(res);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
     freeaddrinfo(res);
 
-    struct sockaddr_storage src_addr;
-    socklen_t src_addrlen = sizeof(src_addr);
     if (getsockname(fd, (struct sockaddr *)&src_addr, &src_addrlen) != 0) {
         close(fd);
-        return -errno;
+        return create_query_test_result(env, 0, 0, errno);
     }
     sockaddr_ntop((const struct sockaddr *)&src_addr, sizeof(src_addr), addrstr, sizeof(addrstr));
     LOGD("... from %s", addrstr);
 
+    uint16_t socket_src_port;
+    if (res->ai_family == AF_INET6) {
+        socket_src_port = ntohs(reinterpret_cast<sockaddr_in6*>(&src_addr)->sin6_port);
+    } else if (src_addr.ss_family == AF_INET) {
+        socket_src_port = ntohs(reinterpret_cast<sockaddr_in*>(&src_addr)->sin_port);
+    } else {
+        LOGD("Invalid source address family %d", src_addr.ss_family);
+        close(fd);
+        return create_query_test_result(env, 0, 0, EAFNOSUPPORT);
+    }
+
     // Don't let reads or writes block indefinitely.
     const struct timeval timeo = { 2, 0 };  // 2 seconds
     setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeo, sizeof(timeo));
@@ -503,7 +544,7 @@
             errnum = errno;
             LOGD("send(QUIC packet) returned sent=%zd, errno=%d", sent, errnum);
             close(fd);
-            return -errnum;
+            return create_query_test_result(env, socket_src_port, i + 1, errnum);
         }
 
         rcvd = recv(fd, response, sizeof(response), 0);
@@ -521,18 +562,19 @@
             LOGD("Does this network block UDP port %s?", kPort);
         }
         close(fd);
-        return -EPROTO;
+        return create_query_test_result(env, socket_src_port, i + 1,
+                rcvd <= 0 ? errnum : EPROTO);
     }
 
     int conn_id_cmp = memcmp(quic_packet + 6, response + 7, 8);
     if (conn_id_cmp != 0) {
         LOGD("sent and received connection IDs do not match");
         close(fd);
-        return -EPROTO;
+        return create_query_test_result(env, socket_src_port, i + 1, EPROTO);
     }
 
     // TODO: Replace this quick 'n' dirty test with proper QUIC-capable code.
 
     close(fd);
-    return 0;
+    return create_query_test_result(env, socket_src_port, i + 1, 0);
 }
diff --git a/tests/cts/net/native/dns/Android.bp b/tests/cts/net/native/dns/Android.bp
index de4a3bf..8138598 100644
--- a/tests/cts/net/native/dns/Android.bp
+++ b/tests/cts/net/native/dns/Android.bp
@@ -62,8 +62,6 @@
         "cts",
         "general-tests",
         "mts-dnsresolver",
-        "mts-networking",
         "mcts-dnsresolver",
-        "mcts-networking",
     ],
 }
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index 9ac2c67..3ab6c0d 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -19,7 +19,10 @@
 
 package android.net.cts
 
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
+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
@@ -35,7 +38,7 @@
 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_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
@@ -49,6 +52,7 @@
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.PowerManager
+import android.os.UserManager
 import android.platform.test.annotations.AppModeFull
 import android.provider.DeviceConfig
 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
@@ -56,6 +60,8 @@
 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
@@ -100,6 +106,7 @@
 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
@@ -140,15 +147,34 @@
         fun turnScreenOff() {
             if (!wakeLock.isHeld()) wakeLock.acquire()
             runShellCommandOrThrow("input keyevent KEYCODE_SLEEP")
-            val result = pollingCheck({ !powerManager.isInteractive() }, timeout_ms = 2000)
-            assertThat(result).isTrue()
+            waitForInteractiveState(false)
         }
 
         fun turnScreenOn() {
             if (wakeLock.isHeld()) wakeLock.release()
             runShellCommandOrThrow("input keyevent KEYCODE_WAKEUP")
-            val result = pollingCheck({ powerManager.isInteractive() }, timeout_ms = 2000)
-            assertThat(result).isTrue()
+            waitForInteractiveState(true)
+        }
+
+        private fun waitForInteractiveState(interactive: Boolean) {
+            // TODO(b/366037029): This test condition should be removed once
+            // PowerManager#isInteractive is fully implemented on automotive
+            // form factor with visible background user.
+            if (isAutomotiveWithVisibleBackgroundUser()) {
+                // Wait for 2 seconds to ensure the interactive state is updated.
+                // This is a workaround for b/366037029.
+                Thread.sleep(2000L)
+            } else {
+                val result = pollingCheck({ powerManager.isInteractive() }, timeout_ms = 2000)
+                assertThat(result).isEqualTo(interactive)
+            }
+        }
+
+        private fun isAutomotiveWithVisibleBackgroundUser(): Boolean {
+            val packageManager = context.getPackageManager()
+            val userManager = context.getSystemService(UserManager::class.java)!!
+            return (packageManager.hasSystemFeature(FEATURE_AUTOMOTIVE) &&
+                    userManager.isVisibleBackgroundUsersSupported)
         }
 
         @BeforeClass
@@ -156,16 +182,18 @@
         @Suppress("ktlint:standard:no-multi-spaces")
         fun setupOnce() {
             // TODO: assertions thrown in @BeforeClass / @AfterClass are not well supported in the
-            // test infrastructure. Consider saving excepion and throwing it in setUp().
+            // test infrastructure. Consider saving exception and throwing it in setUp().
+
             // APF must run when the screen is off and the device is not interactive.
             turnScreenOff()
+
             // Wait for APF to become active.
             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) {
+            runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
                 DeviceConfig.setProperty(
                         NAMESPACE_CONNECTIVITY,
                         APF_NEW_RA_FILTER_VERSION,
@@ -186,8 +214,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()
@@ -199,6 +232,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
@@ -206,10 +241,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
@@ -225,17 +264,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)
             }
         }
 
@@ -274,10 +316,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()
@@ -324,10 +379,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
@@ -347,9 +400,22 @@
         // 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)
         }
+
+        // CHIPSETs (or 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 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.
+        if (vsrApiLevel >= 202504) {
+            assertThat(caps.apfVersionSupported).isEqualTo(6000)
+            assertThat(caps.maximumApfProgramSize).isAtLeast(4000)
+        }
+
+        // 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.
@@ -453,7 +519,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(
@@ -665,69 +731,76 @@
         //     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.addLoad16(R0, ETH_ETHERTYPE_OFFSET)
                 .addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), skipPacketLabel)
                 .addLoad8(R0, IPV6_NEXT_HEADER_OFFSET)
                 .addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), skipPacketLabel)
                 .addLoad8(R0, ICMP6_TYPE_OFFSET)
-                .addJumpIfR0NotEquals(0x81, skipPacketLabel) // Echo reply type
+                .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 {
+        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())
         Log.i(TAG, "counter map: ${apfCounterTracker.counters}")
 
-        assertThat(replyPayload).isEqualTo(firstByte)
+        assertThat(replyPayloads.size).isEqualTo(expectReplyPayloads.size)
+        for (i in replyPayloads.indices) {
+            assertThat(replyPayloads[i]).isEqualTo(expectReplyPayloads[i])
+        }
     }
 }
diff --git a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
index 07e2024..1389be7 100644
--- a/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
+++ b/tests/cts/net/src/android/net/cts/CaptivePortalTest.kt
@@ -44,7 +44,6 @@
 import android.util.Log
 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
 import androidx.test.runner.AndroidJUnit4
-import com.android.modules.utils.build.SdkLevel.isAtLeastR
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL
 import com.android.net.module.util.NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTP_URL
 import com.android.testutils.AutoReleaseNetworkCallbackRule
@@ -201,10 +200,7 @@
                     "access."
             assertNotEquals(network, cm.activeNetwork, wifiDefaultMessage)
 
-            val startPortalAppPermission =
-                    if (isAtLeastR()) NETWORK_SETTINGS
-                    else CONNECTIVITY_INTERNAL
-            runAsShell(startPortalAppPermission) { cm.startCaptivePortalApp(network) }
+            runAsShell(NETWORK_SETTINGS) { cm.startCaptivePortalApp(network) }
 
             // Expect the portal content to be fetched at some point after detecting the portal.
             // Some implementations may fetch the URL before startCaptivePortalApp is called.
diff --git a/tests/cts/net/src/android/net/cts/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 b62db04..9457a42 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -113,6 +113,7 @@
 import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_EXPORTED;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
+import static com.android.testutils.MiscAsserts.assertEventuallyTrue;
 import static com.android.testutils.MiscAsserts.assertThrows;
 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
@@ -144,6 +145,7 @@
 import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
 import android.net.ConnectivitySettingsManager;
+import android.net.DnsResolver;
 import android.net.InetAddresses;
 import android.net.IpSecManager;
 import android.net.IpSecManager.UdpEncapsulationSocket;
@@ -200,6 +202,7 @@
 import com.android.internal.util.ArrayUtils;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
+import com.android.net.module.util.DnsPacket;
 import com.android.networkstack.apishim.ConnectivityManagerShimImpl;
 import com.android.networkstack.apishim.ConstantsShim;
 import com.android.networkstack.apishim.NetworkInformationShimImpl;
@@ -248,7 +251,6 @@
 import java.net.Socket;
 import java.net.SocketException;
 import java.net.URL;
-import java.net.UnknownHostException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -305,6 +307,7 @@
     private static final int LISTEN_ACTIVITY_TIMEOUT_MS = 30_000;
     private static final int NO_CALLBACK_TIMEOUT_MS = 100;
     private static final int NETWORK_REQUEST_TIMEOUT_MS = 3000;
+    private static final int DNS_REQUEST_TIMEOUT_MS = 1000;
     private static final int SOCKET_TIMEOUT_MS = 100;
     private static final int NUM_TRIES_MULTIPATH_PREF_CHECK = 20;
     private static final long INTERVAL_MULTIPATH_PREF_CHECK_MS = 500;
@@ -860,6 +863,55 @@
         });
     }
 
+    @NonNull
+    private static String getDeviceIpv6AddressThroughDnsQuery(Network network) throws Exception {
+        final InetAddress dnsAddr = getAddrByName("ns1.google.com", AF_INET6);
+        assertNotNull("IPv6 address for ns1.google.com should not be null", dnsAddr);
+
+        try (DatagramSocket udpSocket = new DatagramSocket()) {
+            network.bindSocket(udpSocket);
+
+            final DnsPacket queryDnsPkt = new DnsPacket(
+                    new DnsPacket.DnsHeader(new Random().nextInt(), DnsResolver.FLAG_EMPTY,
+                            1 /* qdcount */,
+                            0 /* ancount */),
+                    List.of(DnsPacket.DnsRecord.makeQuestion("o-o.myaddr.l.google.com",
+                            DnsResolver.TYPE_TXT, DnsResolver.CLASS_IN)),
+                    List.of() /* an */
+            );
+            final byte[] queryDnsRawBytes = queryDnsPkt.getBytes();
+            final byte[] receiveBuffer = new byte[1500];
+            final int maxRetry = 3;
+            for (int attempt = 1; attempt <= maxRetry; ++attempt) {
+                try {
+                    final DatagramPacket queryUdpPkt = new DatagramPacket(queryDnsRawBytes,
+                            queryDnsRawBytes.length, dnsAddr, 53 /* port */);
+                    udpSocket.send(queryUdpPkt);
+
+                    final DatagramPacket replyUdpPkt = new DatagramPacket(receiveBuffer,
+                            receiveBuffer.length);
+                    udpSocket.setSoTimeout(DNS_REQUEST_TIMEOUT_MS);
+                    udpSocket.receive(replyUdpPkt);
+                    break;
+                } catch (IOException e) {
+                    if (attempt == maxRetry) {
+                        throw e; // If the last attempt fails, rethrow the exception.
+                    } else {
+                        Log.e(TAG, "DNS request failed (attempt " + attempt + ")" + e);
+                    }
+                }
+            }
+
+            final DnsPacket replyDnsPkt = new DnsPacket(receiveBuffer);
+            final DnsPacket.DnsRecord answerRecord = replyDnsPkt.getRecords(
+                    DnsPacket.ANSECTION).get(0);
+            final byte[] txtReplyRecord = answerRecord.getRR();
+            final byte dataLength = txtReplyRecord[0];
+            assertEquals(dataLength, txtReplyRecord.length - 1);
+            return new String(Arrays.copyOfRange(txtReplyRecord, 1, txtReplyRecord.length));
+        }
+    }
+
     /**
      * Tests that connections can be opened on WiFi and cellphone networks,
      * and that they are made from different IP addresses.
@@ -885,8 +937,30 @@
 
         // Verify that the IP addresses that the requests appeared to come from are actually on the
         // respective networks.
-        assertOnNetwork(wifiAddressString, wifiNetwork);
-        assertOnNetwork(cellAddressString, cellNetwork);
+        final InetAddress wifiAddress = InetAddresses.parseNumericAddress(wifiAddressString);
+        final LinkProperties wifiLinkProperties = mCm.getLinkProperties(wifiNetwork);
+        // To make sure that the request went out on the right network, check that
+        // the IP address seen by the server is assigned to the expected network.
+        // We can only do this for IPv6 addresses, because in IPv4 we will likely
+        // have a private IPv4 address, and that won't match what the server sees.
+        if (wifiAddress instanceof Inet6Address) {
+            assertContains(wifiLinkProperties.getAddresses(), wifiAddress);
+        }
+
+        final LinkProperties cellLinkProperties = mCm.getLinkProperties(cellNetwork);
+        final InetAddress cellAddress = InetAddresses.parseNumericAddress(cellAddressString);
+        final List<InetAddress> cellNetworkAddresses = cellLinkProperties.getAddresses();
+        // In userdebug build, on cellular network, if the onNetwork check failed, we also try to
+        // re-verify it by obtaining the IP address through DNS query.
+        if (cellAddress instanceof Inet6Address) {
+            if (DeviceInfoUtils.isDebuggable() && !cellNetworkAddresses.contains(cellAddress)) {
+                final InetAddress ipv6AddressThroughDns = InetAddresses.parseNumericAddress(
+                        getDeviceIpv6AddressThroughDnsQuery(cellNetwork));
+                assertContains(cellNetworkAddresses, ipv6AddressThroughDns);
+            } else {
+                assertContains(cellNetworkAddresses, cellAddress);
+            }
+        }
 
         assertFalse("Unexpectedly equal: " + wifiNetwork, wifiNetwork.equals(cellNetwork));
     }
@@ -918,17 +992,6 @@
         }
     }
 
-    private void assertOnNetwork(String adressString, Network network) throws UnknownHostException {
-        InetAddress address = InetAddress.getByName(adressString);
-        LinkProperties linkProperties = mCm.getLinkProperties(network);
-        // To make sure that the request went out on the right network, check that
-        // the IP address seen by the server is assigned to the expected network.
-        // We can only do this for IPv6 addresses, because in IPv4 we will likely
-        // have a private IPv4 address, and that won't match what the server sees.
-        if (address instanceof Inet6Address) {
-            assertContains(linkProperties.getAddresses(), address);
-        }
-    }
 
     private static<T> void assertContains(Collection<T> collection, T element) {
         assertTrue(element + " not found in " + collection, collection.contains(element));
@@ -1552,7 +1615,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);
@@ -1565,7 +1628,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,
@@ -1712,7 +1775,8 @@
         }
     }
 
-    private InetAddress getAddrByName(final String hostname, final int family) throws Exception {
+    private static InetAddress getAddrByName(final String hostname, final int family)
+            throws Exception {
         final InetAddress[] allAddrs = InetAddress.getAllByName(hostname);
         for (InetAddress addr : allAddrs) {
             if (family == AF_INET && addr instanceof Inet4Address) return addr;
@@ -2365,7 +2429,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.
@@ -2876,6 +2940,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<>();
 
@@ -2934,12 +3007,7 @@
                 mCm.getActiveNetwork(), false /* accept */ , false /* always */));
     }
 
-    private void ensureCellIsValidatedBeforeMockingValidationUrls() {
-        // Verify that current supported network is validated so that the mock http server will not
-        // apply to unexpected networks. Also see aosp/2208680.
-        //
-        // This may also apply to wifi in principle, but in practice methods that mock validation
-        // URL all disconnect wifi forcefully anyway, so don't wait for wifi to validate.
+    private void ensureCellIsValidated() {
         if (mPackageManager.hasSystemFeature(FEATURE_TELEPHONY)) {
             new ConnectUtil(mContext).ensureCellularValidated();
         }
@@ -3022,9 +3090,13 @@
             networkCallbackRule.requestCell();
 
             final Network wifiNetwork = prepareUnvalidatedNetwork();
-            // Default network should not be wifi ,but checking that wifi is not the default doesn't
-            // guarantee that it won't become the default in the future.
-            assertNotEquals(wifiNetwork, mCm.getActiveNetwork());
+            // Default network should not be wifi ,but checking that Wi-Fi is not the default
+            // doesn't guarantee that it won't become the default in the future.
+            // On U 24Q2+ telephony may teardown (unregisterAfterReplacement) its network when Wi-Fi
+            // is toggled (as part of prepareUnvalidatedNetwork here). Give some time for Wi-Fi to
+            // not be default in case telephony is reconnecting.
+            assertEventuallyTrue("Wifi remained default despite being unvalidated",
+                    WIFI_CONNECT_TIMEOUT_MS, () -> !wifiNetwork.equals(mCm.getActiveNetwork()));
 
             final TestableNetworkCallback wifiCb = networkCallbackRule.registerNetworkCallback(
                     makeWifiNetworkRequest());
@@ -3061,6 +3133,7 @@
 
         try {
             final Network cellNetwork = networkCallbackRule.requestCell();
+            ensureCellIsValidated();
             final Network wifiNetwork = prepareValidatedNetwork();
 
             final TestableNetworkCallback defaultCb =
@@ -3156,7 +3229,12 @@
     }
 
     private Network prepareValidatedNetwork() throws Exception {
-        ensureCellIsValidatedBeforeMockingValidationUrls();
+        // Verify that current supported network is validated so that the mock http server will not
+        // apply to unexpected networks. Also see aosp/2208680.
+        //
+        // This may also apply to wifi in principle, but in practice methods that mock validation
+        // URL all disconnect wifi forcefully anyway, so don't wait for wifi to validate.
+        ensureCellIsValidated();
 
         prepareHttpServer();
         configTestServer(Status.NO_CONTENT, Status.NO_CONTENT);
@@ -3168,7 +3246,7 @@
     }
 
     private Network preparePartialConnectivity() throws Exception {
-        ensureCellIsValidatedBeforeMockingValidationUrls();
+        ensureCellIsValidated();
 
         prepareHttpServer();
         // Configure response code for partial connectivity
@@ -3183,7 +3261,7 @@
     }
 
     private Network prepareUnvalidatedNetwork() throws Exception {
-        ensureCellIsValidatedBeforeMockingValidationUrls();
+        ensureCellIsValidated();
 
         prepareHttpServer();
         // Configure response code for unvalidated network
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/DscpPolicyTest.kt b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
index 041e6cb..1de4cf9 100644
--- a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
+++ b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
@@ -71,7 +71,7 @@
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.RouterAdvertisementResponder
 import com.android.testutils.SC_V2
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnDscpPolicyStatusUpdated
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
@@ -135,7 +135,7 @@
     private lateinit var srcAddressV6: Inet6Address
     private lateinit var iface: TestNetworkInterface
     private lateinit var tunNetworkCallback: TestNetworkCallback
-    private lateinit var reader: TapPacketReader
+    private lateinit var reader: PollPacketReader
     private lateinit var arpResponder: ArpResponder
     private lateinit var raResponder: RouterAdvertisementResponder
 
@@ -169,7 +169,7 @@
         }
 
         handlerThread.start()
-        reader = TapPacketReader(
+        reader = PollPacketReader(
                 handlerThread.threadHandler,
                 iface.fileDescriptor.fileDescriptor,
                 MAX_PACKET_LENGTH)
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index 61ebd8f..5b2c9f7 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -72,11 +72,12 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.RouterAdvertisementResponder
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkCallback
 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
@@ -145,13 +146,15 @@
 
     private var tetheredInterfaceRequest: TetheredInterfaceRequest? = null
 
+    private var ethernetEnabled = true
+
     private class EthernetTestInterface(
         context: Context,
         private val handler: Handler,
         hasCarrier: Boolean
     ) {
         private val tapInterface: TestNetworkInterface
-        private val packetReader: TapPacketReader
+        private val packetReader: PollPacketReader
         private val raResponder: RouterAdvertisementResponder
         private val tnm: TestNetworkManager
         val name get() = tapInterface.interfaceName
@@ -169,7 +172,11 @@
                 tnm.createTapInterface(hasCarrier, false /* bringUp */)
             }
             val mtu = tapInterface.mtu
-            packetReader = TapPacketReader(handler, tapInterface.fileDescriptor.fileDescriptor, mtu)
+            packetReader = PollPacketReader(
+                    handler,
+                    tapInterface.fileDescriptor.fileDescriptor,
+                    mtu
+            )
             raResponder = RouterAdvertisementResponder(packetReader)
             val iidString = "fe80::${Integer.toHexString(Random().nextInt(65536))}"
             val linklocal = InetAddresses.parseNumericAddress(iidString) as Inet6Address
@@ -336,7 +343,7 @@
         }
     }
 
-    private fun isEthernetSupported() : Boolean {
+    private fun isEthernetSupported(): Boolean {
         return context.getSystemService(EthernetManager::class.java) != null
     }
 
@@ -424,7 +431,7 @@
 
         // when an interface comes up, we should always see a down cb before an up cb.
         ifaceListener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
-        if (hasCarrier) {
+        if (hasCarrier && ethernetEnabled) {
             ifaceListener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
         }
         return iface
@@ -510,6 +517,7 @@
     private fun setEthernetEnabled(enabled: Boolean) {
         runAsShell(NETWORK_SETTINGS) { em.setEthernetEnabled(enabled) }
 
+        ethernetEnabled = enabled
         val listener = EthernetStateListener()
         addEthernetStateListener(listener)
         listener.eventuallyExpect(if (enabled) ETHERNET_STATE_ENABLED else ETHERNET_STATE_DISABLED)
@@ -596,26 +604,6 @@
         }
     }
 
-    @Test
-    fun testCallbacks_withRunningInterface() {
-        assumeFalse(isAdbOverEthernet())
-        // Only run this test when no non-restricted / physical interfaces are present.
-        assumeNoInterfaceForTetheringAvailable()
-
-        val iface = createInterface()
-        val listener = EthernetStateListener()
-        addInterfaceStateListener(listener)
-        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
-
-        // Remove running interface. The interface stays running but is no longer tracked.
-        setEthernetEnabled(false)
-        listener.expectCallback(iface, STATE_ABSENT, ROLE_NONE)
-
-        setEthernetEnabled(true)
-        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
-        listener.assertNoCallback()
-    }
-
     private fun assumeNoInterfaceForTetheringAvailable() {
         // Interfaces that have configured NetworkCapabilities will never be used for tethering,
         // see aosp/2123900.
@@ -907,6 +895,30 @@
     }
 
     @Test
+    fun testEnableDisableInterface_disableEnableEthernet() {
+        val iface = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+
+        // When ethernet is disabled, interface should be down and enable/disableInterface()
+        // should not bring the interfaces up.
+        setEthernetEnabled(false)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectError()
+        disableInterface(iface).expectError()
+        listener.assertNoCallback()
+
+        // When ethernet is enabled, enable/disableInterface() should succeed.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+        disableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+    @Test
     fun testUpdateConfiguration_forBothIpConfigAndCapabilities() {
         val iface = createInterface()
         val cb = requestNetwork(ETH_REQUEST.copyWithEthernetSpecifier(iface.name))
@@ -1014,4 +1026,88 @@
         cb.eventuallyExpectCapabilities(TEST_CAPS)
         cb.eventuallyExpectLpForStaticConfig(STATIC_IP_CONFIGURATION.staticIpConfiguration)
     }
+
+    @Test
+    fun testAddInterface_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        // When ethernet is disabled, newly added interfaces should not be brought up.
+        setEthernetEnabled(false)
+        val iface = createInterface(/* hasCarrier */ true)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled after interface is added, it will be brought up.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+
+    @Test
+    fun testRemoveInterface_disableEnableEthernet() {
+        // Set up 2 interfaces for testing
+        val iface1 = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface1, STATE_LINK_UP, ROLE_CLIENT)
+        val iface2 = createInterface()
+        listener.eventuallyExpect(iface2, STATE_LINK_UP, ROLE_CLIENT)
+
+        // Removing interfaces when ethernet is enabled will first send link down, then
+        // STATE_ABSENT/ROLE_NONE.
+        removeInterface(iface1)
+        listener.expectCallback(iface1, STATE_LINK_DOWN, ROLE_CLIENT)
+        listener.expectCallback(iface1, STATE_ABSENT, ROLE_NONE)
+
+        // Removing interfaces after ethernet is disabled will first send link down when ethernet is
+        // disabled, then STATE_ABSENT/ROLE_NONE when interface is removed.
+        setEthernetEnabled(false)
+        listener.expectCallback(iface2, STATE_LINK_DOWN, ROLE_CLIENT)
+        removeInterface(iface2)
+        listener.expectCallback(iface2, STATE_ABSENT, ROLE_NONE)
+    }
+
+    @Test
+    fun testSetTetheringInterfaceMode_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        val iface = createInterface()
+        requestTetheredInterface().expectOnAvailable()
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_SERVER)
+
+        // (b/234743836): Currently the state of server mode interfaces always returns true due to
+        // that interface state for server mode interfaces is not tracked properly.
+        // So we do not get any state change when disabling ethernet.
+        setEthernetEnabled(false)
+        listener.assertNoCallback()
+
+        // When ethernet is disabled, change interface mode will not bring the interface up.
+        releaseTetheredInterface()
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled, interface will be brought up.
+        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/Ikev2VpnTest.java b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
index f6a025a..cb55bd5 100644
--- a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
+++ b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
@@ -184,6 +184,8 @@
 
     // Static state to reduce setup/teardown
     private static final Context sContext = InstrumentationRegistry.getContext();
+    private static boolean sIsWatch =
+                sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH);
     private static final ConnectivityManager sCM =
             (ConnectivityManager) sContext.getSystemService(Context.CONNECTIVITY_SERVICE);
     private static final VpnManager sVpnMgr =
@@ -205,12 +207,15 @@
 
     @Before
     public void setUp() {
-        assumeFalse("Skipping test because watches don't support VPN",
-            sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH));
+        assumeFalse("Skipping test because watches don't support VPN", sIsWatch);
     }
 
     @After
     public void tearDown() {
+        if (sIsWatch) {
+            return; // Tests are skipped for watches.
+        }
+
         for (TestableNetworkCallback callback : mCallbacksToUnregister) {
             sCM.unregisterNetworkCallback(callback);
         }
diff --git a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
index 890c071..f2c6d33 100644
--- a/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
+++ b/tests/cts/net/src/android/net/cts/IpSecManagerTunnelTest.java
@@ -1874,4 +1874,45 @@
                 },
                 false /* enableEncrypt */);
     }
+
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @Test
+    public void testMigrateWhenMultipleTunnelsExist() throws Exception {
+        assumeTrue(mCtsNetUtils.hasIpsecTunnelsFeature());
+        assumeTrue(mCtsNetUtils.hasIpsecTunnelMigrateFeature());
+
+        final int spi = getRandomSpi(LOCAL_OUTER_6, REMOTE_OUTER_6);
+
+        // Create tunnelIfaceFoo and tunnelIfaceBar. Verify tunnelIfaceBar migration will not throw
+        try (IpSecManager.IpSecTunnelInterface tunnelIfaceFoo =
+                mISM.createIpSecTunnelInterface(
+                        LOCAL_OUTER_4, REMOTE_OUTER_4, sTunWrapper.network)) {
+
+            buildTunnelNetworkAndRunTestsSimple(
+                    spi,
+                    (ipsecNetwork,
+                            tunnelIfaceBar,
+                            tunUtils,
+                            inTunnelTransform,
+                            outTunnelTransform,
+                            localOuter,
+                            remoteOuter,
+                            seqNum) -> {
+                        tunnelIfaceBar.setUnderlyingNetwork(sTunWrapperNew.network);
+
+                        mISM.startTunnelModeTransformMigration(
+                                inTunnelTransform, REMOTE_OUTER_6_NEW, LOCAL_OUTER_6_NEW);
+                        mISM.startTunnelModeTransformMigration(
+                                outTunnelTransform, LOCAL_OUTER_6_NEW, REMOTE_OUTER_6_NEW);
+
+                        mISM.applyTunnelModeTransform(
+                                tunnelIfaceBar, IpSecManager.DIRECTION_IN, inTunnelTransform);
+                        mISM.applyTunnelModeTransform(
+                                tunnelIfaceBar, IpSecManager.DIRECTION_OUT, outTunnelTransform);
+
+                        return 0 /* not used */;
+                    },
+                    true /* enableEncrypt */);
+        }
+    }
 }
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/MultinetworkApiTest.java b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
index 2c7d5c6..c67443e 100644
--- a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
+++ b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
@@ -39,10 +39,13 @@
 import android.system.ErrnoException;
 import android.system.OsConstants;
 import android.util.ArraySet;
+import android.util.Log;
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.testutils.AutoReleaseNetworkCallbackRule;
+import com.android.testutils.ConnectivityDiagnosticsCollector;
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.DeviceConfigRule;
 
@@ -51,6 +54,8 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Set;
 
 @DevSdkIgnoreRunner.RestoreDefaultNetwork
@@ -70,13 +75,34 @@
     private static final String TAG = "MultinetworkNativeApiTest";
     static final String GOOGLE_PRIVATE_DNS_SERVER = "dns.google";
 
+    public static class QueryTestResult {
+        public final int sourcePort;
+        public final int attempts;
+        public final int errNo;
+
+        public QueryTestResult(int sourcePort, int attempts, int errNo) {
+            this.sourcePort = sourcePort;
+            this.attempts = attempts;
+            this.errNo = errNo;
+        }
+
+        @Override
+        public String toString() {
+            return "QueryTestResult{"
+                    + "sourcePort=" + sourcePort
+                    + ", attempts=" + attempts
+                    + ", errNo=" + errNo
+                    + '}';
+        }
+    }
+
     /**
      * @return 0 on success
      */
     private static native int runGetaddrinfoCheck(long networkHandle);
     private static native int runSetprocnetwork(long networkHandle);
     private static native int runSetsocknetwork(long networkHandle);
-    private static native int runDatagramCheck(long networkHandle);
+    private static native QueryTestResult runDatagramCheck(long networkHandle, int sourcePort);
     private static native void runResNapiMalformedCheck(long networkHandle);
     private static native void runResNcancelCheck(long networkHandle);
     private static native void runResNqueryCheck(long networkHandle);
@@ -165,14 +191,69 @@
         }
     }
 
+    private void runNativeDatagramTransmissionDiagnostics(Network network,
+            QueryTestResult failedResult) {
+        final ConnectivityDiagnosticsCollector collector = ConnectivityDiagnosticsCollector
+                .getInstance();
+        if (collector == null) {
+            Log.e(TAG, "Missing ConnectivityDiagnosticsCollector, not adding diagnostics");
+            return;
+        }
+
+        final int numReruns = 10;
+        final ArrayList<QueryTestResult> reruns = new ArrayList<>(numReruns);
+        for (int i = 0; i < numReruns; i++) {
+            final QueryTestResult rerunResult =
+                    runDatagramCheck(network.getNetworkHandle(), 0 /* sourcePort */);
+            Log.d(TAG, "Rerun result " + i + ": " + rerunResult);
+            reruns.add(rerunResult);
+        }
+        // Rerun on the original port after trying the other ports, to check that the results are
+        // consistent, as opposed to the network recovering halfway through.
+        int originalPortFailedReruns = 0;
+        for (int i = 0; i < numReruns; i++) {
+            final QueryTestResult originalPortRerun = runDatagramCheck(network.getNetworkHandle(),
+                    failedResult.sourcePort);
+            Log.d(TAG, "Rerun result " + i + " with original port: " + originalPortRerun);
+            if (originalPortRerun.errNo != 0) {
+                originalPortFailedReruns++;
+            }
+        }
+
+        final int noRetrySuccessResults = reruns.stream()
+                .filter(result -> result.errNo == 0 && result.attempts == 1)
+                .mapToInt(result -> 1)
+                .sum();
+        final int failedResults = reruns.stream()
+                .filter(result -> result.errNo != 0)
+                .mapToInt(result -> 1)
+                .sum();
+        collector.addFailureAttribute("numReruns", numReruns);
+        collector.addFailureAttribute("noRetrySuccessReruns", noRetrySuccessResults);
+        collector.addFailureAttribute("failedReruns", failedResults);
+        collector.addFailureAttribute("originalPortFailedReruns", originalPortFailedReruns);
+    }
+
     @Test
     public void testNativeDatagramTransmission() throws Exception {
         for (Network network : getTestableNetworks()) {
-            int errno = runDatagramCheck(network.getNetworkHandle());
-            if (errno != 0) {
-                throw new ErrnoException(
-                        "DatagramCheck on " + mCM.getNetworkInfo(network), -errno);
+            final QueryTestResult result = runDatagramCheck(network.getNetworkHandle(),
+                    0 /* sourcePort */);
+            if (result.errNo == 0) {
+                continue;
             }
+            final NetworkCapabilities nc = mCM.getNetworkCapabilities(network);
+            final int[] transports = nc != null ? nc.getTransportTypes() : null;
+            if (CollectionUtils.contains(transports, TRANSPORT_WIFI)) {
+                runNativeDatagramTransmissionDiagnostics(network, result);
+            }
+
+            // Log the whole result (with source port and attempts) to logcat, but use only the
+            // errno and transport in the fail message so similar failures have consistent messages
+            final String error = "DatagramCheck on transport " + Arrays.toString(transports)
+                    + " failed: " + result.errNo;
+            Log.e(TAG, error + ", result: " + result);
+            fail(error);
         }
     }
 
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 60081d4..286e08c 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -83,13 +83,17 @@
 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
+import android.system.Os
+import android.system.OsConstants.AF_INET6
 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
@@ -105,10 +109,15 @@
 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
+import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV6_PROTOCOL_OFFSET
+import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
 import com.android.testutils.CompatUtil
 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
@@ -131,19 +140,24 @@
 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
 import java.io.Closeable
 import java.io.IOException
 import java.net.DatagramSocket
 import java.net.InetAddress
 import java.net.InetSocketAddress
 import java.net.Socket
+import java.nio.ByteBuffer
 import java.security.MessageDigest
 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.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
@@ -154,6 +168,7 @@
 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
@@ -165,10 +180,12 @@
 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
+
 // 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
@@ -188,6 +205,11 @@
     it.obj = obj
 }
 
+private val LINK_ADDRESS = LinkAddress("2001:db8::1/64")
+private val REMOTE_ADDRESS = InetAddresses.parseNumericAddress("2001:db8::123")
+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.
@@ -205,6 +227,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")
 
@@ -321,6 +346,15 @@
         if (transports.size > 0) removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
     }
 
+    private fun makeTestLinkProperties(ifName: String): LinkProperties {
+        return LinkProperties().apply {
+            interfaceName = ifName
+            addLinkAddress(LINK_ADDRESS)
+            addRoute(RouteInfo(PREFIX, null /* nextHop */, ifName))
+            addRoute(RouteInfo(IpPrefix("::/0"), NEXTHOP, ifName))
+        }
+    }
+
     private fun createNetworkAgent(
         context: Context = realContext,
         specifier: String? = null,
@@ -341,6 +375,7 @@
 
     private fun createConnectedNetworkAgent(
         context: Context = realContext,
+        lp: LinkProperties? = null,
         specifier: String? = UUID.randomUUID().toString(),
         initialConfig: NetworkAgentConfig? = null,
         expectedInitSignalStrengthThresholds: IntArray = intArrayOf(),
@@ -350,7 +385,12 @@
         // 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, initialNc = nc)
+        val agent = createNetworkAgent(
+            context,
+            initialConfig = initialConfig,
+            initialLp = lp,
+            initialNc = nc
+        )
         agent.setTeardownDelayMillis(0)
         // Connect the agent and verify initial status callbacks.
         agent.register()
@@ -361,8 +401,9 @@
         return agent to callback
     }
 
-    private fun connectNetwork(vararg transports: Int): Pair<TestableNetworkAgent, Network> {
-        val (agent, callback) = createConnectedNetworkAgent(transports = transports)
+    private fun connectNetwork(vararg transports: Int, lp: LinkProperties? = null):
+            Pair<TestableNetworkAgent, Network> {
+        val (agent, callback) = createConnectedNetworkAgent(transports = transports, lp = lp)
         val network = agent.network!!
         // createConnectedNetworkAgent internally files a request; release it so that the network
         // will be torn down if unneeded.
@@ -382,8 +423,10 @@
         assertNoCallback()
     }
 
-    private fun createTunInterface(): TestNetworkInterface = realContext.getSystemService(
-                TestNetworkManager::class.java)!!.createTunInterface(emptyList()).also {
+    private fun createTunInterface(addrs: Collection<LinkAddress> = emptyList()):
+            TestNetworkInterface = realContext.getSystemService(
+                TestNetworkManager::class.java
+            )!!.createTunInterface(addrs).also {
             ifacesToCleanUp.add(it)
     }
 
@@ -515,9 +558,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
 
@@ -622,8 +668,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(
@@ -658,15 +709,16 @@
 
     private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
         fun getCertHash(): String {
-            val pkgInfo = realContext.packageManager.getPackageInfo(realContext.opPackageName,
-                    PackageManager.GET_SIGNATURES)
+            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)
@@ -686,16 +738,13 @@
                 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)
+            if (hold) {
+                carrierConfigRule.addConfigOverrides(subId, PersistableBundle().also {
+                    it.putStringArray(CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
+                        arrayOf(getCertHash()))
+                })
+            } else {
+                carrierConfigRule.cleanUpNow()
             }
             assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't change carrier privilege")
         } cleanup {
@@ -768,14 +817,19 @@
         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.
@@ -879,8 +933,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()
@@ -889,10 +945,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
@@ -937,16 +995,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>())
@@ -968,9 +1030,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")
@@ -995,13 +1059,15 @@
         doReturn(mockCm).`when`(mockContext).getSystemService(Context.CONNECTIVITY_SERVICE)
         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
@@ -1048,8 +1114,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)
@@ -1124,7 +1192,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.
@@ -1134,11 +1203,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
@@ -1155,8 +1227,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!!)
     }
@@ -1174,20 +1248,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!!)
@@ -1265,8 +1343,10 @@
         }
 
         fun assertNoCallback() {
-            assertNull(history.poll(NO_CALLBACK_TIMEOUT),
-                    "Callback received")
+            assertNull(
+                history.poll(NO_CALLBACK_TIMEOUT),
+                "Callback received"
+            )
         }
     }
 
@@ -1281,7 +1361,8 @@
 
     private fun setupForQosDatagram() = setupForQosCallbackTest {
         agent: TestableNetworkAgent -> DatagramSocket(
-            InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
+            InetSocketAddress(InetAddress.getLoopbackAddress(), 0)
+        )
             .also { assertNotNull(agent.network?.bindSocket(it)) }
     }
 
@@ -1314,7 +1395,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 {
@@ -1407,8 +1489,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
                 }
@@ -1501,15 +1585,85 @@
 
     private fun createEpsAttributes(qci: Int = 1): EpsBearerQosSessionAttributes {
         val remoteAddresses = ArrayList<InetSocketAddress>()
-        remoteAddresses.add(InetSocketAddress("2001:db8::123", 80))
+        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
+    ) {
+        val s = Os.socket(AF_INET6, SOCK_DGRAM, 0)
+        net.bindSocket(s)
+        val content = ByteArray(16)
+        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
+            it.size == udpStart + content.size &&
+                    it[0].toInt() and 0xf0 == 0x60 &&
+                    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"
+        )
+    }
+
+    fun createInterfaceAndReader(): Triple<TestNetworkInterface, PollPacketReader, LinkProperties> {
+        val iface = createTunInterface(listOf(LINK_ADDRESS))
+        val handler = Handler(Looper.getMainLooper())
+        val reader = PollPacketReader(handler, iface.fileDescriptor.fileDescriptor, ETHER_MTU)
+        reader.startAsyncForTest()
+        handler.waitForIdle(DEFAULT_TIMEOUT_MS)
+        val ifName = iface.interfaceName
+        val lp = makeTestLinkProperties(ifName)
+        return Triple(iface, reader, lp)
+    }
+
+    @Test
+    fun testRegisterAfterUnregister() {
+        val (iface, reader, lp) = createInterfaceAndReader()
+
+        // File a request that matches and keeps up the best-scoring test network.
+        val testCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
+        requestNetwork(makeTestNetworkRequest(), testCallback)
+
+        // Register and unregister networkagents in a loop, checking that every time an agent
+        // connects, the native network is correctly configured and packets can be sent.
+        // Running 10 iterations takes about 1 second on x86 cuttlefish, and detects the race in
+        // b/286649301 most of the time.
+        for (i in 1..10) {
+            val agent1 = createNetworkAgent(realContext, initialLp = lp)
+            agent1.register()
+            agent1.unregister()
+
+            val agent2 = createNetworkAgent(realContext, initialLp = lp)
+            agent2.register()
+            agent2.markConnected()
+            val network2 = agent2.network!!
+
+            testCallback.expectAvailableThenValidatedCallbacks(network2)
+            sendAndExpectUdpPacket(network2, reader, iface)
+            agent2.unregister()
+            testCallback.expect<Lost>(network2)
+        }
+    }
+
     @Test
     fun testUnregisterAfterReplacement() {
+        val (iface, reader, lp) = createInterfaceAndReader()
+
         // Keeps an eye on all test networks.
         val matchAllCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
         registerNetworkCallback(makeTestNetworkRequest(), matchAllCallback)
@@ -1519,14 +1673,13 @@
         requestNetwork(makeTestNetworkRequest(), testCallback)
 
         // Connect the first network. This should satisfy the request.
-        val (agent1, network1) = connectNetwork()
+        val (agent1, network1) = connectNetwork(lp = lp)
         matchAllCallback.expectAvailableThenValidatedCallbacks(network1)
         testCallback.expectAvailableThenValidatedCallbacks(network1)
-        // Check that network1 exists by binding a socket to it and getting no exceptions.
-        network1.bindSocket(DatagramSocket())
+        sendAndExpectUdpPacket(network1, reader, iface)
 
         // Connect a second agent. network1 is preferred because it was already registered, so
-        // testCallback will not see any events. agent2 is be torn down because it has no requests.
+        // testCallback will not see any events. agent2 is torn down because it has no requests.
         val (agent2, network2) = connectNetwork()
         matchAllCallback.expectAvailableThenValidatedCallbacks(network2)
         matchAllCallback.expect<Lost>(network2)
@@ -1551,9 +1704,10 @@
         // 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
         // implicitly ensures that no callbacks are sent since network1 was lost.
-        val (agent3, network3) = connectNetwork()
+        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.
@@ -1573,6 +1727,7 @@
         matchAllCallback.expect<Losing>(network3)
         testCallback.expectAvailableCallbacks(network4, validated = true)
         mCM.unregisterNetworkCallback(agent4callback)
+        sendAndExpectUdpPacket(network3, reader, iface)
         agent3.unregisterAfterReplacement(5_000)
         agent3.expectCallback<OnNetworkUnwanted>()
         matchAllCallback.expect<Lost>(network3, 1000L)
@@ -1588,9 +1743,10 @@
 
         // If a network that is awaiting replacement is unregistered, it disconnects immediately,
         // before the replacement timeout fires.
-        val (agent5, network5) = connectNetwork()
+        val (agent5, network5) = connectNetwork(lp = lp)
         matchAllCallback.expectAvailableThenValidatedCallbacks(network5)
         testCallback.expectAvailableThenValidatedCallbacks(network5)
+        sendAndExpectUdpPacket(network5, reader, iface)
         agent5.unregisterAfterReplacement(5_000 /* timeoutMillis */)
         agent5.unregister()
         matchAllCallback.expect<Lost>(network5, 1000L /* timeoutMs */)
@@ -1637,7 +1793,7 @@
         matchAllCallback.assertNoCallback(200 /* timeoutMs */)
 
         // If wifi is replaced within the timeout, the device does not switch to cellular.
-        val (_, cellNetwork) = connectNetwork(TRANSPORT_CELLULAR)
+        val (cellAgent, cellNetwork) = connectNetwork(TRANSPORT_CELLULAR)
         testCallback.expectAvailableThenValidatedCallbacks(cellNetwork)
         matchAllCallback.expectAvailableThenValidatedCallbacks(cellNetwork)
 
@@ -1657,8 +1813,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"))
@@ -1674,6 +1832,34 @@
         matchAllCallback.expectAvailableThenValidatedCallbacks(newWifiNetwork)
         matchAllCallback.expect<Lost>(wifiNetwork)
         wifiAgent.expectCallback<OnNetworkUnwanted>()
+        testCallback.expect<CapabilitiesChanged>(newWifiNetwork)
+
+        cellAgent.unregister()
+        matchAllCallback.expect<Lost>(cellNetwork)
+        newWifiAgent.unregister()
+        matchAllCallback.expect<Lost>(newWifiNetwork)
+        testCallback.expect<Lost>(newWifiNetwork)
+
+        // Calling unregisterAfterReplacement several times in quick succession works.
+        // These networks are all kept up by testCallback.
+        val agent10 = createNetworkAgent(realContext, initialLp = lp)
+        agent10.register()
+        agent10.unregisterAfterReplacement(5_000)
+
+        val agent11 = createNetworkAgent(realContext, initialLp = lp)
+        agent11.register()
+        agent11.unregisterAfterReplacement(5_000)
+
+        val agent12 = createNetworkAgent(realContext, initialLp = lp)
+        agent12.register()
+        agent12.unregisterAfterReplacement(5_000)
+
+        val agent13 = createNetworkAgent(realContext, initialLp = lp)
+        agent13.register()
+        agent13.markConnected()
+        testCallback.expectAvailableThenValidatedCallbacks(agent13.network!!)
+        sendAndExpectUdpPacket(agent13.network!!, reader, iface)
+        agent13.unregister()
     }
 
     @Test
@@ -1701,19 +1887,16 @@
         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()
             }
         }
-        val lp = LinkProperties().apply {
-            interfaceName = ifName
-            addLinkAddress(LinkAddress("2001:db8::1/64"))
-            addRoute(RouteInfo(IpPrefix("2001:db8::/64"), null /* nextHop */, ifName))
-            addRoute(RouteInfo(IpPrefix("::/0"),
-                    InetAddresses.parseNumericAddress("fe80::abcd"),
-                    ifName))
-        }
+        val lp = makeTestLinkProperties(ifName)
 
         // File a request containing the agent's specifier to receive callbacks and to ensure that
         // the agent is not torn down due to being unneeded.
@@ -1754,9 +1937,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) }
@@ -1782,7 +1967,8 @@
     fun testNativeNetworkCreation_PhysicalNetwork() {
         doTestNativeNetworkCreation(
                 expectCreatedImmediately = SHOULD_CREATE_NETWORKS_IMMEDIATELY,
-                intArrayOf(TRANSPORT_CELLULAR))
+                intArrayOf(TRANSPORT_CELLULAR)
+        )
     }
 
     @Test
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..f05bf15
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
@@ -0,0 +1,140 @@
+/*
+ * 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.MANAGE_TEST_NETWORKS
+import android.Manifest.permission.NETWORK_SETTINGS
+import android.net.ConnectivityManager
+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.RES_ID_MATCH_ALL_RESERVATIONS
+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.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.runAsShell
+import kotlin.test.assertEquals
+import org.junit.After
+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)
+
+    @Before
+    fun setUp() {
+        runAsShell(NETWORK_SETTINGS) {
+            cm.registerNetworkProvider(provider)
+        }
+    }
+
+    @After
+    fun tearDown() {
+        runAsShell(NETWORK_SETTINGS) {
+            // unregisterNetworkProvider unregisters all associated NetworkOffers.
+            cm.unregisterNetworkProvider(provider)
+        }
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    fun NetworkCapabilities.copyWithReservationId(resId: Int) = NetworkCapabilities(this).also {
+        it.reservationId = resId
+    }
+
+    @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 = TestableNetworkCallback()
+        cm.reserveNetwork(ETHERNET_REQUEST, handler, 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 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>()
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
index 1a48983..65daf57 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
@@ -19,28 +19,28 @@
 import static android.os.Process.INVALID_UID;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.Context;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.net.INetworkStatsService;
 import android.net.TrafficStats;
+import android.net.connectivity.android.net.netstats.StatsResult;
 import android.os.Build;
 import android.os.IBinder;
 import android.os.Process;
 import android.os.RemoteException;
-import android.test.AndroidTestCase;
-import android.util.SparseArray;
 
 import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.util.CollectionUtils;
+import com.android.testutils.ConnectivityModuleTest;
 import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRunner;
 
-import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -48,37 +48,20 @@
 import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.function.Function;
 import java.util.function.Predicate;
 
-@RunWith(AndroidJUnit4.class)
+@ConnectivityModuleTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2) // Mainline NetworkStats starts from T.
+@RunWith(DevSdkIgnoreRunner.class)
 public class NetworkStatsBinderTest {
-    // NOTE: These are shamelessly copied from TrafficStats.
-    private static final int TYPE_RX_BYTES = 0;
-    private static final int TYPE_RX_PACKETS = 1;
-    private static final int TYPE_TX_BYTES = 2;
-    private static final int TYPE_TX_PACKETS = 3;
-
-    @Rule
-    public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule(
-            Build.VERSION_CODES.Q /* ignoreClassUpTo */);
-
-    private final SparseArray<Function<Integer, Long>> mUidStatsQueryOpArray = new SparseArray<>();
-
-    @Before
-    public void setUp() throws Exception {
-        mUidStatsQueryOpArray.put(TYPE_RX_BYTES, uid -> TrafficStats.getUidRxBytes(uid));
-        mUidStatsQueryOpArray.put(TYPE_RX_PACKETS, uid -> TrafficStats.getUidRxPackets(uid));
-        mUidStatsQueryOpArray.put(TYPE_TX_BYTES, uid -> TrafficStats.getUidTxBytes(uid));
-        mUidStatsQueryOpArray.put(TYPE_TX_PACKETS, uid -> TrafficStats.getUidTxPackets(uid));
-    }
-
-    private long getUidStatsFromBinder(int uid, int type) throws Exception {
-        Method getServiceMethod = Class.forName("android.os.ServiceManager")
+    @Nullable
+    private StatsResult getUidStatsFromBinder(int uid) throws Exception {
+        final Method getServiceMethod = Class.forName("android.os.ServiceManager")
                 .getDeclaredMethod("getService", new Class[]{String.class});
-        IBinder binder = (IBinder) getServiceMethod.invoke(null, Context.NETWORK_STATS_SERVICE);
-        INetworkStatsService nss = INetworkStatsService.Stub.asInterface(binder);
-        return nss.getUidStats(uid, type);
+        final IBinder binder = (IBinder) getServiceMethod.invoke(
+                null, Context.NETWORK_STATS_SERVICE);
+        final INetworkStatsService nss = INetworkStatsService.Stub.asInterface(binder);
+        return nss.getUidStats(uid);
     }
 
     private int getFirstAppUidThat(@NonNull Predicate<Integer> predicate) {
@@ -108,38 +91,34 @@
         if (notMyUid != INVALID_UID) testUidList.add(notMyUid);
 
         for (final int uid : testUidList) {
-            for (int i = 0; i < mUidStatsQueryOpArray.size(); i++) {
-                final int type = mUidStatsQueryOpArray.keyAt(i);
-                try {
-                    final long uidStatsFromBinder = getUidStatsFromBinder(uid, type);
-                    final long uidTrafficStats = mUidStatsQueryOpArray.get(type).apply(uid);
+            try {
+                final StatsResult uidStatsFromBinder = getUidStatsFromBinder(uid);
 
-                    // Verify that UNSUPPORTED is returned if the uid is not current app uid.
-                    if (uid != myUid) {
-                        assertEquals(uidStatsFromBinder, TrafficStats.UNSUPPORTED);
-                    }
+                if (uid != myUid) {
+                    // Verify that null is returned if the uid is not current app uid.
+                    assertNull(uidStatsFromBinder);
+                } else {
                     // Verify that returned result is the same with the result get from
                     // TrafficStats.
-                    // TODO: If the test is flaky then it should instead assert that the values
-                    //  are approximately similar.
-                    assertEquals("uidStats is not matched for query type " + type
-                                    + ", uid=" + uid + ", myUid=" + myUid, uidTrafficStats,
-                            uidStatsFromBinder);
-                } catch (IllegalAccessException e) {
-                    /* Java language access prevents exploitation. */
-                    return;
-                } catch (InvocationTargetException e) {
-                    /* Underlying method has been changed. */
-                    return;
-                } catch (ClassNotFoundException e) {
-                    /* not vulnerable if hidden API no longer available */
-                    return;
-                } catch (NoSuchMethodException e) {
-                    /* not vulnerable if hidden API no longer available */
-                    return;
-                } catch (RemoteException e) {
-                    return;
+                    assertEquals(uidStatsFromBinder.rxBytes, TrafficStats.getUidRxBytes(uid));
+                    assertEquals(uidStatsFromBinder.rxPackets, TrafficStats.getUidRxPackets(uid));
+                    assertEquals(uidStatsFromBinder.txBytes, TrafficStats.getUidTxBytes(uid));
+                    assertEquals(uidStatsFromBinder.txPackets, TrafficStats.getUidTxPackets(uid));
                 }
+            } catch (IllegalAccessException e) {
+                /* Java language access prevents exploitation. */
+                return;
+            } catch (InvocationTargetException e) {
+                /* Underlying method has been changed. */
+                return;
+            } catch (ClassNotFoundException e) {
+                /* not vulnerable if hidden API no longer available */
+                return;
+            } catch (NoSuchMethodException | NoSuchMethodError e) {
+                /* not vulnerable if hidden API no longer available */
+                return;
+            } catch (RemoteException e) {
+                return;
             }
         }
     }
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 2315940..eb2dbf7 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -41,6 +41,7 @@
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 
+import android.annotation.NonNull;
 import android.app.AppOpsManager;
 import android.app.Instrumentation;
 import android.app.usage.NetworkStats;
@@ -62,19 +63,24 @@
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.SystemClock;
+import android.os.UserHandle;
 import android.platform.test.annotations.AppModeFull;
 import android.telephony.TelephonyManager;
 import android.text.TextUtils;
 import android.util.Log;
 
 import androidx.test.InstrumentationRegistry;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 
 import com.android.compatibility.common.util.ShellIdentityUtils;
 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;
+import com.android.testutils.RecorderCallback.CallbackEntry;
+import com.android.testutils.TestableNetworkCallback;
 
 import org.junit.After;
 import org.junit.Before;
@@ -90,21 +96,29 @@
 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;
 
-@ConnectivityModuleTest
+// TODO: Fix thread leaks in testCallback and annotating with @MonitorThreadLeak.
 @AppModeFull(reason = "instant apps cannot be granted USAGE_STATS")
-@RunWith(AndroidJUnit4.class)
+@ConnectivityModuleTest
+@DevSdkIgnoreRunner.RestoreDefaultNetwork
+@RunWith(DevSdkIgnoreRunner.class)
 public class NetworkStatsManagerTest {
-    @Rule
+    @Rule(order = 1)
     public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule(Build.VERSION_CODES.Q);
+    @Rule(order = 2)
+    public final AutoReleaseNetworkCallbackRule
+            networkCallbackRule = new AutoReleaseNetworkCallbackRule();
+
 
     private static final String LOG_TAG = "NetworkStatsManagerTest";
-    private static final String APPOPS_SET_SHELL_COMMAND = "appops set {0} {1} {2}";
-    private static final String APPOPS_GET_SHELL_COMMAND = "appops get {0} {1}";
+    private static final String APPOPS_SET_SHELL_COMMAND = "appops set --user {0} {1} {2} {3}";
+    private static final String APPOPS_GET_SHELL_COMMAND = "appops get --user {0} {1} {2}";
 
     private static final long MINUTE = 1000 * 60;
     private static final int TIMEOUT_MILLIS = 15000;
@@ -115,14 +129,23 @@
 
     private static final int NETWORK_TAG = 0xf00d;
     private static final long THRESHOLD_BYTES = 2 * 1024 * 1024;  // 2 MB
+    private static final long SHORT_TOLERANCE = MINUTE / 2;
+    private static final long LONG_TOLERANCE = MINUTE * 120;
 
     private abstract class NetworkInterfaceToTest {
+
+        final TestableNetworkCallback mRequestNetworkCb = new TestableNetworkCallback();
         private boolean mMetered;
         private boolean mRoaming;
         private boolean mIsDefault;
 
         abstract int getNetworkType();
-        abstract int getTransportType();
+
+        abstract Network requestNetwork();
+
+        void unrequestNetwork() {
+            networkCallbackRule.unregisterNetworkCallback(mRequestNetworkCb);
+        }
 
         public boolean getMetered() {
             return mMetered;
@@ -149,7 +172,13 @@
         }
 
         abstract String getSystemFeature();
-        abstract String getErrorMessage();
+
+        @NonNull NetworkRequest buildRequestForTransport(int transport) {
+            return new NetworkRequest.Builder()
+                    .addTransportType(transport)
+                    .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+                    .build();
+        }
     }
 
     private final NetworkInterfaceToTest[] mNetworkInterfacesToTest =
@@ -161,19 +190,20 @@
                         }
 
                         @Override
-                        public int getTransportType() {
-                            return NetworkCapabilities.TRANSPORT_WIFI;
+                        public Network requestNetwork() {
+                            networkCallbackRule.requestNetwork(buildRequestForTransport(
+                                    NetworkCapabilities.TRANSPORT_WIFI),
+                                    mRequestNetworkCb, TIMEOUT_MILLIS);
+                            return mRequestNetworkCb.expect(CallbackEntry.AVAILABLE,
+                                    "Wifi network not available. "
+                                            + "Please ensure the device has working wifi."
+                            ).getNetwork();
                         }
 
                         @Override
                         public String getSystemFeature() {
                             return PackageManager.FEATURE_WIFI;
                         }
-
-                        @Override
-                        public String getErrorMessage() {
-                            return " Please make sure you are connected to a WiFi access point.";
-                        }
                     },
                     new NetworkInterfaceToTest() {
                         @Override
@@ -182,22 +212,20 @@
                         }
 
                         @Override
-                        public int getTransportType() {
-                            return NetworkCapabilities.TRANSPORT_CELLULAR;
+                        public Network requestNetwork() {
+                            networkCallbackRule.requestNetwork(buildRequestForTransport(
+                                            NetworkCapabilities.TRANSPORT_CELLULAR),
+                                    mRequestNetworkCb, TIMEOUT_MILLIS);
+                            return mRequestNetworkCb.expect(CallbackEntry.AVAILABLE,
+                                    "Cell network not available. "
+                                            + "Please ensure the device has working mobile data."
+                            ).getNetwork();
                         }
 
                         @Override
                         public String getSystemFeature() {
                             return PackageManager.FEATURE_TELEPHONY;
                         }
-
-                        @Override
-                        public String getErrorMessage() {
-                            return " Please make sure you have added a SIM card with data plan to"
-                                    + " your phone, have enabled data over cellular and in case of"
-                                    + " dual SIM devices, have selected the right SIM "
-                                    + "for data connection.";
-                        }
                     }
             };
 
@@ -213,7 +241,22 @@
     private String mWriteSettingsMode;
     private String mUsageStatsMode;
 
-    private void exerciseRemoteHost(Network network, URL url) throws Exception {
+    // The test host only has IPv4. So on a dual-stack network where IPv6 connects before IPv4,
+    // we need to wait until IPv4 is available or the test will spuriously fail.
+    private static void waitForHostResolution(@NonNull Network network, @NonNull URL url) {
+        for (int i = 0; i < HOST_RESOLUTION_RETRIES; i++) {
+            try {
+                network.getAllByName(url.getHost());
+                return;
+            } catch (UnknownHostException e) {
+                SystemClock.sleep(HOST_RESOLUTION_INTERVAL_MS);
+            }
+        }
+        fail(String.format("%s could not be resolved on network %s (%d attempts %dms apart)",
+                url.getHost(), network, HOST_RESOLUTION_RETRIES, HOST_RESOLUTION_INTERVAL_MS));
+    }
+
+    private void exerciseRemoteHost(@NonNull Network network, @NonNull URL url) throws Exception {
         NetworkInfo networkInfo = mCm.getNetworkInfo(network);
         if (networkInfo == null) {
             Log.w(LOG_TAG, "Network info is null");
@@ -290,12 +333,14 @@
     }
 
     private void setAppOpsMode(String appop, String mode) throws Exception {
-        final String command = MessageFormat.format(APPOPS_SET_SHELL_COMMAND, mPkg, appop, mode);
+        final String command = MessageFormat.format(APPOPS_SET_SHELL_COMMAND,
+                UserHandle.myUserId(), mPkg, appop, mode);
         SystemUtil.runShellCommand(mInstrumentation, command);
     }
 
     private String getAppOpsMode(String appop) throws Exception {
-        final String command = MessageFormat.format(APPOPS_GET_SHELL_COMMAND, mPkg, appop);
+        final String command = MessageFormat.format(APPOPS_GET_SHELL_COMMAND,
+                UserHandle.myUserId(), mPkg, appop);
         String result = SystemUtil.runShellCommand(mInstrumentation, command);
         if (result == null) {
             Log.w(LOG_TAG, "App op " + appop + " could not be read.");
@@ -309,99 +354,44 @@
         return result.contains("FOREGROUND");
     }
 
-    private class NetworkCallback extends ConnectivityManager.NetworkCallback {
-        private long mTolerance;
-        private URL mUrl;
-        public boolean success;
-        public boolean metered;
-        public boolean roaming;
-        public boolean isDefault;
-
-        NetworkCallback(long tolerance, URL url) {
-            mTolerance = tolerance;
-            mUrl = url;
-            success = false;
-            metered = false;
-            roaming = false;
-            isDefault = false;
-        }
-
-        // The test host only has IPv4. So on a dual-stack network where IPv6 connects before IPv4,
-        // we need to wait until IPv4 is available or the test will spuriously fail.
-        private void waitForHostResolution(Network network) {
-            for (int i = 0; i < HOST_RESOLUTION_RETRIES; i++) {
-                try {
-                    network.getAllByName(mUrl.getHost());
-                    return;
-                } catch (UnknownHostException e) {
-                    SystemClock.sleep(HOST_RESOLUTION_INTERVAL_MS);
-                }
-            }
-            fail(String.format("%s could not be resolved on network %s (%d attempts %dms apart)",
-                    mUrl.getHost(), network, HOST_RESOLUTION_RETRIES, HOST_RESOLUTION_INTERVAL_MS));
-        }
-
-        @Override
-        public void onAvailable(Network network) {
-            try {
-                mStartTime = System.currentTimeMillis() - mTolerance;
-                isDefault = network.equals(mCm.getActiveNetwork());
-                waitForHostResolution(network);
-                exerciseRemoteHost(network, mUrl);
-                mEndTime = System.currentTimeMillis() + mTolerance;
-                success = true;
-                metered = !mCm.getNetworkCapabilities(network)
-                        .hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED);
-                roaming = !mCm.getNetworkCapabilities(network)
-                        .hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING);
-                synchronized (NetworkStatsManagerTest.this) {
-                    NetworkStatsManagerTest.this.notify();
-                }
-            } catch (Exception e) {
-                Log.w(LOG_TAG, "exercising remote host failed.", e);
-                success = false;
-            }
-        }
+    private boolean shouldTestThisNetworkType(int networkTypeIndex) {
+        return mPm.hasSystemFeature(mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature());
     }
 
-    private boolean shouldTestThisNetworkType(int networkTypeIndex, final long tolerance)
-            throws Exception {
-        boolean hasFeature = mPm.hasSystemFeature(
-                mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature());
-        if (!hasFeature) {
-            return false;
-        }
-        NetworkCallback callback = new NetworkCallback(tolerance, new URL(CHECK_CONNECTIVITY_URL));
-        mCm.requestNetwork(new NetworkRequest.Builder()
-                .addTransportType(mNetworkInterfacesToTest[networkTypeIndex].getTransportType())
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-                .build(), callback);
-        synchronized (this) {
-            long now = System.currentTimeMillis();
-            final long deadline = (long) (now + TIMEOUT_MILLIS * 2.4);
-            while (!callback.success && now < deadline) {
-                try {
-                    wait(deadline - now);
-                } catch (InterruptedException e) {
-                }
-                now = System.currentTimeMillis();
-            }
-        }
-        mCm.unregisterNetworkCallback(callback);
-        if (callback.success) {
-            mNetworkInterfacesToTest[networkTypeIndex].setMetered(callback.metered);
-            mNetworkInterfacesToTest[networkTypeIndex].setRoaming(callback.roaming);
-            mNetworkInterfacesToTest[networkTypeIndex].setIsDefault(callback.isDefault);
-            return true;
-        }
+    @NonNull
+    private Network requestNetworkAndSetAttributes(
+            @NonNull NetworkInterfaceToTest networkInterface) {
+        final Network network = networkInterface.requestNetwork();
 
-        // This will always fail at this point as we know 'hasFeature' is true.
-        assertFalse(mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature()
-                + " is a reported system feature, "
-                + "however no corresponding connected network interface was found or the attempt "
-                + "to connect and read has timed out (timeout = " + (TIMEOUT_MILLIS * 2) + "ms)."
-                + mNetworkInterfacesToTest[networkTypeIndex].getErrorMessage(), hasFeature);
-        return false;
+        // These attributes are needed when performing NetworkStats queries.
+        // Fetch caps from the first capabilities changed event since the
+        // interested attributes are not mutable, and not expected to be
+        // changed during the test.
+        final NetworkCapabilities caps = networkInterface.mRequestNetworkCb.expect(
+                CallbackEntry.NETWORK_CAPS_UPDATED, network).getCaps();
+        networkInterface.setMetered(!caps.hasCapability(
+                NetworkCapabilities.NET_CAPABILITY_NOT_METERED));
+        networkInterface.setRoaming(!caps.hasCapability(
+                NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING));
+        networkInterface.setIsDefault(network.equals(mCm.getActiveNetwork()));
+
+        return network;
+    }
+
+    private void requestNetworkAndGenerateTraffic(int networkTypeIndex, final long tolerance)
+            throws Exception {
+        final NetworkInterfaceToTest networkInterface = mNetworkInterfacesToTest[networkTypeIndex];
+        final Network network = requestNetworkAndSetAttributes(networkInterface);
+
+        mStartTime = System.currentTimeMillis() - tolerance;
+        waitForHostResolution(network, new URL(CHECK_CONNECTIVITY_URL));
+        exerciseRemoteHost(network, new URL(CHECK_CONNECTIVITY_URL));
+        mEndTime = System.currentTimeMillis() + tolerance;
+
+        // It is fine if the test fails and this line is not reached.
+        // The AutoReleaseNetworkCallbackRule will eventually release
+        // all unwanted callbacks.
+        networkInterface.unrequestNetwork();
     }
 
     private String getSubscriberId(int networkIndex) {
@@ -417,9 +407,10 @@
     @Test
     public void testDeviceSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats.Bucket bucket = null;
             try {
@@ -453,9 +444,10 @@
     @Test
     public void testUserSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats.Bucket bucket = null;
             try {
@@ -489,14 +481,15 @@
     @Test
     public void testAppSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
+            if (!shouldTestThisNetworkType(i)) {
+                continue;
+            }
             // Use tolerance value that large enough to make sure stats of at
             // least one bucket is included. However, this is possible that
             // the test will see data of different app but with the same UID
             // that created before testing.
             // TODO: Consider query stats before testing and use the difference to verify.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
-                continue;
-            }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -565,10 +558,11 @@
     @Test
     public void testAppDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            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 {
@@ -609,9 +603,10 @@
     public void testUidDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -663,9 +658,10 @@
     public void testTagDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -718,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) {
@@ -749,91 +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) {
-            // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
             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.");
@@ -847,9 +885,10 @@
     public void testCallback() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
 
             TestUsageCallback usageCallback = new TestUsageCallback();
@@ -905,7 +944,7 @@
         }
     }
 
-    private String tagToString(Integer tag) {
+    private static String tagToString(Integer tag) {
         if (tag == null) return "null";
         switch (tag) {
             case TAG_NONE:
@@ -915,7 +954,7 @@
         }
     }
 
-    private String stateToString(Integer state) {
+    private static String stateToString(Integer state) {
         if (state == null) return "null";
         switch (state) {
             case STATE_ALL:
@@ -928,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;
@@ -938,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);
@@ -954,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/NetworkValidationTest.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
index f9acb66..aad072c 100644
--- a/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
@@ -46,7 +46,7 @@
 import com.android.testutils.DhcpClientPacketFilter
 import com.android.testutils.DhcpOptionFilter
 import com.android.testutils.RecorderCallback.CallbackEntry
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestHttpServer
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.runAsShell
@@ -93,7 +93,7 @@
     private val ethRequestCb = TestableNetworkCallback()
 
     private lateinit var iface: TestNetworkInterface
-    private lateinit var reader: TapPacketReader
+    private lateinit var reader: PollPacketReader
     private lateinit var capportUrl: Uri
 
     private var testSkipped = false
@@ -118,7 +118,7 @@
         iface = testInterfaceRule.createTapInterface()
 
         handlerThread.start()
-        reader = TapPacketReader(
+        reader = PollPacketReader(
                 handlerThread.threadHandler,
                 iface.fileDescriptor.fileDescriptor,
                 MAX_PACKET_LENGTH)
@@ -218,7 +218,7 @@
                     TEST_MTU, false /* rapidCommit */, capportUrl.toString())
 }
 
-private fun <T : DhcpPacket> TapPacketReader.assertDhcpPacketReceived(
+private fun <T : DhcpPacket> PollPacketReader.assertDhcpPacketReceived(
     packetType: Class<T>,
     timeoutMs: Long,
     type: Byte
diff --git a/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
index a0b40aa..d3d4f4d 100644
--- a/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
@@ -17,6 +17,7 @@
 package android.net.cts
 
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.provider.DeviceConfig
 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
 import com.android.net.module.util.NetworkStackConstants
@@ -33,7 +34,7 @@
      * Clear the test network validation URLs.
      */
     @JvmStatic fun clearValidationTestUrlsDeviceConfig() {
-        runAsShell(WRITE_DEVICE_CONFIG) {
+        runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
             DeviceConfig.setProperty(NAMESPACE_CONNECTIVITY,
                     NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL, null, false)
             DeviceConfig.setProperty(NAMESPACE_CONNECTIVITY,
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
index 24af42b..1973899 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
@@ -92,7 +92,7 @@
             assertEquals(downstreamIface.name, iface)
             val request = TetheringRequest.Builder(TETHERING_ETHERNET)
                 .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL).build()
-            tetheringEventCallback = enableEthernetTethering(
+            tetheringEventCallback = enableTethering(
                 iface, request,
                 null /* any upstream */
             ).apply {
@@ -125,7 +125,7 @@
             val request = TetheringRequest.Builder(TETHERING_ETHERNET)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setShouldShowEntitlementUi(false).build()
-            tetheringEventCallback = enableEthernetTethering(
+            tetheringEventCallback = enableTethering(
                 iface, request,
                 null /* any upstream */
             ).apply {
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index c71d925..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.TapPacketReader
 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
@@ -326,6 +263,15 @@
         it.port = TEST_PORT
     }
 
+    private fun makePacketReader(network: TestTapNetwork = testNetwork1) = PollPacketReader(
+            Handler(handlerThread.looper),
+            network.iface.fileDescriptor.fileDescriptor,
+            1500 /* maxPacketSize */
+    ).also {
+        it.startAsyncForTest()
+        handlerThread.waitForIdle(TIMEOUT_MS)
+    }
+
     @After
     fun tearDown() {
         runAsShell(MANAGE_TEST_NETWORKS) {
@@ -564,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>()
@@ -1298,14 +1246,7 @@
         assumeTrue(TestUtils.shouldTestTApis())
 
         val si = makeTestServiceInfo(testNetwork1.network)
-
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
@@ -1345,13 +1286,7 @@
                     parseNumericAddress("2001:db8::3"))
         }
 
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
@@ -1391,13 +1326,7 @@
             hostname = customHostname
         }
 
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
@@ -1438,13 +1367,7 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         tryTest {
             assertNotNull(packetReader.pollForAdvertisement(serviceName, serviceType),
@@ -1518,13 +1441,7 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
-                Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         tryTest {
             assertNotNull(packetReader.pollForAdvertisement(serviceName, serviceType),
@@ -1587,13 +1504,7 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         tryTest {
             assertNotNull(packetReader.pollForAdvertisement(serviceName, serviceType),
@@ -1630,13 +1541,7 @@
     fun testDiscoveryWithPtrOnlyResponse_ServiceIsFound() {
         // Register service on testNetwork1
         val discoveryRecord = NsdDiscoveryRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         nsdManager.discoverServices(
             serviceType,
@@ -1675,9 +1580,12 @@
                 assertEmpty(it.hostAddresses)
                 assertEquals(0, it.attributes.size)
             }
-        } cleanup {
+        } cleanupStep {
             nsdManager.stopServiceDiscovery(discoveryRecord)
             discoveryRecord.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -1688,79 +1596,77 @@
     fun testResolveWhenServerSendsNoAdditionalRecord() {
         // Resolve service on testNetwork1
         val resolveRecord = NsdResolveRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
-
+        val packetReader = makePacketReader()
         val si = makeTestServiceInfo(testNetwork1.network)
         nsdManager.resolveService(si, { it.run() }, resolveRecord)
 
-        val serviceFullName = "$serviceName.$serviceType.local"
-        // The query should ask for ANY, since both SRV and TXT are requested. Note legacy
-        // mdnsresponder will ask for SRV and TXT separately, and will not proceed to asking for
-        // address records without an answer for both.
-        val srvTxtQuery = packetReader.pollForQuery(serviceFullName, DnsResolver.TYPE_ANY)
-        assertNotNull(srvTxtQuery)
+        tryTest {
+            val serviceFullName = "$serviceName.$serviceType.local"
+            // The query should ask for ANY, since both SRV and TXT are requested. Note legacy
+            // mdnsresponder will ask for SRV and TXT separately, and will not proceed to asking for
+            // address records without an answer for both.
+            val srvTxtQuery = packetReader.pollForQuery(serviceFullName, DnsResolver.TYPE_ANY)
+            assertNotNull(srvTxtQuery)
 
-        /*
-        Generated with:
-        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
-            scapy.DNSRRSRV(rrname='NsdTest123456789._nmt123456789._tcp.local',
-                rclass=0x8001, port=31234, target='testhost.local', ttl=120) /
-            scapy.DNSRR(rrname='NsdTest123456789._nmt123456789._tcp.local', type='TXT', ttl=120,
-                rdata='testkey=testvalue')
-        ))).hex()
-         */
-        val srvTxtResponsePayload = HexDump.hexStringToByteArray(
-            "000084000000000200000000104" +
-                "e7364546573743132333435363738390d5f6e6d74313233343536373839045f746370056c6f6" +
-                "3616c0000218001000000780011000000007a020874657374686f7374c030c00c00100001000" +
-                "00078001211746573746b65793d7465737476616c7565"
-        )
-        replaceServiceNameAndTypeWithTestSuffix(srvTxtResponsePayload)
-        packetReader.sendResponse(buildMdnsPacket(srvTxtResponsePayload))
+            /*
+            Generated with:
+            scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+                scapy.DNSRRSRV(rrname='NsdTest123456789._nmt123456789._tcp.local',
+                    rclass=0x8001, port=31234, target='testhost.local', ttl=120) /
+                scapy.DNSRR(rrname='NsdTest123456789._nmt123456789._tcp.local', type='TXT', ttl=120,
+                    rdata='testkey=testvalue')
+            ))).hex()
+             */
+            val srvTxtResponsePayload = HexDump.hexStringToByteArray(
+                    "000084000000000200000000104" +
+                            "e7364546573743132333435363738390d5f6e6d74313233343536373839045f7463" +
+                            "70056c6f63616c0000218001000000780011000000007a020874657374686f7374c" +
+                            "030c00c0010000100000078001211746573746b65793d7465737476616c7565"
+            )
+            replaceServiceNameAndTypeWithTestSuffix(srvTxtResponsePayload)
+            packetReader.sendResponse(buildMdnsPacket(srvTxtResponsePayload))
 
-        val testHostname = "testhost.local"
-        val addressQuery = packetReader.pollForQuery(
-            testHostname,
-            DnsResolver.TYPE_A,
-            DnsResolver.TYPE_AAAA
-        )
-        assertNotNull(addressQuery)
+            val testHostname = "testhost.local"
+            val addressQuery = packetReader.pollForQuery(
+                    testHostname,
+                    DnsResolver.TYPE_A,
+                    DnsResolver.TYPE_AAAA
+            )
+            assertNotNull(addressQuery)
 
-        /*
-        Generated with:
-        scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
-            scapy.DNSRR(rrname='testhost.local', type='A', ttl=120,
-                rdata='192.0.2.123') /
-            scapy.DNSRR(rrname='testhost.local', type='AAAA', ttl=120,
-                rdata='2001:db8::123')
-        ))).hex()
-         */
-        val addressPayload = HexDump.hexStringToByteArray(
-            "0000840000000002000000000874657374" +
-                "686f7374056c6f63616c0000010001000000780004c000027bc00c001c000100000078001020" +
-                "010db8000000000000000000000123"
-        )
-        packetReader.sendResponse(buildMdnsPacket(addressPayload))
+            /*
+            Generated with:
+            scapy.raw(scapy.dns_compress(scapy.DNS(rd=0, qr=1, aa=1, qd = None, an =
+                scapy.DNSRR(rrname='testhost.local', type='A', ttl=120,
+                    rdata='192.0.2.123') /
+                scapy.DNSRR(rrname='testhost.local', type='AAAA', ttl=120,
+                    rdata='2001:db8::123')
+            ))).hex()
+             */
+            val addressPayload = HexDump.hexStringToByteArray(
+                    "0000840000000002000000000874657374" +
+                            "686f7374056c6f63616c0000010001000000780004c000027bc00c001c000100000" +
+                            "078001020010db8000000000000000000000123"
+            )
+            packetReader.sendResponse(buildMdnsPacket(addressPayload))
 
-        val serviceResolved = resolveRecord.expectCallback<ServiceResolved>()
-        serviceResolved.serviceInfo.let {
-            assertEquals(serviceName, it.serviceName)
-            assertEquals(".$serviceType", it.serviceType)
-            assertEquals(testNetwork1.network, it.network)
-            assertEquals(31234, it.port)
-            assertEquals(1, it.attributes.size)
-            assertArrayEquals("testvalue".encodeToByteArray(), it.attributes["testkey"])
+            val serviceResolved = resolveRecord.expectCallback<ServiceResolved>()
+            serviceResolved.serviceInfo.let {
+                assertEquals(serviceName, it.serviceName)
+                assertEquals(".$serviceType", it.serviceType)
+                assertEquals(testNetwork1.network, it.network)
+                assertEquals(31234, it.port)
+                assertEquals(1, it.attributes.size)
+                assertArrayEquals("testvalue".encodeToByteArray(), it.attributes["testkey"])
+            }
+            assertEquals(
+                    setOf(parseNumericAddress("192.0.2.123"), parseNumericAddress("2001:db8::123")),
+                    serviceResolved.serviceInfo.hostAddresses.toSet()
+            )
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
-        assertEquals(
-                setOf(parseNumericAddress("192.0.2.123"), parseNumericAddress("2001:db8::123")),
-                serviceResolved.serviceInfo.hostAddresses.toSet()
-        )
     }
 
     @Test
@@ -1774,13 +1680,9 @@
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
         var nsResponder: NSResponder? = null
+        val packetReader = makePacketReader()
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
-            packetReader.startAsyncForTest()
-
-            handlerThread.waitForIdle(TIMEOUT_MS)
             /*
             Send a "query unicast" query.
             Generated with:
@@ -1805,10 +1707,13 @@
                         pkt.dstAddr == testSrcAddr
             }
             assertNotNull(reply)
-        } cleanup {
+        } cleanupStep {
             nsResponder?.stop()
             nsdManager.unregisterService(registrationRecord)
             registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -1824,13 +1729,9 @@
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
         var nsResponder: NSResponder? = null
+        val packetReader = makePacketReader()
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
-                    testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
-            packetReader.startAsyncForTest()
-
-            handlerThread.waitForIdle(TIMEOUT_MS)
             /*
             Send a query with a known answer. Expect to receive a response containing TXT record
             only.
@@ -1895,10 +1796,13 @@
                         pkt.dstAddr == testSrcAddr
             }
             assertNotNull(reply2)
-        } cleanup {
+        } cleanupStep {
             nsResponder?.stop()
             nsdManager.unregisterService(registrationRecord)
             registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -1914,13 +1818,9 @@
         // Register service on testNetwork1
         val registrationRecord = NsdRegistrationRecord()
         var nsResponder: NSResponder? = null
+        val packetReader = makePacketReader()
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
-                    testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
-            packetReader.startAsyncForTest()
-
-            handlerThread.waitForIdle(TIMEOUT_MS)
             /*
             Send a query with truncated bit set.
             Generated with:
@@ -1976,10 +1876,13 @@
                         pkt.dstAddr == testSrcAddr
             }
             assertNotNull(reply)
-        } cleanup {
+        } cleanupStep {
             nsResponder?.stop()
             nsdManager.unregisterService(registrationRecord)
             registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -1991,13 +1894,7 @@
 
         // Register service on testNetwork1
         val discoveryRecord = NsdDiscoveryRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         nsdManager.discoverServices(
             serviceType,
@@ -2043,9 +1940,12 @@
                         pkt.isReplyFor("$serviceType.local", DnsResolver.TYPE_PTR)
             }
             assertNotNull(query)
-        } cleanup {
+        } cleanupStep {
             nsdManager.stopServiceDiscovery(discoveryRecord)
             discoveryRecord.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -2355,14 +2255,7 @@
             it.port = TEST_PORT
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
-
+        val packetReader = makePacketReader()
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         tryTest {
@@ -2394,8 +2287,11 @@
             nsdManager.stopServiceDiscovery(discoveryRecord)
 
             discoveryRecord.expectCallback<DiscoveryStopped>()
-        } cleanup {
+        } cleanupStep {
             nsdManager.unregisterService(registrationRecord)
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -2410,14 +2306,7 @@
                     parseNumericAddress("2001:db8::2"))
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
-
+        val packetReader = makePacketReader()
         val registrationRecord = NsdRegistrationRecord()
         tryTest {
             registerService(registrationRecord, si)
@@ -2439,8 +2328,11 @@
                         it.nsType == DnsResolver.TYPE_A
             }
             assertEquals(3, addressRecords.size)
-        } cleanup {
+        } cleanupStep {
             nsdManager.unregisterService(registrationRecord)
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -2467,14 +2359,7 @@
             it.hostAddresses = listOf()
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-            testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
-
+        val packetReader = makePacketReader()
         val registrationRecord1 = NsdRegistrationRecord()
         val registrationRecord2 = NsdRegistrationRecord()
         tryTest {
@@ -2508,9 +2393,12 @@
             assertTrue(keyRecords.any { it.dName == "$customHostname.local" })
             assertTrue(keyRecords.all { it.ttl == NAME_RECORDS_TTL_MILLIS })
             assertTrue(keyRecords.all { it.rr.contentEquals(publicKey) })
-        } cleanup {
+        } cleanupStep {
             nsdManager.unregisterService(registrationRecord1)
             nsdManager.unregisterService(registrationRecord2)
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -2582,13 +2470,7 @@
             "test_nsd_avoid_advertising_empty_txt_records",
             "1"
         )
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-            testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
-        )
-        packetReader.startAsyncForTest()
-        handlerThread.waitForIdle(TIMEOUT_MS)
+        val packetReader = makePacketReader()
 
         // Test behavior described in RFC6763 6.1: empty TXT records are not allowed, but TXT
         // records with a zero length string are equivalent.
@@ -2607,9 +2489,125 @@
             assertEquals(1, txtRecords.size)
             // The TXT record should contain as single zero
             assertContentEquals(byteArrayOf(0), txtRecords[0].rr)
-        } cleanup {
+        } cleanupStep {
             nsdManager.unregisterService(registrationRecord)
             registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
+        }
+    }
+
+    private fun verifyCachedServicesRemoval(isCachedServiceRemoved: Boolean) {
+        val si = makeTestServiceInfo(testNetwork1.network)
+        // Register service on testNetwork1
+        val registrationRecord = NsdRegistrationRecord()
+        registerService(registrationRecord, si)
+        // Register a discovery request.
+        val discoveryRecord = NsdDiscoveryRecord()
+        val packetReader = makePacketReader()
+
+        tryTest {
+            nsdManager.discoverServices(
+                    serviceType,
+                    NsdManager.PROTOCOL_DNS_SD,
+                    testNetwork1.network,
+                    { it.run() },
+                    discoveryRecord
+            )
+
+            discoveryRecord.expectCallback<DiscoveryStarted>()
+            val foundInfo = discoveryRecord.waitForServiceDiscovered(
+                    serviceName, serviceType, testNetwork1.network)
+            assertEquals(testNetwork1.network, foundInfo.network)
+            // Verify that the service is not in the cache (a query is sent).
+            assertNotNull(packetReader.pollForQuery(
+                    "$serviceType.local", DnsResolver.TYPE_PTR, timeoutMs = 0L))
+
+            // Stop discovery to trigger the cached services removal process.
+            nsdManager.stopServiceDiscovery(discoveryRecord)
+            discoveryRecord.expectCallback<DiscoveryStopped>()
+
+            val serviceFullName = "$serviceName.$serviceType.local"
+            if (isCachedServiceRemoved) {
+                Thread.sleep(100L)
+                resolveService(foundInfo)
+                // Verify the resolution query will send because cached services are remove after
+                // exceeding the retention time.
+                assertNotNull(packetReader.pollForQuery(
+                        serviceFullName, DnsResolver.TYPE_ANY, timeoutMs = 0L))
+            } else {
+                resolveService(foundInfo)
+                // Verify the resolution query will not be sent because services are still cached.
+                assertNull(packetReader.pollForQuery(
+                        serviceFullName, DnsResolver.TYPE_ANY, timeoutMs = 0L))
+            }
+        } cleanupStep {
+            nsdManager.unregisterService(registrationRecord)
+            registrationRecord.expectCallback<ServiceUnregistered>()
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
+        }
+    }
+
+    @Test
+    fun testRemoveCachedServices() {
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_cached_services_removal", "1")
+        verifyCachedServicesRemoval(isCachedServiceRemoved = false)
+    }
+
+    @Test
+    fun testRemoveCachedServices_ShortRetentionTime() {
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_cached_services_removal", "1")
+        deviceConfigRule.setConfig(
+                NAMESPACE_TETHERING,
+                "test_nsd_cached_services_retention_time",
+                "1"
+        )
+        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)
         }
     }
 
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
index 0dd2a23..173d13f 100644
--- a/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsNetUtils.java
@@ -53,6 +53,7 @@
 import android.os.Build;
 import android.os.ConditionVariable;
 import android.os.IBinder;
+import android.os.UserHandle;
 import android.system.Os;
 import android.system.OsConstants;
 import android.telephony.SubscriptionManager;
@@ -145,7 +146,8 @@
         for (final String pkg : new String[] {"com.android.shell", mContext.getPackageName()}) {
             final String cmd =
                     String.format(
-                            "appops set %s %s %s",
+                            "appops set --user %d %s %s %s",
+                            UserHandle.myUserId(), // user id
                             pkg, // Package name
                             opName, // Appop
                             (allow ? "allow" : "deny")); // Action
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 dffd9d5..243cd27 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
@@ -46,6 +46,7 @@
 import android.net.TetheringManager.TetheringEventCallback;
 import android.net.TetheringManager.TetheringInterfaceRegexps;
 import android.net.TetheringManager.TetheringRequest;
+import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
@@ -491,13 +492,29 @@
         }
     }
 
+    /**
+     * Starts Wi-Fi tethering.
+     */
     public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback)
             throws InterruptedException {
+        return startWifiTethering(callback, null);
+    }
+
+    /**
+     * Starts Wi-Fi tethering with the specified SoftApConfiguration.
+     */
+    public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback,
+            final SoftApConfiguration softApConfiguration)
+            throws InterruptedException {
         final List<String> wifiRegexs = getWifiTetherableInterfaceRegexps(callback);
 
         final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
-        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
-                .setShouldShowEntitlementUi(false).build();
+        TetheringRequest.Builder builder = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setShouldShowEntitlementUi(false);
+        if (softApConfiguration != null) {
+            builder.setSoftApConfiguration(softApConfiguration);
+        }
+        final TetheringRequest request = builder.build();
 
         return runAsShell(TETHER_PRIVILEGED, () -> {
             mTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
diff --git a/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt b/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
index 32d6899..20cfa1d 100644
--- a/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
+++ b/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
@@ -28,7 +28,7 @@
 import android.os.Handler
 import android.util.Log
 import com.android.net.module.util.ArrayTrackRecord
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.runAsShell
 import com.android.testutils.waitForIdle
 import java.net.NetworkInterface
@@ -85,7 +85,7 @@
             assertNotNull(nif)
             return nif.mtu
         }
-    val packetReader = TapPacketReader(handler, testIface.fileDescriptor.fileDescriptor, mtu)
+    val packetReader = PollPacketReader(handler, testIface.fileDescriptor.fileDescriptor, mtu)
     private val listener = EthernetStateListener(name)
     private val em = context.getSystemService(EthernetManager::class.java)!!
     @Volatile private var cleanedUp = false
diff --git a/tests/cts/netpermission/internetpermission/Android.bp b/tests/cts/netpermission/internetpermission/Android.bp
index e0424ac..71d2b6e 100644
--- a/tests/cts/netpermission/internetpermission/Android.bp
+++ b/tests/cts/netpermission/internetpermission/Android.bp
@@ -32,4 +32,7 @@
     ],
     host_required: ["net-tests-utils-host-common"],
     sdk_version: "test_current",
+    data: [
+        ":ConnectivityTestPreparer",
+    ],
 }
diff --git a/tests/cts/netpermission/internetpermission/AndroidTest.xml b/tests/cts/netpermission/internetpermission/AndroidTest.xml
index ad9a731..13deb82 100644
--- a/tests/cts/netpermission/internetpermission/AndroidTest.xml
+++ b/tests/cts/netpermission/internetpermission/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/netpermission/updatestatspermission/Android.bp b/tests/cts/netpermission/updatestatspermission/Android.bp
index 689ce74..0ff98e7 100644
--- a/tests/cts/netpermission/updatestatspermission/Android.bp
+++ b/tests/cts/netpermission/updatestatspermission/Android.bp
@@ -33,8 +33,10 @@
 
     // Tag this module as a cts test artifact
     test_suites: [
+        "automotive-general-tests",
         "cts",
         "general-tests",
     ],
+    data: [":ConnectivityTestPreparer"],
     host_required: ["net-tests-utils-host-common"],
 }
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 1165018..d167836 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -19,7 +19,10 @@
 
 java_defaults {
     name: "CtsTetheringTestDefaults",
-    defaults: ["cts_defaults"],
+    defaults: [
+        "cts_defaults",
+        "framework-connectivity-test-defaults",
+    ],
 
     libs: [
         "android.test.base.stubs.system",
@@ -31,6 +34,7 @@
 
     static_libs: [
         "TetheringCommonTests",
+        "com.android.net.flags-aconfig-java",
         "compatibility-device-util-axt",
         "cts-net-utils",
         "net-tests-utils",
@@ -94,14 +98,8 @@
     // Tag this module as a cts test artifact
     test_suites: [
         "cts",
-        "mts-dnsresolver",
-        "mts-networking",
         "mts-tethering",
-        "mts-wifi",
-        "mcts-dnsresolver",
-        "mcts-networking",
         "mcts-tethering",
-        "mcts-wifi",
         "general-tests",
     ],
 
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index 1454d9a..d103f75 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -17,21 +17,26 @@
 
 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;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+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.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.cts.util.CtsTetheringUtils.isAnyIfaceMatch;
+import static android.os.Process.INVALID_UID;
 
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -40,6 +45,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;
@@ -67,6 +73,7 @@
 import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiSsid;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.PersistableBundle;
 import android.os.ResultReceiver;
@@ -74,14 +81,18 @@
 import android.telephony.SubscriptionManager;
 import android.telephony.TelephonyManager;
 
+import androidx.annotation.NonNull;
 import androidx.test.InstrumentationRegistry;
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.flags.Flags;
 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;
 
@@ -90,12 +101,15 @@
 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;
 
@@ -222,19 +236,23 @@
 
     }
 
-    @Test
-    public void testTetheringRequest() {
-        SoftApConfiguration softApConfiguration;
+    private SoftApConfiguration createSoftApConfiguration(@NonNull String ssid) {
+        SoftApConfiguration config;
         if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
+            config = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes(ssid.getBytes(StandardCharsets.UTF_8)))
                     .build();
         } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
+            config = new SoftApConfiguration.Builder()
+                    .setSsid(ssid)
                     .build();
         }
+        return config;
+    }
+
+    @Test
+    public void testTetheringRequest() {
+        SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         final TetheringRequest tr = new TetheringRequest.Builder(TETHERING_WIFI)
                 .setSoftApConfiguration(softApConfiguration)
                 .build();
@@ -243,41 +261,70 @@
         assertNull(tr.getClientStaticIpv4Address());
         assertFalse(tr.isExemptFromEntitlementCheck());
         assertTrue(tr.getShouldShowEntitlementUi());
+        assertEquals(CONNECTIVITY_SCOPE_GLOBAL, tr.getConnectivityScope());
         assertEquals(softApConfiguration, tr.getSoftApConfiguration());
+        assertEquals(INVALID_UID, tr.getUid());
+        assertNull(tr.getPackageName());
+        assertEquals(tr.toString(), "TetheringRequest[ "
+                + "TETHERING_WIFI, "
+                + "showProvisioningUi, "
+                + "CONNECTIVITY_SCOPE_GLOBAL, "
+                + "softApConfig=" + softApConfiguration.toString()
+                + " ]");
 
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
         final TetheringRequest tr2 = new TetheringRequest.Builder(TETHERING_USB)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
-                .setShouldShowEntitlementUi(false).build();
+                .setShouldShowEntitlementUi(false)
+                .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL)
+                .build();
+        int uid = 1000;
+        String packageName = "package";
+        tr2.setUid(uid);
+        tr2.setPackageName(packageName);
 
         assertEquals(localAddr, tr2.getLocalIpv4Address());
         assertEquals(clientAddr, tr2.getClientStaticIpv4Address());
         assertEquals(TETHERING_USB, tr2.getTetheringType());
         assertTrue(tr2.isExemptFromEntitlementCheck());
         assertFalse(tr2.getShouldShowEntitlementUi());
+        assertEquals(CONNECTIVITY_SCOPE_LOCAL, tr2.getConnectivityScope());
+        assertNull(tr2.getSoftApConfiguration());
+        assertEquals(uid, tr2.getUid());
+        assertEquals(packageName, tr2.getPackageName());
+        assertEquals(tr2.toString(), "TetheringRequest[ "
+                + "TETHERING_USB, "
+                + "localIpv4Address=" + localAddr + ", "
+                + "staticClientAddress=" + clientAddr + ", "
+                + "exemptFromEntitlementCheck, "
+                + "CONNECTIVITY_SCOPE_LOCAL, "
+                + "uid=1000, "
+                + "packageName=package"
+                + " ]");
 
         final TetheringRequest tr3 = new TetheringRequest.Builder(TETHERING_USB)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
-                .setShouldShowEntitlementUi(false).build();
+                .setShouldShowEntitlementUi(false)
+                .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL)
+                .build();
+        tr3.setUid(uid);
+        tr3.setPackageName(packageName);
         assertEquals(tr2, tr3);
+
+        final String interfaceName = "test_iface";
+        final TetheringRequest tr4 = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName(interfaceName)
+                .setConnectivityScope(CONNECTIVITY_SCOPE_GLOBAL).build();
+        assertEquals(interfaceName, tr4.getInterfaceName());
+        assertEquals(CONNECTIVITY_SCOPE_GLOBAL, tr4.getConnectivityScope());
     }
 
     @Test
     public void testTetheringRequestSetSoftApConfigurationFailsWhenNotWifi() {
-        final SoftApConfiguration softApConfiguration;
-        if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
-                    .build();
-        } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
-                    .build();
-        }
+        final SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         for (int type : List.of(TETHERING_USB, TETHERING_BLUETOOTH, TETHERING_WIFI_P2P,
                 TETHERING_NCM, TETHERING_ETHERNET)) {
             try {
@@ -290,33 +337,40 @@
     }
 
     @Test
-    public void testTetheringRequestParcelable() {
-        final SoftApConfiguration softApConfiguration;
-        if (SdkLevel.isAtLeastT()) {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setWifiSsid(WifiSsid.fromBytes(
-                            "This is an SSID!".getBytes(StandardCharsets.UTF_8)))
-                    .build();
-        } else {
-            softApConfiguration = new SoftApConfiguration.Builder()
-                    .setSsid("This is an SSID!")
-                    .build();
+    public void testTetheringRequestSetInterfaceNameFailsExceptTetheringVirtual() {
+        for (int type : List.of(TETHERING_USB, TETHERING_BLUETOOTH, TETHERING_NCM,
+                TETHERING_ETHERNET)) {
+            try {
+                new TetheringRequest.Builder(type).setInterfaceName("test_iface");
+                fail("Was able to set interface name for tethering type " + type);
+            } catch (IllegalArgumentException e) {
+                // Success
+            }
         }
+    }
+
+    @Test
+    public void testTetheringRequestParcelable() {
+        final SoftApConfiguration softApConfiguration = createSoftApConfiguration("SSID");
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
-        final TetheringRequest withConfig = new TetheringRequest.Builder(TETHERING_WIFI)
+        final TetheringRequest withApConfig = new TetheringRequest.Builder(TETHERING_WIFI)
                 .setSoftApConfiguration(softApConfiguration)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
                 .setShouldShowEntitlementUi(false).build();
-        final TetheringRequest withoutConfig = new TetheringRequest.Builder(TETHERING_WIFI)
+        final TetheringRequest withoutApConfig = new TetheringRequest.Builder(TETHERING_WIFI)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
                 .setShouldShowEntitlementUi(false).build();
-        assertEquals(withConfig, ParcelUtils.parcelingRoundTrip(withConfig));
-        assertEquals(withoutConfig, ParcelUtils.parcelingRoundTrip(withoutConfig));
-        assertNotEquals(withConfig, ParcelUtils.parcelingRoundTrip(withoutConfig));
-        assertNotEquals(withoutConfig, ParcelUtils.parcelingRoundTrip(withConfig));
+        assertEquals(withApConfig, ParcelUtils.parcelingRoundTrip(withApConfig));
+        assertEquals(withoutApConfig, ParcelUtils.parcelingRoundTrip(withoutApConfig));
+        assertNotEquals(withApConfig, ParcelUtils.parcelingRoundTrip(withoutApConfig));
+        assertNotEquals(withoutApConfig, ParcelUtils.parcelingRoundTrip(withApConfig));
+
+        final TetheringRequest withIfaceName = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("test_iface").build();
+        assertEquals(withIfaceName, ParcelUtils.parcelingRoundTrip(withIfaceName));
     }
 
     @Test
@@ -328,26 +382,31 @@
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
             tetherEventCallback.expectNoTetheringActive();
 
+            SoftApConfiguration softApConfig = createSoftApConfiguration("SSID");
             final TetheringInterface tetheredIface =
-                    mCtsTetheringUtils.startWifiTethering(tetherEventCallback);
+                    mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
             assertNotNull(tetheredIface);
+            assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
             final String wifiTetheringIface = tetheredIface.getInterface();
 
             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 (Build.VERSION.SDK_INT <= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+                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);
@@ -398,11 +457,40 @@
     }
 
     @Test
-    public void testEnableTetheringPermission() throws Exception {
+    public void testStopTetheringRequest() throws Exception {
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        Executor executor = Runnable::run;
+        TetheringManager.StopTetheringCallback callback =
+                new TetheringManager.StopTetheringCallback() {};
+        try {
+            mTM.stopTethering(request, executor, callback);
+            fail("stopTethering should throw UnsupportedOperationException");
+        } catch (UnsupportedOperationException expect) { }
+    }
+
+    private boolean isTetheringWithSoftApConfigEnabled() {
+        return Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM
+                && Flags.tetheringWithSoftApConfig();
+    }
+
+    @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 (isTetheringWithSoftApConfigEnabled()) {
+            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 {
@@ -467,22 +555,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() {
@@ -543,4 +622,11 @@
             }
         }
     }
+
+    @Test
+    public void testLegacyTetherApisThrowUnsupportedOperationExceptionAfterV() {
+        assumeTrue(Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM);
+        assertThrows(UnsupportedOperationException.class, () -> mTM.tether("iface"));
+        assertThrows(UnsupportedOperationException.class, () -> mTM.untether("iface"));
+    }
 }
diff --git a/tests/deflake/Android.bp b/tests/deflake/Android.bp
index 726e504..70a3655 100644
--- a/tests/deflake/Android.bp
+++ b/tests/deflake/Android.bp
@@ -40,7 +40,7 @@
         "kotlin-test",
         "net-host-tests-utils",
     ],
-    data: [":FrameworksNetTests"],
+    device_common_data: [":FrameworksNetTests"],
     test_suites: ["device-tests"],
     // It will get build error if just set enabled to true. It fails with "windows_common"
     // depends on some disabled modules that are used by this test and it looks like set
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 00f9d05..9edf9bd 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -72,8 +72,8 @@
     ],
 }
 
-java_defaults {
-    name: "FrameworksNetTestsDefaults",
+android_library {
+    name: "ConnectivityUnitTestsLib",
     min_sdk_version: "30",
     defaults: [
         "framework-connectivity-internal-test-defaults",
@@ -82,6 +82,7 @@
         "java/**/*.java",
         "java/**/*.kt",
     ],
+    exclude_srcs: [":non-connectivity-module-test"],
     static_libs: [
         "androidx.test.rules",
         "androidx.test.uiautomator_uiautomator",
@@ -110,18 +111,10 @@
         "ServiceConnectivityResources",
     ],
     exclude_kotlinc_generated_files: false,
-}
-
-android_library {
-    name: "FrameworksNetTestsLib",
-    defaults: [
-        "FrameworksNetTestsDefaults",
-    ],
-    exclude_srcs: [":non-connectivity-module-test"],
     visibility: ["//packages/modules/Connectivity/tests:__subpackages__"],
 }
 
-genrule {
+java_genrule {
     name: "frameworks-net-tests-jarjar-rules",
     defaults: ["jarjar-rules-combine-defaults"],
     srcs: [
@@ -137,7 +130,7 @@
 java_genrule {
     name: "frameworks-net-tests-lib-jarjar-gen",
     tool_files: [
-        ":FrameworksNetTestsLib{.jar}",
+        ":ConnectivityUnitTestsLib{.jar}",
         "jarjar-excludes.txt",
     ],
     tools: [
@@ -145,7 +138,7 @@
     ],
     out: ["frameworks-net-tests-lib-jarjar-rules.txt"],
     cmd: "$(location jarjar-rules-generator) " +
-        "$(location :FrameworksNetTestsLib{.jar}) " +
+        "$(location :ConnectivityUnitTestsLib{.jar}) " +
         "--prefix android.net.connectivity " +
         "--excludes $(location jarjar-excludes.txt) " +
         "--output $(out)",
@@ -156,14 +149,25 @@
     name: "FrameworksNetTests",
     enabled: enable_frameworks_net_tests,
     defaults: [
-        "FrameworksNetTestsDefaults",
+        "framework-connectivity-internal-test-defaults",
         "FrameworksNetTests-jni-defaults",
     ],
     jarjar_rules: ":frameworks-net-tests-jarjar-rules",
+    srcs: [":non-connectivity-module-test"],
     test_suites: ["device-tests"],
     static_libs: [
+        "frameworks-base-testutils",
         "services.core",
         "services.net",
+        "androidx.test.rules",
+        "framework-protos",
+        "mockito-target-minus-junit4",
+        "net-tests-utils",
+        "service-connectivity-pre-jarjar",
+        "service-connectivity-tiramisu-pre-jarjar",
+    ],
+    libs: [
+        "android.test.mock.stubs",
     ],
     jni_libs: [
         "libandroid_net_connectivity_com_android_net_module_util_jni",
diff --git a/tests/unit/java/android/net/ConnectivityManagerTest.java b/tests/unit/java/android/net/ConnectivityManagerTest.java
index 9a77c89..b415382 100644
--- a/tests/unit/java/android/net/ConnectivityManagerTest.java
+++ b/tests/unit/java/android/net/ConnectivityManagerTest.java
@@ -44,6 +44,7 @@
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.nullable;
@@ -65,6 +66,7 @@
 import android.content.Context;
 import android.content.pm.ApplicationInfo;
 import android.net.ConnectivityManager.NetworkCallback;
+import android.os.Build;
 import android.os.Build.VERSION_CODES;
 import android.os.Bundle;
 import android.os.Handler;
@@ -669,4 +671,12 @@
                 // No callbacks overridden -> do not use the optimization
                 eq(~0));
     }
+
+    @Test
+    public void testLegacyTetherApisThrowUnsupportedOperationExceptionAfterV() {
+        assumeTrue(Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM);
+        final ConnectivityManager manager = new ConnectivityManager(mCtx, mService);
+        assertThrows(UnsupportedOperationException.class, () -> manager.tether("iface"));
+        assertThrows(UnsupportedOperationException.class, () -> manager.untether("iface"));
+    }
 }
diff --git a/tests/unit/java/android/net/TrafficStatsTest.kt b/tests/unit/java/android/net/TrafficStatsTest.kt
index c61541e..0f85daf 100644
--- a/tests/unit/java/android/net/TrafficStatsTest.kt
+++ b/tests/unit/java/android/net/TrafficStatsTest.kt
@@ -1,46 +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.
-*/
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 
 package android.net
 
-import android.net.TrafficStats.getValueForTypeFromFirstEntry
-import android.net.TrafficStats.TYPE_RX_BYTES
 import android.net.TrafficStats.UNSUPPORTED
+import android.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 kotlin.test.assertEquals
+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
 
-private const val TEST_IFACE1 = "test_iface1"
+const val TEST_EXPIRY_DURATION_MS = 1000
+const val TEST_IFACE = "wlan0"
 
 @RunWith(DevSdkIgnoreRunner::class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+@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())
 
-    @Test
-    fun testGetValueForTypeFromFirstEntry() {
-        var stats: NetworkStats = NetworkStats(0, 0)
-        // empty stats
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), UNSUPPORTED.toLong())
-        // invalid type
-        stats.insertEntry(TEST_IFACE1, 1, 2, 3, 4)
-        assertEquals(getValueForTypeFromFirstEntry(stats, 1000), UNSUPPORTED.toLong())
-        // valid type
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), 1)
+    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
+        }
     }
-}
\ No newline at end of file
+
+    @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/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index 999d17d..f7d7c87 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -2369,6 +2369,18 @@
             mScheduledEvaluationTimeouts.add(new Pair<>(network.netId, delayMs));
             super.scheduleEvaluationTimeout(handler, network, delayMs);
         }
+
+        @Override
+        public int getDefaultCellularDataInactivityTimeout() {
+            // Needed to mock out the dependency on DeviceConfig
+            return 10;
+        }
+
+        @Override
+        public int getDefaultWifiDataInactivityTimeout() {
+            // Needed to mock out the dependency on DeviceConfig
+            return 15;
+        }
     }
 
     private class AutomaticOnOffKeepaliveTrackerDependencies
diff --git a/tests/unit/java/com/android/server/L2capNetworkProviderTest.kt b/tests/unit/java/com/android/server/L2capNetworkProviderTest.kt
new file mode 100644
index 0000000..5a7515e
--- /dev/null
+++ b/tests/unit/java/com/android/server/L2capNetworkProviderTest.kt
@@ -0,0 +1,216 @@
+/*
+ * 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.content.Context
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.TYPE_NONE
+import android.net.L2capNetworkSpecifier
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
+import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_NONE
+import android.net.L2capNetworkSpecifier.ROLE_SERVER
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
+import android.net.NetworkProvider
+import android.net.NetworkProvider.NetworkOfferCallback
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertTrue
+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.any
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mock
+import org.mockito.Mockito.any
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.MockitoAnnotations
+
+const val TAG = "L2capNetworkProviderTest"
+
+val RESERVATION_CAPS = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+    .addTransportType(TRANSPORT_BLUETOOTH)
+    .build()
+
+val RESERVATION = NetworkRequest(
+        NetworkCapabilities(RESERVATION_CAPS),
+        TYPE_NONE,
+        42 /* rId */,
+        NetworkRequest.Type.RESERVATION
+)
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class L2capNetworkProviderTest {
+    @Mock private lateinit var context: Context
+    @Mock private lateinit var deps: L2capNetworkProvider.Dependencies
+    @Mock private lateinit var provider: NetworkProvider
+    @Mock private lateinit var cm: ConnectivityManager
+    @Mock private lateinit var pm: PackageManager
+
+    private val handlerThread = HandlerThread("$TAG handler thread").apply { start() }
+    private val handler = Handler(handlerThread.looper)
+
+    @Before
+    fun setUp() {
+        MockitoAnnotations.initMocks(this)
+        doReturn(provider).`when`(deps).getNetworkProvider(any(), any())
+        doReturn(handlerThread).`when`(deps).getHandlerThread()
+        doReturn(cm).`when`(context).getSystemService(eq(ConnectivityManager::class.java))
+        doReturn(pm).`when`(context).getPackageManager()
+        doReturn(true).`when`(pm).hasSystemFeature(FEATURE_BLUETOOTH_LE)
+    }
+
+    @After
+    fun tearDown() {
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+
+    @Test
+    fun testNetworkProvider_registeredWhenSupported() {
+        L2capNetworkProvider(deps, context).start()
+        verify(cm).registerNetworkProvider(eq(provider))
+        verify(provider).registerNetworkOffer(any(), any(), any(), any())
+    }
+
+    @Test
+    fun testNetworkProvider_notRegisteredWhenNotSupported() {
+        doReturn(false).`when`(pm).hasSystemFeature(FEATURE_BLUETOOTH_LE)
+        L2capNetworkProvider(deps, context).start()
+        verify(cm, never()).registerNetworkProvider(eq(provider))
+    }
+
+    fun doTestBlanketOfferIgnoresRequest(request: NetworkRequest) {
+        clearInvocations(provider)
+        L2capNetworkProvider(deps, context).start()
+
+        val blanketOfferCaptor = ArgumentCaptor.forClass(NetworkOfferCallback::class.java)
+        verify(provider).registerNetworkOffer(any(), any(), any(), blanketOfferCaptor.capture())
+
+        blanketOfferCaptor.value.onNetworkNeeded(request)
+        verify(provider).registerNetworkOffer(any(), any(), any(), any())
+    }
+
+    fun doTestBlanketOfferCreatesReservation(
+            request: NetworkRequest,
+            reservation: NetworkCapabilities
+    ) {
+        clearInvocations(provider)
+        L2capNetworkProvider(deps, context).start()
+
+        val blanketOfferCaptor = ArgumentCaptor.forClass(NetworkOfferCallback::class.java)
+        verify(provider).registerNetworkOffer(any(), any(), any(), blanketOfferCaptor.capture())
+
+        blanketOfferCaptor.value.onNetworkNeeded(request)
+
+        val capsCaptor = ArgumentCaptor.forClass(NetworkCapabilities::class.java)
+        verify(provider, times(2)).registerNetworkOffer(any(), capsCaptor.capture(), any(), any())
+
+        assertTrue(reservation.satisfiedByNetworkCapabilities(capsCaptor.value))
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithoutSpecifier() {
+        val caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .build()
+        val nr = NetworkRequest(caps, TYPE_NONE, 42 /* rId */, NetworkRequest.Type.RESERVATION)
+
+        doTestBlanketOfferIgnoresRequest(nr)
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithCorrectSpecifier() {
+        var specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
+                .build()
+        var caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        var nr = NetworkRequest(caps, TYPE_NONE, 42 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferCreatesReservation(nr, caps)
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .build()
+        caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        nr = NetworkRequest(caps, TYPE_NONE, 43 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferCreatesReservation(nr, caps)
+    }
+
+    @Test
+    fun testBlanketOffer_reservationWithIncorrectSpecifier() {
+        var specifier = L2capNetworkSpecifier.Builder().build()
+        var caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        var nr = NetworkRequest(caps, TYPE_NONE, 42 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferIgnoresRequest(nr)
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .build()
+        caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        nr = NetworkRequest(caps, TYPE_NONE, 44 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferIgnoresRequest(nr)
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setRole(ROLE_SERVER)
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .setPsm(0x81)
+                .build()
+        caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        nr = NetworkRequest(caps, TYPE_NONE, 45 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferIgnoresRequest(nr)
+
+        specifier = L2capNetworkSpecifier.Builder()
+                .setHeaderCompression(HEADER_COMPRESSION_NONE)
+                .build()
+        caps = NetworkCapabilities.Builder.withoutDefaultCapabilities()
+                .addTransportType(TRANSPORT_BLUETOOTH)
+                .setNetworkSpecifier(specifier)
+                .build()
+        nr = NetworkRequest(caps, TYPE_NONE, 47 /* rId */, NetworkRequest.Type.RESERVATION)
+        doTestBlanketOfferIgnoresRequest(nr)
+    }
+}
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..ba62114 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
@@ -185,12 +188,12 @@
     @Before
     fun setUp() {
         thread.start()
-        doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname()
+        doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname(anyBoolean())
         doReturn(mockInterfaceAdvertiser1).`when`(mockDeps).makeAdvertiser(eq(mockSocket1),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
+                any(), any(), any(), any(), any(), any(), any()
         )
         doReturn(mockInterfaceAdvertiser2).`when`(mockDeps).makeAdvertiser(eq(mockSocket2),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
+                any(), any(), any(), any(), any(), any(), any()
         )
         doReturn(true).`when`(mockInterfaceAdvertiser1).isProbing(anyInt())
         doReturn(true).`when`(mockInterfaceAdvertiser2).isProbing(anyInt())
@@ -578,11 +581,59 @@
     fun testRemoveService_whenAllServiceRemoved_thenUpdateHostName() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
-        verify(mockDeps, times(1)).generateHostname()
+        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 ec47618..ab2fb99 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -26,6 +26,7 @@
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -102,6 +103,7 @@
     @Mock MdnsServiceBrowserListener mockListenerOne;
     @Mock MdnsServiceBrowserListener mockListenerTwo;
     @Mock SharedLog sharedLog;
+    @Mock MdnsServiceCache mockServiceCache;
     private MdnsDiscoveryManager discoveryManager;
     private HandlerThread thread;
     private Handler handler;
@@ -145,7 +147,9 @@
                         return null;
                     }
                 };
+        discoveryManager = makeDiscoveryManager(MdnsFeatureFlags.newBuilder().build());
         doReturn(mockExecutorService).when(mockServiceTypeClientType1NullNetwork).getExecutor();
+        doReturn(mockExecutorService).when(mockServiceTypeClientType1Network1).getExecutor();
     }
 
     @After
@@ -156,6 +160,40 @@
         }
     }
 
+    private MdnsDiscoveryManager makeDiscoveryManager(@NonNull MdnsFeatureFlags featureFlags) {
+        return new MdnsDiscoveryManager(executorProvider, socketClient, sharedLog, featureFlags) {
+            @Override
+            MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
+                    @NonNull SocketKey socketKey) {
+                createdServiceTypeClientCount++;
+                final Pair<String, SocketKey> perSocketServiceType =
+                        Pair.create(serviceType, socketKey);
+                if (perSocketServiceType.equals(PER_SOCKET_SERVICE_TYPE_1_NULL_NETWORK)) {
+                    return mockServiceTypeClientType1NullNetwork;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_1_NETWORK_1)) {
+                    return mockServiceTypeClientType1Network1;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NULL_NETWORK)) {
+                    return mockServiceTypeClientType2NullNetwork;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NETWORK_1)) {
+                    return mockServiceTypeClientType2Network1;
+                } else if (perSocketServiceType.equals(
+                        PER_SOCKET_SERVICE_TYPE_2_NETWORK_2)) {
+                    return mockServiceTypeClientType2Network2;
+                }
+                fail("Unexpected perSocketServiceType: " + perSocketServiceType);
+                return null;
+            }
+
+            @Override
+            MdnsServiceCache getServiceCache() {
+                return mockServiceCache;
+            }
+        };
+    }
+
     private void runOnHandler(Runnable r) {
         handler.post(r);
         HandlerUtils.waitForIdle(handler, DEFAULT_TIMEOUT);
@@ -438,6 +476,57 @@
         }
     }
 
+    @Test
+    public void testRemoveServicesAfterAllListenersUnregistered() throws IOException {
+        final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
+                .setIsCachedServicesRemovalEnabled(true)
+                .setCachedServicesRetentionTime(0L)
+                .build();
+        discoveryManager = makeDiscoveryManager(mdnsFeatureFlags);
+
+        final MdnsSearchOptions options =
+                MdnsSearchOptions.newBuilder().setNetwork(NETWORK_1).build();
+        final SocketCreationCallback callback = expectSocketCreationCallback(
+                SERVICE_TYPE_1, mockListenerOne, options);
+        runOnHandler(() -> callback.onSocketCreated(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).startSendAndReceive(mockListenerOne, options);
+
+        final MdnsServiceCache.CacheKey cacheKey =
+                new MdnsServiceCache.CacheKey(SERVICE_TYPE_1, SOCKET_KEY_NETWORK_1);
+        doReturn(cacheKey).when(mockServiceTypeClientType1Network1).getCacheKey();
+        doReturn(true).when(mockServiceTypeClientType1Network1)
+                .stopSendAndReceive(mockListenerOne);
+        runOnHandler(() -> discoveryManager.unregisterListener(SERVICE_TYPE_1, mockListenerOne));
+        verify(executorProvider).shutdownExecutorService(mockExecutorService);
+        verify(mockServiceTypeClientType1Network1).stopSendAndReceive(mockListenerOne);
+        verify(socketClient).stopDiscovery();
+        verify(mockServiceCache, timeout(DEFAULT_TIMEOUT)).removeServices(cacheKey);
+    }
+
+    @Test
+    public void testRemoveServicesAfterSocketDestroyed() throws IOException {
+        final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
+                .setIsCachedServicesRemovalEnabled(true)
+                .setCachedServicesRetentionTime(0L)
+                .build();
+        discoveryManager = makeDiscoveryManager(mdnsFeatureFlags);
+
+        final MdnsSearchOptions options =
+                MdnsSearchOptions.newBuilder().setNetwork(NETWORK_1).build();
+        final SocketCreationCallback callback = expectSocketCreationCallback(
+                SERVICE_TYPE_1, mockListenerOne, options);
+        runOnHandler(() -> callback.onSocketCreated(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).startSendAndReceive(mockListenerOne, options);
+
+        final MdnsServiceCache.CacheKey cacheKey =
+                new MdnsServiceCache.CacheKey(SERVICE_TYPE_1, SOCKET_KEY_NETWORK_1);
+        doReturn(cacheKey).when(mockServiceTypeClientType1Network1).getCacheKey();
+        runOnHandler(() -> callback.onSocketDestroyed(SOCKET_KEY_NETWORK_1));
+        verify(mockServiceTypeClientType1Network1).notifySocketDestroyed();
+        verify(executorProvider).shutdownExecutorService(mockExecutorService);
+        verify(mockServiceCache, timeout(DEFAULT_TIMEOUT)).removeServices(cacheKey);
+    }
+
     private MdnsPacket createMdnsPacket(String serviceType) {
         final String[] type = TextUtils.split(serviceType, "\\.");
         final ArrayList<String> name = new ArrayList<>(type.length + 1);
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index da0bc88..67f9d9c 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;
@@ -566,21 +566,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 +591,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
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
index 5c3ad22..efae244 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
@@ -22,21 +22,27 @@
 import com.android.server.connectivity.mdns.MdnsConstants.FLAG_TRUNCATED
 import com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR
 import com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR
+import com.android.server.connectivity.mdns.MdnsInetAddressRecord
 import com.android.server.connectivity.mdns.MdnsPacket
 import com.android.server.connectivity.mdns.MdnsPacketReader
 import com.android.server.connectivity.mdns.MdnsPointerRecord
 import com.android.server.connectivity.mdns.MdnsRecord
+import com.android.server.connectivity.mdns.MdnsResponse
+import com.android.server.connectivity.mdns.MdnsServiceInfo
+import com.android.server.connectivity.mdns.MdnsServiceRecord
+import com.android.server.connectivity.mdns.MdnsTextRecord
 import com.android.server.connectivity.mdns.util.MdnsUtils.createQueryDatagramPackets
 import com.android.server.connectivity.mdns.util.MdnsUtils.truncateServiceName
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
-import java.net.DatagramPacket
-import kotlin.test.assertContentEquals
+import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
+import java.net.DatagramPacket
+import kotlin.test.assertContentEquals
 
 @RunWith(DevSdkIgnoreRunner::class)
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
@@ -157,4 +163,54 @@
         assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v6Packet, otherV6Packet)))
         assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet, v6Packet)))
     }
+
+    @Test
+    fun testBuildMdnsServiceInfoFromResponse() {
+        val serviceInstanceName = "MyTestService"
+        val serviceType = "_testservice._tcp.local"
+        val hostName = "Android_000102030405060708090A0B0C0D0E0F.local"
+        val port = 12345
+        val ttlTime = 120000L
+        val testElapsedRealtime = 123L
+        val serviceName = "$serviceInstanceName.$serviceType".split(".").toTypedArray()
+        val v4Address = "192.0.2.1"
+        val v6Address = "2001:db8::1"
+        val interfaceIndex = 99
+        val response = MdnsResponse(0 /* now */, serviceName, interfaceIndex, null /* network */)
+        // Set PTR record
+        response.addPointerRecord(MdnsPointerRecord(serviceType.split(".").toTypedArray(),
+                testElapsedRealtime, false /* cacheFlush */, ttlTime, serviceName))
+        // Set SRV record.
+        response.serviceRecord = MdnsServiceRecord(serviceName, testElapsedRealtime,
+                false /* cacheFlush */, ttlTime, 0 /* servicePriority */, 0 /* serviceWeight */,
+                port, hostName.split(".").toTypedArray())
+        // Set TXT record.
+        response.textRecord = MdnsTextRecord(serviceName,
+                testElapsedRealtime, true /* cacheFlush */, 0L /* ttlMillis */,
+                listOf(MdnsServiceInfo.TextEntry.fromString("somedifferent=entry")))
+        // Set InetAddress record.
+        response.addInet4AddressRecord(MdnsInetAddressRecord(hostName.split(".").toTypedArray(),
+                testElapsedRealtime, true /* cacheFlush */,
+                0L /* ttlMillis */, InetAddresses.parseNumericAddress(v4Address)))
+        response.addInet6AddressRecord(MdnsInetAddressRecord(hostName.split(".").toTypedArray(),
+                testElapsedRealtime, true /* cacheFlush */,
+                0L /* ttlMillis */, InetAddresses.parseNumericAddress(v6Address)))
+
+        // Convert a MdnsResponse to a MdnsServiceInfo
+        val serviceInfo = MdnsUtils.buildMdnsServiceInfoFromResponse(
+                response, serviceType.split(".").toTypedArray(), testElapsedRealtime)
+
+        assertEquals(serviceInstanceName, serviceInfo.serviceInstanceName)
+        assertArrayEquals(serviceType.split(".").toTypedArray(), serviceInfo.serviceType)
+        assertArrayEquals(hostName.split(".").toTypedArray(), serviceInfo.hostName)
+        assertEquals(port, serviceInfo.port)
+        assertEquals(1, serviceInfo.ipv4Addresses.size)
+        assertEquals(v4Address, serviceInfo.ipv4Addresses[0])
+        assertEquals(1, serviceInfo.ipv6Addresses.size)
+        assertEquals(v6Address, serviceInfo.ipv6Addresses[0])
+        assertEquals(interfaceIndex, serviceInfo.interfaceIndex)
+        assertEquals(null, serviceInfo.network)
+        assertEquals(mapOf("somedifferent" to "entry"),
+                serviceInfo.attributes)
+    }
 }
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/CSDestroyedNetworkTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
index 5c29e3a..b824531 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
@@ -27,9 +27,26 @@
 import com.android.testutils.TestableNetworkCallback
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
 
 private const val LONG_TIMEOUT_MS = 5_000
 
+private val CAPABILITIES = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_WIFI)
+        .build()
+
+private val REQUEST = NetworkRequest.Builder()
+        .clearCapabilities()
+        .addTransportType(TRANSPORT_WIFI)
+        .build()
+
+
 @DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
@@ -37,29 +54,53 @@
 class CSDestroyedNetworkTests : CSTest() {
     @Test
     fun testDestroyNetworkNotKeptWhenUnvalidated() {
-        val nc = NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_WIFI)
-                .build()
-
-        val nr = NetworkRequest.Builder()
-                .clearCapabilities()
-                .addTransportType(TRANSPORT_WIFI)
-                .build()
         val cbRequest = TestableNetworkCallback()
         val cbCallback = TestableNetworkCallback()
-        cm.requestNetwork(nr, cbRequest)
-        cm.registerNetworkCallback(nr, cbCallback)
+        cm.requestNetwork(REQUEST, cbRequest)
+        cm.registerNetworkCallback(REQUEST, cbCallback)
 
-        val firstAgent = Agent(nc = nc)
+        val firstAgent = Agent(nc = CAPABILITIES)
         firstAgent.connect()
         cbCallback.expectAvailableCallbacks(firstAgent.network, validated = false)
 
         firstAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
 
-        val secondAgent = Agent(nc = nc)
+        val secondAgent = Agent(nc = CAPABILITIES)
         secondAgent.connect()
         cbCallback.expectAvailableCallbacks(secondAgent.network, validated = false)
 
         cbCallback.expect<Lost>(timeoutMs = 500) { it.network == firstAgent.network }
     }
+
+    @Test
+    fun testDestroyNetworkWithDelayedTeardown() {
+        val cbRequest = TestableNetworkCallback()
+        val cbCallback = TestableNetworkCallback()
+        cm.requestNetwork(REQUEST, cbRequest)
+        cm.registerNetworkCallback(REQUEST, cbCallback)
+
+        val firstAgent = Agent(nc = CAPABILITIES)
+        firstAgent.connect()
+        firstAgent.setTeardownDelayMillis(1)
+        cbCallback.expectAvailableCallbacks(firstAgent.network, validated = false)
+
+        clearInvocations(netd)
+        val inOrder = inOrder(netd)
+        firstAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+
+        val secondAgent = Agent(nc = CAPABILITIES)
+        secondAgent.connect()
+        cbCallback.expectAvailableCallbacks(secondAgent.network, validated = false)
+        secondAgent.disconnect()
+
+        cbCallback.expect<Lost>(timeoutMs = 500) { it.network == firstAgent.network }
+        cbCallback.expect<Lost>(timeoutMs = 500) { it.network == secondAgent.network }
+        // onLost is fired before the network is destroyed.
+        waitForIdle()
+
+        inOrder.verify(netd).networkDestroy(eq(firstAgent.network.netId))
+        inOrder.verify(netd).networkCreate(argThat{ it.netId == secondAgent.network.netId })
+        inOrder.verify(netd).networkDestroy(eq(secondAgent.network.netId))
+        verify(netd, never()).networkSetPermissionForNetwork(anyInt(), anyInt())
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
index cb98454..16a30aa 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
@@ -55,25 +55,28 @@
 @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),
         )
     }
 
@@ -84,11 +87,11 @@
     @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)
@@ -96,7 +99,8 @@
             addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
             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()) }
@@ -106,7 +110,7 @@
         localAgent.connect()
         netdInOrder.verify(netd).networkCreate(
                 makeNativeNetworkConfigLocal(localAgent.network.netId, INetd.PERMISSION_NONE))
-        if (addLocalNetCapToRequest) {
+        if (params.addLocalNetCapToRequest) {
             assertEquals(localAgent.network, allNetworksCb.expect<Available>().network)
         } else {
             allNetworksCb.assertNoCallback(NO_CALLBACK_TIMEOUT_MS)
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
index df0a2cc..ccbd6b3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
@@ -21,6 +21,7 @@
 import android.net.ConnectivityManager.EXTRA_DEVICE_TYPE
 import android.net.ConnectivityManager.EXTRA_IS_ACTIVE
 import android.net.ConnectivityManager.EXTRA_REALTIME_NS
+import android.net.ConnectivitySettingsManager
 import android.net.LinkProperties
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_IMS
@@ -41,12 +42,14 @@
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
+import java.time.Duration
 import kotlin.test.assertNotNull
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.anyString
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.anyLong
@@ -69,6 +72,18 @@
 @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 class CSNetworkActivityTest : CSTest() {
 
+    private fun setMobileDataActivityTimeout(timeoutSeconds: Int) {
+        ConnectivitySettingsManager.setMobileDataActivityTimeout(
+            context, Duration.ofSeconds(timeoutSeconds.toLong())
+        )
+    }
+
+    private fun setWifiDataActivityTimeout(timeoutSeconds: Int) {
+        ConnectivitySettingsManager.setWifiDataActivityTimeout(
+            context, Duration.ofSeconds(timeoutSeconds.toLong())
+        )
+    }
+
     private fun getRegisteredNetdUnsolicitedEventListener(): BaseNetdUnsolicitedEventListener {
         val captor = ArgumentCaptor.forClass(BaseNetdUnsolicitedEventListener::class.java)
         verify(netd).registerUnsolicitedEventListener(captor.capture())
@@ -252,8 +267,122 @@
         cm.unregisterNetworkCallback(dataNetworkCb)
         cm.unregisterNetworkCallback(imsNetworkCb)
     }
+
+    @Test
+    fun testCellularIdleTimerSettingsTimeout() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+
+        val settingsTimeout: Int = deps.defaultCellDataInactivityTimeoutForTest + 432
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is set, so the default should be ignored.
+        setMobileDataActivityTimeout(settingsTimeout)
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(DATA_CELL_IFNAME), eq(settingsTimeout), anyString())
+    }
+
+    @Test
+    fun testCellularIdleTimerDefaultTimeout() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+
+        val testTimeout: Int = deps.defaultCellDataInactivityTimeoutForTest
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is not set, so the default should be used.
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(DATA_CELL_IFNAME), eq(testTimeout), anyString())
+    }
+
+    @Test
+    fun testCellularIdleTimerDisabled() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+        setMobileDataActivityTimeout(0)
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd, never()).idletimerAddInterface(eq(DATA_CELL_IFNAME), anyInt(), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerSettingsTimeout() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        val settingsTimeout: Int = deps.defaultWifiDataInactivityTimeout + 435
+        setWifiDataActivityTimeout(settingsTimeout)
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is set, so the default should be ignored.
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(WIFI_IFNAME), eq(settingsTimeout), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerDefaultTimeout() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        val testTimeout: Int = deps.defaultWifiDataInactivityTimeoutForTest
+        // DATA_ACTIVITY_TIMEOUT_WIFI is not set, so the default should be used.
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(WIFI_IFNAME), eq(testTimeout), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerDisabled() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        setWifiDataActivityTimeout(0)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd, never()).idletimerAddInterface(eq(WIFI_IFNAME), anyInt(), anyString())
+    }
 }
 
+
 internal fun CSContext.expectDataActivityBroadcast(
         deviceType: Int,
         isActive: Boolean,
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/CSSatelliteNetworkTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
index 5ca7fcc..58420c0 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSSatelliteNetworkTest.kt
@@ -163,19 +163,36 @@
         doTestSatelliteNeverBecomeDefaultNetwork(restricted = false)
     }
 
-    private fun doTestUnregisterAfterReplacementSatisfier(destroyed: Boolean) {
+    private fun doTestUnregisterAfterReplacementSatisfier(destroyBeforeRequest: Boolean = false,
+                                                          destroyAfterRequest: Boolean = false) {
         val satelliteAgent = createSatelliteAgent("satellite0")
         satelliteAgent.connect()
 
+        if (destroyBeforeRequest) {
+            satelliteAgent.unregisterAfterReplacement(timeoutMs = 5000)
+        }
+
         val uids = setOf(TEST_PACKAGE_UID)
         updateSatelliteNetworkFallbackUids(uids)
 
-        if (destroyed) {
+        if (destroyBeforeRequest) {
+            verify(netd, never()).networkAddUidRangesParcel(any())
+        } else {
+            verify(netd).networkAddUidRangesParcel(
+                NativeUidRangeConfig(
+                    satelliteAgent.network.netId,
+                    toUidRangeStableParcels(uidRangesForUids(uids)),
+                    PREFERENCE_ORDER_SATELLITE_FALLBACK
+                )
+            )
+        }
+
+        if (destroyAfterRequest) {
             satelliteAgent.unregisterAfterReplacement(timeoutMs = 5000)
         }
 
         updateSatelliteNetworkFallbackUids(setOf())
-        if (destroyed) {
+        if (destroyBeforeRequest || destroyAfterRequest) {
             // If the network is already destroyed, networkRemoveUidRangesParcel should not be
             // called.
             verify(netd, never()).networkRemoveUidRangesParcel(any())
@@ -191,13 +208,18 @@
     }
 
     @Test
-    fun testUnregisterAfterReplacementSatisfier_destroyed() {
-        doTestUnregisterAfterReplacementSatisfier(destroyed = true)
+    fun testUnregisterAfterReplacementSatisfier_destroyBeforeRequest() {
+        doTestUnregisterAfterReplacementSatisfier(destroyBeforeRequest = true)
+    }
+
+    @Test
+    fun testUnregisterAfterReplacementSatisfier_destroyAfterRequest() {
+        doTestUnregisterAfterReplacementSatisfier(destroyAfterRequest = true)
     }
 
     @Test
     fun testUnregisterAfterReplacementSatisfier_notDestroyed() {
-        doTestUnregisterAfterReplacementSatisfier(destroyed = false)
+        doTestUnregisterAfterReplacementSatisfier()
     }
 
     private fun assertCreateMultiLayerNrisFromSatelliteNetworkPreferredUids(uids: Set<Int>) {
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 1f5ee32..9be7d11 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -181,6 +181,7 @@
         cb.eventuallyExpect<Lost> { it.network == agent.network }
     }
 
+    fun setTeardownDelayMillis(delayMillis: Int) = agent.setTeardownDelayMillis(delayMillis)
     fun unregisterAfterReplacement(timeoutMs: Int) = agent.unregisterAfterReplacement(timeoutMs)
 
     fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
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 46c25d2..ae196a6 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -341,6 +341,18 @@
             }
         }
 
+        // Need a non-zero value to avoid disarming the timer.
+        val defaultCellDataInactivityTimeoutForTest: Int = 81
+        override fun getDefaultCellularDataInactivityTimeout(): Int {
+            return defaultCellDataInactivityTimeoutForTest
+        }
+
+        // Need a non-zero value to avoid disarming the timer.
+        val defaultWifiDataInactivityTimeoutForTest: Int = 121
+        override fun getDefaultWifiDataInactivityTimeout(): Int {
+            return defaultWifiDataInactivityTimeoutForTest
+        }
+
         override fun isChangeEnabled(changeId: Long, pkg: String, user: UserHandle) =
                 changeId in enabledChangeIds
         override fun isChangeEnabled(changeId: Long, uid: Int) =
diff --git a/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java b/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
index e6aba22..b7cfaf9 100644
--- a/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
+++ b/tests/unit/java/com/android/server/ethernet/EthernetTrackerTest.java
@@ -166,9 +166,10 @@
                 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)
@@ -176,21 +177,20 @@
     }
 
     /**
-     * Test: Attempt to create a capabilties with various valid sets of capabilities/transports
+     * Test: Attempt to create a capabilities with various valid sets of capabilities/transports
      */
     @Test
     public void createNetworkCapabilities() {
-
         // Particularly common expected results
-        NetworkCapabilities defaultEthernetCleared =
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+        NetworkCapabilities defaultCapabilities =
+                makeEthernetCapabilitiesBuilder(false /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
                         .build();
 
         NetworkCapabilities ethernetClearedWithCommonCaps =
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(true /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -200,89 +200,71 @@
                         .addCapability(15)
                         .build();
 
-        // Empty capabilities and transports lists with a "please clear defaults" should
-        // yield an empty capabilities set with TRANPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "");
+        // Empty capabilities and transports should return the default capabilities set
+        // with TRANSPORT_ETHERNET
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "");
 
-        // 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, "", "");
-
-        // 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", "");
-
-        // 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", "");
+        // Adding a list of capabilities will leave exactly those capabilities with a default
+        // TRANSPORT_ETHERNET since no overrides are specified
+        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, "12,13,14,15", "");
 
         // 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", "");
+        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, "12,13,14,15,65,73", "");
+        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, "12,13,14,15,abcdefg", "");
 
         // Adding a valid override transport will remove the default TRANSPORT_ETHERNET transport
-        // and apply only the override to the capabiltities object
+        // and apply only the override to the capabilities object
         assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(false /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(0)
                         .build(),
-                true, "", "0");
+                "",
+                "0");
         assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(false /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(1)
                         .build(),
-                true, "", "1");
+                "",
+                "1");
         assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(false /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(2)
                         .build(),
-                true, "", "2");
+                "",
+                "2");
         assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(false /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addTransportType(3)
                         .build(),
-                true, "", "3");
+                "",
+                "3");
 
-        // "4" is TRANSPORT_VPN, which is unsupported. Should default back to TRANPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "4");
+        // "4" is TRANSPORT_VPN, which is unsupported. Should default back to TRANSPORT_ETHERNET
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "4");
 
         // "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");
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "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");
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "6");
 
         // Adding an invalid override transport will leave the transport as TRANSPORT_ETHERNET
-        assertParsedNetworkCapabilities(defaultEthernetCleared,true, "", "100");
-        assertParsedNetworkCapabilities(defaultEthernetCleared, true, "", "abcdefg");
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "100");
+        assertParsedNetworkCapabilities(defaultCapabilities, "", "abcdefg");
 
         // Ensure the adding of both capabilities and transports work
         assertParsedNetworkCapabilities(
-                makeEthernetCapabilitiesBuilder(true /* clearAll */)
+                makeEthernetCapabilitiesBuilder(true /* clearDefaults */)
                         .setLinkUpstreamBandwidthKbps(100000)
                         .setLinkDownstreamBandwidthKbps(100000)
                         .addCapability(12)
@@ -291,17 +273,21 @@
                         .addCapability(15)
                         .addTransportType(3)
                         .build(),
-                true, "12,13,14,15", "3");
+                "12,13,14,15",
+                "3");
 
         // Ensure order does not matter for capability list
-        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, true, "13,12,15,14", "");
+        assertParsedNetworkCapabilities(ethernetClearedWithCommonCaps, "13,12,15,14", "");
     }
 
-    private void assertParsedNetworkCapabilities(NetworkCapabilities expectedNetworkCapabilities,
-            boolean clearCapabilties, String configCapabiltiies,String configTransports) {
-        assertEquals(expectedNetworkCapabilities,
-                EthernetTracker.createNetworkCapabilities(clearCapabilties, configCapabiltiies,
-                        configTransports).build());
+    private void assertParsedNetworkCapabilities(
+            NetworkCapabilities expectedNetworkCapabilities,
+            String configCapabiltiies,
+            String configTransports) {
+        assertEquals(
+                expectedNetworkCapabilities,
+                EthernetTracker.createNetworkCapabilities(configCapabiltiies, configTransports)
+                        .build());
     }
 
     @Test
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 9cf6007..b528480 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -56,7 +56,6 @@
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.net.TrafficStats.UID_REMOVED;
 import static android.net.TrafficStats.UID_TETHERING;
-import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -73,13 +72,14 @@
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_UPDATED;
 import static com.android.server.net.NetworkStatsService.BROADCAST_NETWORK_STATS_UPDATED_RATE_LIMIT_ENABLED_FLAG;
 import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS;
-import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES;
+import static com.android.server.net.NetworkStatsService.DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES;
 import static com.android.server.net.NetworkStatsService.NETSTATS_FASTDATAINPUT_FALLBACKS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_FASTDATAINPUT_SUCCESSES_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_SUCCESSES_COUNTER_NAME;
-import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG;
+import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG;
+import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertEquals;
@@ -128,10 +128,12 @@
 import android.net.TestNetworkSpecifier;
 import android.net.TetherStatsParcel;
 import android.net.TetheringManager;
-import android.net.TrafficStats;
 import android.net.UnderlyingNetworkInfo;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.net.wifi.WifiInfo;
+import android.os.Build;
 import android.os.DropBoxManager;
 import android.os.Handler;
 import android.os.HandlerThread;
@@ -209,7 +211,6 @@
 import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Function;
 
 /**
  * Tests for {@link NetworkStatsService}.
@@ -223,6 +224,8 @@
 // NetworkStatsService is not updatable before T, so tests do not need to be backwards compatible
 @DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
 public class NetworkStatsServiceTest extends NetworkStatsBaseTest {
+    @Rule
+    public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
 
     private static final String TAG = "NetworkStatsServiceTest";
 
@@ -621,8 +624,10 @@
         }
 
         @Override
-        public boolean alwaysUseTrafficStatsRateLimitCache(Context ctx) {
-            return mFeatureFlags.getOrDefault(TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
+        public boolean isTrafficStatsServiceRateLimitCacheEnabled(Context ctx,
+                boolean isClientCacheEnabled) {
+            return !isClientCacheEnabled && mFeatureFlags.getOrDefault(
+                    TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
         }
 
         @Override
@@ -637,8 +642,21 @@
         }
 
         @Override
-        public int getTrafficStatsRateLimitCacheMaxEntries() {
-            return DEFAULT_TRAFFIC_STATS_CACHE_MAX_ENTRIES;
+        public int getTrafficStatsServiceRateLimitCacheMaxEntries() {
+            return DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES;
+        }
+
+        @Override
+        public TrafficStatsRateLimitCacheConfig getTrafficStatsRateLimitCacheClientSideConfig(
+                @NonNull Context ctx) {
+            final TrafficStatsRateLimitCacheConfig config =
+                    new TrafficStatsRateLimitCacheConfig.Builder()
+                            .setIsCacheEnabled(mFeatureFlags.getOrDefault(
+                                    TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, false))
+                            .setExpiryDurationMs(DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS)
+                            .setMaxEntries(DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES)
+                            .build();
+            return config;
         }
 
         @Override
@@ -2452,30 +2470,79 @@
         assertUidTotal(sTemplateWifi, UID_GREEN, 64L, 3L, 1024L, 8L, 0);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @Test
+    public void testGetRateLimitCacheConfig_featureDisabled() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testGetRateLimitCacheConfig_vOrAbove() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testGetRateLimitCacheConfig_belowV() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @Test
+    public void testTrafficStatsRateLimitCache_clientCacheEnabledDisableServiceCache()
+            throws Exception {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
     @Test
     public void testTrafficStatsRateLimitCache_disabledWithCompatChangeEnabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
-        doTestTrafficStatsRateLimitCache(true /* expectCached */);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
     @Test
     public void testTrafficStatsRateLimitCache_enabledWithCompatChangeEnabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
         doTestTrafficStatsRateLimitCache(true /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
     @Test
     public void testTrafficStatsRateLimitCache_disabledWithCompatChangeDisabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
         doTestTrafficStatsRateLimitCache(false /* expectCached */);
     }
 
-    @FeatureFlag(name = TRAFFICSTATS_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     @Test
-    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled() throws Exception {
+    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled_belowV()
+            throws Exception {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled_vOrAbove()
+            throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
         doTestTrafficStatsRateLimitCache(true /* expectCached */);
     }
@@ -2514,22 +2581,18 @@
     // Assert for 3 different API return values respectively.
     private void assertTrafficStatsValues(String iface, int uid, long rxBytes, long rxPackets,
             long txBytes, long txPackets) {
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessTotalStats(), type));
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(
-                        mService.getTypelessIfaceStats(iface), type)
-        );
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessUidStats(uid), type));
+        assertStatsResultEquals(mService.getTotalStats(), rxBytes, rxPackets, txBytes, txPackets);
+        assertStatsResultEquals(mService.getIfaceStats(iface), rxBytes, rxPackets, txBytes,
+                txPackets);
+        assertStatsResultEquals(mService.getUidStats(uid), rxBytes, rxPackets, txBytes, txPackets);
     }
 
-    private void assertTrafficStatsValuesThat(long rxBytes, long rxPackets, long txBytes,
-            long txPackets, Function<Integer, Long> fetcher) {
-        assertEquals(rxBytes, (long) fetcher.apply(TrafficStats.TYPE_RX_BYTES));
-        assertEquals(rxPackets, (long) fetcher.apply(TrafficStats.TYPE_RX_PACKETS));
-        assertEquals(txBytes, (long) fetcher.apply(TrafficStats.TYPE_TX_BYTES));
-        assertEquals(txPackets, (long) fetcher.apply(TrafficStats.TYPE_TX_PACKETS));
+    private void assertStatsResultEquals(StatsResult stats, long rxBytes, long rxPackets,
+            long txBytes, long txPackets) {
+        assertEquals(rxBytes, stats.rxBytes);
+        assertEquals(rxPackets, stats.rxPackets);
+        assertEquals(txBytes, stats.txBytes);
+        assertEquals(txPackets, stats.txPackets);
     }
 
     private void assertShouldRunComparison(boolean expected, boolean isDebuggable) {
diff --git a/tests/unit/jni/Android.bp b/tests/unit/jni/Android.bp
index 57a157d..1a833e1 100644
--- a/tests/unit/jni/Android.bp
+++ b/tests/unit/jni/Android.bp
@@ -42,6 +42,7 @@
     ],
     static_libs: [
         "libnet_utils_device_common_bpfjni",
+        "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 06a3986..f70b04b 100644
--- a/tests/unit/jni/android_net_frameworktests_util/onload.cpp
+++ b/tests/unit/jni/android_net_frameworktests_util/onload.cpp
@@ -24,6 +24,8 @@
 
 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_ServiceConnectivityJni(JNIEnv *env,
+                                                      char const *class_name);
 
 extern "C" jint JNI_OnLoad(JavaVM* vm, void*) {
     JNIEnv *env;
@@ -38,6 +40,10 @@
     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_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 34d67bb..40842f1 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -13,6 +13,9 @@
   "postsubmit": [
     {
       "name": "ThreadNetworkMultiDeviceTests"
+    },
+    {
+      "name": "ThreadNetworkTrelDisabledTests"
     }
   ]
 }
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/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
index e95feaf..ea30e26 100644
--- a/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
+++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
@@ -28,6 +28,7 @@
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkException;
 import android.net.thread.ThreadNetworkManager;
@@ -45,8 +46,13 @@
 import androidx.core.content.ContextCompat;
 import androidx.fragment.app.Fragment;
 
+import com.google.android.material.switchmaterial.SwitchMaterial;
+
 import java.time.Duration;
 import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Timer;
+import java.util.TimerTask;
 import java.util.concurrent.Executor;
 
 public final class ThreadNetworkSettingsFragment extends Fragment {
@@ -59,11 +65,18 @@
     private TextView mTextState;
     private TextView mTextNetworkInfo;
     private TextView mMigrateNetworkState;
+    private TextView mEphemeralKeyStateText;
+    private SwitchMaterial mNat64Switch;
     private Executor mMainExecutor;
 
     private int mDeviceRole;
     private long mPartitionId;
     private ActiveOperationalDataset mActiveDataset;
+    private int mEphemeralKeyState;
+    private String mEphemeralKey;
+    private Instant mEphemeralKeyExpiry;
+    private Timer mEphemeralKeyLifetimeTimer;
+    private ThreadConfiguration mThreadConfiguration;
 
     private static final byte[] DEFAULT_ACTIVE_DATASET_TLVS =
             base16().lowerCase()
@@ -89,6 +102,23 @@
         }
     }
 
+    private static String ephemeralKeyStateToString(int ephemeralKeyState) {
+        switch (ephemeralKeyState) {
+            case ThreadNetworkController.EPHEMERAL_KEY_DISABLED:
+                return "Disabled";
+            case ThreadNetworkController.EPHEMERAL_KEY_ENABLED:
+                return "Enabled";
+            case ThreadNetworkController.EPHEMERAL_KEY_IN_USE:
+                return "Connected";
+            default:
+                return "Unknown";
+        }
+    }
+
+    private static String booleanToEnabledOrDisabled(boolean enabled) {
+        return enabled ? "Enabled" : "Disabled";
+    }
+
     @Override
     public View onCreateView(
             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -144,6 +174,15 @@
                             ThreadNetworkSettingsFragment.this.mPartitionId = mPartitionId;
                             updateState();
                         }
+
+                        @Override
+                        public void onEphemeralKeyStateChanged(
+                                int state, String ephemeralKey, Instant expiry) {
+                            ThreadNetworkSettingsFragment.this.mEphemeralKeyState = state;
+                            ThreadNetworkSettingsFragment.this.mEphemeralKey = ephemeralKey;
+                            ThreadNetworkSettingsFragment.this.mEphemeralKeyExpiry = expiry;
+                            updateState();
+                        }
                     });
             mThreadController.registerOperationalDatasetCallback(
                     mMainExecutor,
@@ -151,10 +190,16 @@
                         this.mActiveDataset = newActiveDataset;
                         updateState();
                     });
+            mThreadController.registerConfigurationCallback(
+                    mMainExecutor, this::updateConfiguration);
         }
 
         mTextState = (TextView) view.findViewById(R.id.text_state);
         mTextNetworkInfo = (TextView) view.findViewById(R.id.text_network_info);
+        mEphemeralKeyStateText = (TextView) view.findViewById(R.id.text_ephemeral_key_state);
+        mNat64Switch = (SwitchMaterial) view.findViewById(R.id.switch_nat64);
+        mNat64Switch.setOnCheckedChangeListener(
+                (buttonView, isChecked) -> doSetNat64Enabled(isChecked));
 
         if (mThreadController == null) {
             mTextState.setText("Thread not supported!");
@@ -168,6 +213,11 @@
         ((Button) view.findViewById(R.id.button_migrate_network))
                 .setOnClickListener(v -> doMigration());
 
+        ((Button) view.findViewById(R.id.button_activate_ephemeral_key_mode))
+                .setOnClickListener(v -> doActivateEphemeralKeyMode());
+        ((Button) view.findViewById(R.id.button_deactivate_ephemeral_key_mode))
+                .setOnClickListener(v -> doDeactivateEphemeralKeyMode());
+
         updateState();
     }
 
@@ -234,12 +284,74 @@
                 });
     }
 
+    private void doActivateEphemeralKeyMode() {
+        mThreadController.activateEphemeralKeyMode(
+                Duration.ofMinutes(2),
+                mMainExecutor,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onError(ThreadNetworkException error) {
+                        Log.e(TAG, "Failed to activate ephemeral key", error);
+                    }
+
+                    @Override
+                    public void onResult(Void v) {
+                        Log.i(TAG, "Successfully activated ephemeral key mode");
+                    }
+                });
+    }
+
+    private void doDeactivateEphemeralKeyMode() {
+        mThreadController.deactivateEphemeralKeyMode(
+                mMainExecutor,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onError(ThreadNetworkException error) {
+                        Log.e(TAG, "Failed to deactivate ephemeral key", error);
+                    }
+
+                    @Override
+                    public void onResult(Void v) {
+                        Log.i(TAG, "Successfully deactivated ephemeral key mode");
+                    }
+                });
+    }
+
+    private void doSetNat64Enabled(boolean enabled) {
+        if (mThreadConfiguration == null) {
+            Log.e(TAG, "Thread configuration is not available");
+            return;
+        }
+        final ThreadConfiguration config =
+                new ThreadConfiguration.Builder(mThreadConfiguration)
+                        .setNat64Enabled(enabled)
+                        .build();
+        mThreadController.setConfiguration(
+                config,
+                mMainExecutor,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onError(ThreadNetworkException error) {
+                        Log.e(
+                                TAG,
+                                "Failed to set NAT64 " + booleanToEnabledOrDisabled(enabled),
+                                error);
+                    }
+
+                    @Override
+                    public void onResult(Void v) {
+                        Log.i(TAG, "Successfully set NAT64 " + booleanToEnabledOrDisabled(enabled));
+                    }
+                });
+    }
+
     private void updateState() {
         Log.i(
                 TAG,
                 String.format(
-                        "Updating Thread states (mDeviceRole: %s)",
-                        deviceRoleToString(mDeviceRole)));
+                        "Updating Thread states (mDeviceRole: %s, mEphemeralKeyState: %s)",
+                        deviceRoleToString(mDeviceRole),
+                        ephemeralKeyStateToString(mEphemeralKeyState)));
 
         String state =
                 String.format(
@@ -254,6 +366,30 @@
                                 ? base16().encode(mActiveDataset.getExtendedPanId())
                                 : null);
         mTextState.setText(state);
+
+        updateEphemeralKeyStatus();
+    }
+
+    private void updateEphemeralKeyStatus() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(ephemeralKeyStateToString(mEphemeralKeyState));
+        if (mEphemeralKeyState != ThreadNetworkController.EPHEMERAL_KEY_DISABLED) {
+            sb.append("\nPasscode: ");
+            sb.append(mEphemeralKey);
+            sb.append("\nRemaining lifetime: ");
+            sb.append(Instant.now().until(mEphemeralKeyExpiry, ChronoUnit.SECONDS));
+            sb.append(" seconds");
+            mEphemeralKeyLifetimeTimer = new Timer();
+            mEphemeralKeyLifetimeTimer.schedule(
+                    new TimerTask() {
+                        @Override
+                        public void run() {
+                            mMainExecutor.execute(() -> updateEphemeralKeyStatus());
+                        }
+                    },
+                    1000L /* delay in millis */);
+        }
+        mEphemeralKeyStateText.setText(sb.toString());
     }
 
     private void updateNetworkInfo(LinkProperties linProperties) {
@@ -274,4 +410,11 @@
         }
         mTextNetworkInfo.setText(sb.toString());
     }
+
+    private void updateConfiguration(ThreadConfiguration config) {
+        Log.i(TAG, "Updating configuration: " + config);
+
+        mThreadConfiguration = config;
+        mNat64Switch.setChecked(config.isNat64Enabled());
+    }
 }
diff --git a/thread/demoapp/res/layout/main_activity.xml b/thread/demoapp/res/layout/main_activity.xml
index 12072e5..d874db1 100644
--- a/thread/demoapp/res/layout/main_activity.xml
+++ b/thread/demoapp/res/layout/main_activity.xml
@@ -21,6 +21,7 @@
     android:id="@+id/drawer_layout"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
+    android:fitsSystemWindows="true"
     tools:context=".MainActivity">
 
     <LinearLayout
diff --git a/thread/demoapp/res/layout/thread_network_settings_fragment.xml b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
index cae46a3..47ce62a 100644
--- a/thread/demoapp/res/layout/thread_network_settings_fragment.xml
+++ b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
@@ -14,58 +14,99 @@
      limitations under the License.
 -->
 
-<LinearLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:padding="8dp"
-    android:orientation="vertical"
-    tools:context=".ThreadNetworkSettingsFragment" >
+<ScrollView
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="match_parent"
+  android:layout_height="match_parent">
+    <LinearLayout
+        xmlns:app="http://schemas.android.com/apk/res-auto"
+        xmlns:tools="http://schemas.android.com/tools"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="8dp"
+        android:orientation="vertical"
+        tools:context=".ThreadNetworkSettingsFragment" >
 
-    <Button android:id="@+id/button_join_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Join Network" />
-    <Button android:id="@+id/button_leave_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Leave Network" />
+        <Button android:id="@+id/button_join_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Join Network" />
+        <Button android:id="@+id/button_leave_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Leave Network" />
 
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="16dp"
-        android:textStyle="bold"
-        android:text="State" />
-    <TextView
-        android:id="@+id/text_state"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp"
-        android:typeface="monospace" />
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="16sp"
+            android:textStyle="bold"
+            android:text="State" />
+        <TextView
+            android:id="@+id/text_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12sp"
+            android:typeface="monospace" />
 
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="10dp"
-        android:textSize="16dp"
-        android:textStyle="bold"
-        android:text="Network Info" />
-    <TextView
-        android:id="@+id/text_network_info"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp" />
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:textSize="16sp"
+            android:textStyle="bold"
+            android:text="Network Info" />
+        <TextView
+            android:id="@+id/text_network_info"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12sp" />
 
-    <Button android:id="@+id/button_migrate_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Migrate Network" />
-    <TextView
-        android:id="@+id/text_migrate_network_state"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp" />
-</LinearLayout>
+        <Button android:id="@+id/button_migrate_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Migrate Network" />
+        <TextView
+            android:id="@+id/text_migrate_network_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12sp" />
+
+        <Button android:id="@+id/button_activate_ephemeral_key_mode"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Activate Ephemeral Key Mode" />
+        <Button android:id="@+id/button_deactivate_ephemeral_key_mode"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Deactivate Ephemeral Key Mode" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:textSize="16sp"
+            android:textStyle="bold"
+            android:text="Ephemeral Key State" />
+        <TextView
+            android:id="@+id/text_ephemeral_key_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12sp" />
+
+        <TextView
+            android:layout_height="wrap_content"
+            android:layout_width="wrap_content"
+            android:text="Configuration"
+            android:textSize="16sp"
+            android:textStyle="bold" />
+        <com.google.android.material.switchmaterial.SwitchMaterial
+            android:id="@+id/switch_nat64"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:checked="false"
+            android:text="NAT64" />
+
+    </LinearLayout>
+</ScrollView>
diff --git a/thread/docs/build-an-android-border-router.md b/thread/docs/build-an-android-border-router.md
index 257999b..f90a23b 100644
--- a/thread/docs/build-an-android-border-router.md
+++ b/thread/docs/build-an-android-border-router.md
@@ -169,7 +169,7 @@
     user thread_network
 ```
 
-For real RCP devices, it supports both SPI and UART interace and you can
+For real RCP devices, it supports both SPI and UART interfaces and you can
 specify the device with the schema `spinel+spi://`, `spinel+hdlc+uart://` and
 `spinel+socket://` respectively.
 
diff --git a/thread/framework/java/android/net/thread/IOutputReceiver.aidl b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
new file mode 100644
index 0000000..b6b4375
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+/** Receives the output of a Thread network operation. @hide */
+oneway interface IOutputReceiver {
+    void onOutput(in String output);
+    void onComplete();
+    void onError(int errorCode, String errorMessage);
+}
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 9d0a571..d074b01 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -23,4 +23,6 @@
     void onDeviceRoleChanged(int deviceRole);
     void onPartitionIdChanged(long partitionId);
     void onThreadEnableStateChanged(int enabledState);
+    void onEphemeralKeyStateChanged(
+            int ephemeralKeyState, @nullable String ephemeralKey, long lifetimeMillis);
 }
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index b7f68c9..e9cbb83 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -53,4 +53,7 @@
     void setConfiguration(in ThreadConfiguration config, in IOperationReceiver receiver);
     void registerConfigurationCallback(in IConfigurationReceiver receiver);
     void unregisterConfigurationCallback(in IConfigurationReceiver receiver);
+
+    void activateEphemeralKeyMode(long lifetimeMillis, in IOperationReceiver receiver);
+    void deactivateEphemeralKeyMode(in IOperationReceiver receiver);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadConfiguration.java b/thread/framework/java/android/net/thread/ThreadConfiguration.java
index 1c25535..0829265 100644
--- a/thread/framework/java/android/net/thread/ThreadConfiguration.java
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.java
@@ -44,24 +44,48 @@
 @FlaggedApi(Flags.FLAG_CONFIGURATION_ENABLED)
 @SystemApi
 public final class ThreadConfiguration implements Parcelable {
+    private final boolean mBorderRouterEnabled;
     private final boolean mNat64Enabled;
     private final boolean mDhcpv6PdEnabled;
 
     private ThreadConfiguration(Builder builder) {
-        this(builder.mNat64Enabled, builder.mDhcpv6PdEnabled);
+        this(builder.mBorderRouterEnabled, builder.mNat64Enabled, builder.mDhcpv6PdEnabled);
     }
 
-    private ThreadConfiguration(boolean nat64Enabled, boolean dhcpv6PdEnabled) {
+    private ThreadConfiguration(
+            boolean borderRouterEnabled, boolean nat64Enabled, boolean dhcpv6PdEnabled) {
+        this.mBorderRouterEnabled = borderRouterEnabled;
         this.mNat64Enabled = nat64Enabled;
         this.mDhcpv6PdEnabled = dhcpv6PdEnabled;
     }
 
+    /**
+     * Returns {@code true} if this device is operating as a Thread Border Router.
+     *
+     * <p>A Thread Border Router works on both Thread and infrastructure networks. For example, it
+     * can route packets between Thread and infrastructure networks (e.g. Wi-Fi or Ethernet), makes
+     * devices in both networks discoverable to each other, and accepts connections from external
+     * commissioner.
+     *
+     * <p>Note it costs significantly more power to operate as a Border Router, so this is typically
+     * only enabled for wired Android devices (e.g. TV or display).
+     *
+     * @hide
+     */
+    public boolean isBorderRouterEnabled() {
+        return mBorderRouterEnabled;
+    }
+
     /** Returns {@code true} if NAT64 is enabled. */
     public boolean isNat64Enabled() {
         return mNat64Enabled;
     }
 
-    /** Returns {@code true} if DHCPv6 Prefix Delegation is enabled. */
+    /**
+     * Returns {@code true} if DHCPv6 Prefix Delegation is enabled.
+     *
+     * @hide
+     */
     public boolean isDhcpv6PdEnabled() {
         return mDhcpv6PdEnabled;
     }
@@ -74,22 +98,24 @@
             return false;
         } else {
             ThreadConfiguration otherConfig = (ThreadConfiguration) other;
-            return mNat64Enabled == otherConfig.mNat64Enabled
+            return mBorderRouterEnabled == otherConfig.mBorderRouterEnabled
+                    && mNat64Enabled == otherConfig.mNat64Enabled
                     && mDhcpv6PdEnabled == otherConfig.mDhcpv6PdEnabled;
         }
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(mNat64Enabled, mDhcpv6PdEnabled);
+        return Objects.hash(mBorderRouterEnabled, mNat64Enabled, mDhcpv6PdEnabled);
     }
 
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append('{');
-        sb.append("Nat64Enabled=").append(mNat64Enabled);
-        sb.append(", Dhcpv6PdEnabled=").append(mDhcpv6PdEnabled);
+        sb.append("borderRouterEnabled=").append(mBorderRouterEnabled);
+        sb.append(", nat64Enabled=").append(mNat64Enabled);
+        sb.append(", dhcpv6PdEnabled=").append(mDhcpv6PdEnabled);
         sb.append('}');
         return sb.toString();
     }
@@ -101,6 +127,7 @@
 
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeBoolean(mBorderRouterEnabled);
         dest.writeBoolean(mNat64Enabled);
         dest.writeBoolean(mDhcpv6PdEnabled);
     }
@@ -110,6 +137,7 @@
                 @Override
                 public ThreadConfiguration createFromParcel(Parcel in) {
                     ThreadConfiguration.Builder builder = new ThreadConfiguration.Builder();
+                    builder.setBorderRouterEnabled(in.readBoolean());
                     builder.setNat64Enabled(in.readBoolean());
                     builder.setDhcpv6PdEnabled(in.readBoolean());
                     return builder.build();
@@ -126,31 +154,65 @@
      *
      * @hide
      */
+    @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+    @SystemApi
     public static final class Builder {
+        // Thread in Android V is default to a Border Router device, so the default value here needs
+        // to be {@code true} to be compatible.
+        private boolean mBorderRouterEnabled = true;
+
         private boolean mNat64Enabled = false;
         private boolean mDhcpv6PdEnabled = false;
 
-        /** Creates a new {@link Builder} object with all features disabled. */
+        /**
+         * Creates a new {@link Builder} object with all features disabled.
+         *
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         public Builder() {}
 
         /**
          * Creates a new {@link Builder} object from a {@link ThreadConfiguration} object.
          *
          * @param config the Border Router configurations to be copied
+         * @hide
          */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         public Builder(@NonNull ThreadConfiguration config) {
             Objects.requireNonNull(config);
 
+            mBorderRouterEnabled = config.mBorderRouterEnabled;
             mNat64Enabled = config.mNat64Enabled;
             mDhcpv6PdEnabled = config.mDhcpv6PdEnabled;
         }
 
         /**
+         * Enables or disables this device as a Border Router.
+         *
+         * <p>Defaults to {@code true} if this method is not called.
+         *
+         * @see ThreadConfiguration#isBorderRouterEnabled
+         * @hide
+         */
+        @NonNull
+        public Builder setBorderRouterEnabled(boolean enabled) {
+            this.mBorderRouterEnabled = enabled;
+            return this;
+        }
+
+        /**
          * Enables or disables NAT64 for the device.
          *
          * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
          * IPv4.
+         *
+         * @hide
          */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         @NonNull
         public Builder setNat64Enabled(boolean enabled) {
             this.mNat64Enabled = enabled;
@@ -162,6 +224,8 @@
          *
          * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
          * IPv6.
+         *
+         * @hide
          */
         @NonNull
         public Builder setDhcpv6PdEnabled(boolean enabled) {
@@ -169,7 +233,13 @@
             return this;
         }
 
-        /** Creates a new {@link ThreadConfiguration} object. */
+        /**
+         * Creates a new {@link ThreadConfiguration} object.
+         *
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         @NonNull
         public ThreadConfiguration build() {
             return new ThreadConfiguration(this);
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index ecaefd0..73a6bda 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -40,6 +40,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
@@ -82,6 +83,25 @@
     /** The Thread radio is being disabled. */
     public static final int STATE_DISABLING = 2;
 
+    /** The ephemeral key mode is disabled. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_DISABLED = 0;
+
+    /**
+     * The ephemeral key mode is enabled, an external commissioner candidate can use the ephemeral
+     * key to connect to this device and get Thread credential shared.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_ENABLED = 1;
+
+    /**
+     * The ephemeral key is in use. This state means there is already an active secure session
+     * connected to this device with the ephemeral key, it's not possible to use the ephemeral key
+     * for new connections in this state.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_IN_USE = 2;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({
@@ -100,6 +120,13 @@
             value = {STATE_DISABLED, STATE_ENABLED, STATE_DISABLING})
     public @interface EnabledState {}
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            prefix = {"EPHEMERAL_KEY_"},
+            value = {EPHEMERAL_KEY_DISABLED, EPHEMERAL_KEY_ENABLED, EPHEMERAL_KEY_IN_USE})
+    public @interface EphemeralKeyState {}
+
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
@@ -110,6 +137,9 @@
     @SuppressLint("MinMaxConstant")
     public static final int MAX_POWER_CHANNEL_DISABLED = Integer.MIN_VALUE;
 
+    /** The maximum lifetime of an ephemeral key. @hide */
+    @NonNull private static final Duration EPHEMERAL_KEY_LIFETIME_MAX = Duration.ofMinutes(10);
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({THREAD_VERSION_1_3})
@@ -174,6 +204,87 @@
         }
     }
 
+    /** Returns the maximum lifetime allowed when activating ephemeral key mode. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @NonNull
+    public Duration getMaxEphemeralKeyLifetime() {
+        return EPHEMERAL_KEY_LIFETIME_MAX;
+    }
+
+    /**
+     * Activates ephemeral key mode with a given {@code lifetime}. The ephemeral key is a temporary,
+     * single-use numeric code that is used for Thread Administration Sharing. After activation, the
+     * mode may expire or get deactivated, caller to this method should subscribe to the ephemeral
+     * key state updates with {@link #registerStateCallback} to get notified when the ephemeral key
+     * state changes.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The ephemeral
+     * key string contains a sequence of numeric digits 0-9 of user-input friendly length (typically
+     * 9). Subscribers to ephemeral key state updates with {@link #registerStateCallback} will be
+     * notified with a call to {@link #onEphemeralKeyStateChanged}.
+     *
+     * <p>On failure, {@link OutcomeReceiver#onError} of {@code receiver} will be invoked with a
+     * specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is 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
+     * </ul>
+     *
+     * @param lifetime valid lifetime of the generated ephemeral key, should be larger than {@link
+     *     Duration#ZERO} and at most the duration returned by {@link #getMaxEphemeralKeyLifetime}.
+     * @param executor the executor on which to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     * @throws IllegalArgumentException if the {@code lifetime} exceeds the allowed range
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void activateEphemeralKeyMode(
+            @NonNull Duration lifetime,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        if (lifetime.compareTo(Duration.ZERO) <= 0
+                || lifetime.compareTo(EPHEMERAL_KEY_LIFETIME_MAX) > 0) {
+            throw new IllegalArgumentException(
+                    "Invalid ephemeral key lifetime: the value must be in range of (0, "
+                            + EPHEMERAL_KEY_LIFETIME_MAX
+                            + "]");
+        }
+        long lifetimeMillis = lifetime.toMillis();
+        try {
+            mControllerService.activateEphemeralKeyMode(
+                    lifetimeMillis, new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Deactivates ephemeral key mode. If there is an active connection with the ephemeral key, the
+     * 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.
+     *
+     * @param executor the executor to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void deactivateEphemeralKeyMode(
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        try {
+            mControllerService.deactivateEphemeralKeyMode(
+                    new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
     /** Returns the Thread version this device is operating on. */
     @ThreadVersion
     public int getThreadVersion() {
@@ -248,6 +359,24 @@
          * @param enabledState the new Thread enabled state
          */
         default void onThreadEnableStateChanged(@EnabledState int enabledState) {}
+
+        /**
+         * The ephemeral key state has changed.
+         *
+         * @param ephemeralKeyState the ephemeral key state
+         * @param ephemeralKey the ephemeral key string which contains a sequence of numeric digits
+         *     0-9 of user-input friendly length (typically 9), or {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED} or the caller doesn't have the
+         *     permission {@link android.permission.THREAD_NETWORK_PRIVILEGED}
+         * @param expiry a timestamp of when the ephemeral key will expire or {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED}
+         */
+        @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+        @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+        default void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState,
+                @Nullable String ephemeralKey,
+                @Nullable Instant expiry) {}
     }
 
     private static final class StateCallbackProxy extends IStateCallback.Stub {
@@ -288,13 +417,34 @@
                 Binder.restoreCallingIdentity(identity);
             }
         }
+
+        @Override
+        public void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState,
+                String ephemeralKey,
+                long lifetimeMillis) {
+            final long identity = Binder.clearCallingIdentity();
+            final Instant expiry =
+                    ephemeralKeyState == EPHEMERAL_KEY_DISABLED
+                            ? null
+                            : Instant.now().plusMillis(lifetimeMillis);
+
+            try {
+                mExecutor.execute(
+                        () ->
+                                mCallback.onEphemeralKeyStateChanged(
+                                        ephemeralKeyState, ephemeralKey, expiry));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
     }
 
     /**
      * Registers a callback to be called when Thread network states are changed.
      *
-     * <p>Upon return of this method, methods of {@code callback} will be invoked immediately with
-     * existing states.
+     * <p>Upon return of this method, all methods of {@code callback} will be invoked immediately
+     * with existing states. The order of the invoked callbacks is not guaranteed.
      *
      * @param executor the executor to execute the {@code callback}
      * @param callback the callback to receive Thread network state changes
@@ -595,15 +745,19 @@
      * OutcomeReceiver#onResult} will be called, and the {@code configuration} will be applied and
      * persisted to the device; the configuration changes can be observed by {@link
      * #registerConfigurationCallback}. On failure, {@link OutcomeReceiver#onError} of {@code
-     * receiver} will be invoked with a specific error.
+     * receiver} will be invoked with a specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_UNSUPPORTED_FEATURE} the configuration enables a
+     *       feature which is not supported by the platform.
+     * </ul>
      *
      * @param configuration the configuration to set
      * @param executor the executor to execute {@code receiver}
      * @param receiver the receiver to receive result of this operation
-     * @hide
      */
-    // @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
-    // @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
+    @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+    @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
     public void setConfiguration(
             @NonNull ThreadConfiguration configuration,
             @NonNull @CallbackExecutor Executor executor,
@@ -706,9 +860,9 @@
     /**
      * Sets max power of each channel.
      *
-     * <p>This method sets the max power for the given channel. The platform sets the actual
-     * output power to be less than or equal to the {@code channelMaxPowers} and as close as
-     * possible to the {@code channelMaxPowers}.
+     * <p>This method sets the max power for the given channel. The platform sets the actual output
+     * power to be less than or equal to the {@code channelMaxPowers} and as close as possible to
+     * the {@code channelMaxPowers}.
      *
      * <p>If not set, the default max power is set by the Thread HAL service or the Thread radio
      * chip firmware.
@@ -726,13 +880,13 @@
      *     and corresponding max power. Valid channel values should be between {@link
      *     ActiveOperationalDataset#CHANNEL_MIN_24_GHZ} and {@link
      *     ActiveOperationalDataset#CHANNEL_MAX_24_GHZ}. The unit of the max power is 0.01dBm. For
-     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of
-     *     {@code channelMaxPowers} is lower than the minimum output power supported by the
-     *     platform, the output power will be set to the minimum output power supported by the
-     *     platform. If the power value of {@code channelMaxPowers} is higher than the maximum
-     *     output power supported by the platform, the output power will be set to the maximum
-     *     output power supported by the platform. If the power value of {@code channelMaxPowers}
-     *     is set to {@link #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
+     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of {@code
+     *     channelMaxPowers} is lower than the minimum output power supported by the platform, the
+     *     output power will be set to the minimum output power supported by the platform. If the
+     *     power value of {@code channelMaxPowers} is higher than the maximum output power supported
+     *     by the platform, the output power will be set to the maximum output power supported by
+     *     the platform. If the power value of {@code channelMaxPowers} is set to {@link
+     *     #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
      * @param executor the executor to execute {@code receiver}.
      * @param receiver the receiver to receive the result of this operation.
      * @throws IllegalArgumentException if the size of {@code channelMaxPowers} is smaller than 1,
@@ -754,7 +908,6 @@
 
         for (int i = 0; i < channelMaxPowers.size(); i++) {
             int channel = channelMaxPowers.keyAt(i);
-            int maxPower = channelMaxPowers.get(channel);
 
             if ((channel < ActiveOperationalDataset.CHANNEL_MIN_24_GHZ)
                     || (channel > ActiveOperationalDataset.CHANNEL_MAX_24_GHZ)) {
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index bca8b6e..b863bc2 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -81,6 +81,19 @@
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
     /**
+     * Permission allows accessing Thread network state and performing certain testing-related
+     * operations.
+     *
+     * <p>This is the same value as android.Manifest.permission.THREAD_NETWORK_TESTING. That symbol
+     * is not available on U while this feature needs to support Android U TV devices, so here is
+     * making a copy of android.Manifest.permission.THREAD_NETWORK_TESTING.
+     *
+     * @hide
+     */
+    public static final String PERMISSION_THREAD_NETWORK_TESTING =
+            "android.permission.THREAD_NETWORK_TESTING";
+
+    /**
      * This user restriction specifies if Thread network is disallowed on the device. If Thread
      * network is disallowed it cannot be turned on via Settings.
      *
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java
new file mode 100644
index 0000000..205c16e
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.net.NetworkSpecifier;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.net.module.util.HexDump;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Represents and identifies a Thread network.
+ *
+ * @hide
+ */
+public final class ThreadNetworkSpecifier extends NetworkSpecifier implements Parcelable {
+    /** The Extended PAN ID of a Thread network. */
+    @NonNull private final byte[] mExtendedPanId;
+
+    /** The Active Timestamp of a Thread network. */
+    @Nullable private final OperationalDatasetTimestamp mActiveTimestamp;
+
+    private final boolean mRouterEligibleForLeader;
+
+    private ThreadNetworkSpecifier(@NonNull Builder builder) {
+        mExtendedPanId = builder.mExtendedPanId.clone();
+        mActiveTimestamp = builder.mActiveTimestamp;
+        mRouterEligibleForLeader = builder.mRouterEligibleForLeader;
+    }
+
+    /** Returns the Extended PAN ID of the Thread network this specifier refers to. */
+    @NonNull
+    public byte[] getExtendedPanId() {
+        return mExtendedPanId.clone();
+    }
+
+    /**
+     * Returns the Active Timestamp of the Thread network this specifier refers to, or {@code null}
+     * if not specified.
+     */
+    @Nullable
+    public OperationalDatasetTimestamp getActiveTimestamp() {
+        return mActiveTimestamp;
+    }
+
+    /**
+     * Returns {@code true} if this device can be a leader during attachment when there are no
+     * nearby routers.
+     */
+    public boolean isRouterEligibleForLeader() {
+        return mRouterEligibleForLeader;
+    }
+
+    /**
+     * Returns {@code true} if both {@link #getExtendedPanId()} and {@link #getActiveTimestamp()}
+     * (if not {@code null}) of the two {@link ThreadNetworkSpecifier} objects are equal.
+     *
+     * <p>Note value of {@link #isRouterEligibleForLeader()} is expiclitly excluded because this is
+     * not part of the identifier.
+     *
+     * @hide
+     */
+    @Override
+    public boolean canBeSatisfiedBy(@Nullable NetworkSpecifier other) {
+        if (!(other instanceof ThreadNetworkSpecifier)) {
+            return false;
+        }
+        ThreadNetworkSpecifier otherSpecifier = (ThreadNetworkSpecifier) other;
+
+        if (mActiveTimestamp != null && !mActiveTimestamp.equals(otherSpecifier.mActiveTimestamp)) {
+            return false;
+        }
+
+        return Arrays.equals(mExtendedPanId, otherSpecifier.mExtendedPanId);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (!(other instanceof ThreadNetworkSpecifier)) {
+            return false;
+        } else if (this == other) {
+            return true;
+        }
+
+        ThreadNetworkSpecifier otherSpecifier = (ThreadNetworkSpecifier) other;
+
+        return Arrays.equals(mExtendedPanId, otherSpecifier.mExtendedPanId)
+                && Objects.equals(mActiveTimestamp, otherSpecifier.mActiveTimestamp)
+                && mRouterEligibleForLeader == otherSpecifier.mRouterEligibleForLeader;
+    }
+
+    @Override
+    public int hashCode() {
+        return deepHashCode(mExtendedPanId, mActiveTimestamp, mRouterEligibleForLeader);
+    }
+
+    /** An easy-to-use wrapper of {@link Arrays#deepHashCode}. */
+    private static int deepHashCode(Object... values) {
+        return Arrays.deepHashCode(values);
+    }
+
+    @Override
+    public String toString() {
+        return "ThreadNetworkSpecifier{extendedPanId="
+                + HexDump.toHexString(mExtendedPanId)
+                + ", activeTimestamp="
+                + mActiveTimestamp
+                + ", routerEligibleForLeader="
+                + mRouterEligibleForLeader
+                + "}";
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(mExtendedPanId);
+        dest.writeByteArray(mActiveTimestamp != null ? mActiveTimestamp.toTlvValue() : null);
+        dest.writeBoolean(mRouterEligibleForLeader);
+    }
+
+    public static final @NonNull Parcelable.Creator<ThreadNetworkSpecifier> CREATOR =
+            new Parcelable.Creator<ThreadNetworkSpecifier>() {
+                @Override
+                public ThreadNetworkSpecifier createFromParcel(Parcel in) {
+                    byte[] extendedPanId = in.createByteArray();
+                    byte[] activeTimestampBytes = in.createByteArray();
+                    OperationalDatasetTimestamp activeTimestamp =
+                            (activeTimestampBytes != null)
+                                    ? OperationalDatasetTimestamp.fromTlvValue(activeTimestampBytes)
+                                    : null;
+                    boolean routerEligibleForLeader = in.readBoolean();
+
+                    return new Builder(extendedPanId)
+                            .setActiveTimestamp(activeTimestamp)
+                            .setRouterEligibleForLeader(routerEligibleForLeader)
+                            .build();
+                }
+
+                @Override
+                public ThreadNetworkSpecifier[] newArray(int size) {
+                    return new ThreadNetworkSpecifier[size];
+                }
+            };
+
+    /** The builder for creating {@link ActiveOperationalDataset} objects. */
+    public static final class Builder {
+        @NonNull private final byte[] mExtendedPanId;
+        @Nullable private OperationalDatasetTimestamp mActiveTimestamp;
+        private boolean mRouterEligibleForLeader;
+
+        /**
+         * Creates a new {@link Builder} object with given Extended PAN ID.
+         *
+         * @throws IllegalArgumentException if {@code extendedPanId} is {@code null} or the length
+         *     is not {@link ActiveOperationalDataset#LENGTH_EXTENDED_PAN_ID}
+         */
+        public Builder(@NonNull byte[] extendedPanId) {
+            if (extendedPanId == null || extendedPanId.length != LENGTH_EXTENDED_PAN_ID) {
+                throw new IllegalArgumentException(
+                        "extendedPanId is null or length is not "
+                                + LENGTH_EXTENDED_PAN_ID
+                                + ": "
+                                + Arrays.toString(extendedPanId));
+            }
+            mExtendedPanId = extendedPanId.clone();
+            mRouterEligibleForLeader = false;
+        }
+
+        /**
+         * Creates a new {@link Builder} object by copying the data in the given {@code specifier}
+         * object.
+         */
+        public Builder(@NonNull ThreadNetworkSpecifier specifier) {
+            this(specifier.getExtendedPanId());
+            setActiveTimestamp(specifier.getActiveTimestamp());
+            setRouterEligibleForLeader(specifier.isRouterEligibleForLeader());
+        }
+
+        /** Sets the Active Timestamp of the Thread network. */
+        @NonNull
+        public Builder setActiveTimestamp(@Nullable OperationalDatasetTimestamp activeTimestamp) {
+            mActiveTimestamp = activeTimestamp;
+            return this;
+        }
+
+        /**
+         * Sets whether this device should be a leader during attachment when there are no nearby
+         * routers.
+         */
+        @NonNull
+        public Builder setRouterEligibleForLeader(boolean eligible) {
+            mRouterEligibleForLeader = eligible;
+            return this;
+        }
+
+        /** Creates a new {@link ThreadNetworkSpecifier} object from values set so far. */
+        @NonNull
+        public ThreadNetworkSpecifier build() {
+            return new ThreadNetworkSpecifier(this);
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/FeatureFlags.java b/thread/service/java/com/android/server/thread/FeatureFlags.java
new file mode 100644
index 0000000..29bcedd
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/FeatureFlags.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.server.thread;
+
+import com.android.net.module.util.DeviceConfigUtils;
+
+public class FeatureFlags {
+    // The namespace for Thread Network feature flags
+    private static final String NAMESPACE_THREAD_NETWORK = "thread_network";
+
+    // The prefix for TREL feature flags
+    private static final String TREL_FEATURE_PREFIX = "TrelFeature__";
+
+    // The feature flag for TREL enabled state
+    private static final String TREL_ENABLED_FLAG = TREL_FEATURE_PREFIX + "enabled";
+
+    private static boolean isFeatureEnabled(String flag, boolean defaultValue) {
+        return DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                NAMESPACE_THREAD_NETWORK, flag, defaultValue);
+    }
+
+    public static boolean isTrelEnabled() {
+        return isFeatureEnabled(TREL_ENABLED_FLAG, false);
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index 8d89e13..9697c02 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -23,6 +23,7 @@
 import android.content.Context;
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -30,6 +31,7 @@
 import android.os.CancellationSignal;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.system.Os;
 import android.text.TextUtils;
 import android.util.SparseArray;
 
@@ -66,6 +68,7 @@
 
     // TODO: b/321883491 - specify network for mDNS operations
     @Nullable private Network mNetwork;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final NsdManager mNsdManager;
     private final DnsResolver mDnsResolver;
     private final Handler mHandler;
@@ -76,17 +79,28 @@
     private final SparseArray<HostInfoListener> mHostInfoListeners = new SparseArray<>(0);
 
     @VisibleForTesting
-    public NsdPublisher(NsdManager nsdManager, DnsResolver dnsResolver, Handler handler) {
+    public NsdPublisher(
+            NsdManager nsdManager,
+            DnsResolver dnsResolver,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mNetwork = null;
         mNsdManager = nsdManager;
         mDnsResolver = dnsResolver;
         mHandler = handler;
         mExecutor = runnable -> mHandler.post(runnable);
+        mNetworkToLinkProperties = networkToLinkProperties;
     }
 
-    public static NsdPublisher newInstance(Context context, Handler handler) {
+    public static NsdPublisher newInstance(
+            Context context,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         return new NsdPublisher(
-                context.getSystemService(NsdManager.class), DnsResolver.getInstance(), handler);
+                context.getSystemService(NsdManager.class),
+                DnsResolver.getInstance(),
+                handler,
+                networkToLinkProperties);
     }
 
     // TODO: b/321883491 - NsdPublisher should be disabled when mNetwork is null
@@ -586,6 +600,14 @@
                             + ", serviceInfo: "
                             + serviceInfo);
             List<String> addresses = new ArrayList<>();
+            int interfaceIndex = 0;
+            if (mNetworkToLinkProperties.containsKey(serviceInfo.getNetwork())) {
+                interfaceIndex =
+                        Os.if_nametoindex(
+                                mNetworkToLinkProperties
+                                        .get(serviceInfo.getNetwork())
+                                        .getInterfaceName());
+            }
             for (InetAddress address : serviceInfo.getHostAddresses()) {
                 if (address instanceof Inet6Address) {
                     addresses.add(address.getHostAddress());
@@ -602,6 +624,7 @@
             try {
                 mResolveServiceCallback.onServiceResolved(
                         serviceInfo.getHostname(),
+                        interfaceIndex,
                         serviceInfo.getServiceName(),
                         serviceInfo.getServiceType(),
                         serviceInfo.getPort(),
diff --git a/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
new file mode 100644
index 0000000..aa9a05d
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
+
+import android.net.thread.IOutputReceiver;
+import android.net.thread.ThreadNetworkException;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** A {@link IOutputReceiver} wrapper which makes it easier to invoke the callbacks. */
+final class OutputReceiverWrapper {
+    private final IOutputReceiver mReceiver;
+    private final boolean mExpectOtDaemonDied;
+
+    private static final Object sPendingReceiversLock = new Object();
+
+    @GuardedBy("sPendingReceiversLock")
+    private static final Set<OutputReceiverWrapper> sPendingReceivers = new HashSet<>();
+
+    public OutputReceiverWrapper(IOutputReceiver receiver) {
+        this(receiver, false /* expectOtDaemonDied */);
+    }
+
+    /**
+     * Creates a new {@link OutputReceiverWrapper}.
+     *
+     * <p>If {@code expectOtDaemonDied} is {@code true}, it's expected that ot-daemon becomes dead
+     * before {@code receiver} is completed with {@code onComplete} and {@code onError} and {@code
+     * receiver#onComplete} will be invoked in this case.
+     */
+    public OutputReceiverWrapper(IOutputReceiver receiver, boolean expectOtDaemonDied) {
+        mReceiver = receiver;
+        mExpectOtDaemonDied = expectOtDaemonDied;
+
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.add(this);
+        }
+    }
+
+    public static void onOtDaemonDied() {
+        synchronized (sPendingReceiversLock) {
+            for (OutputReceiverWrapper receiver : sPendingReceivers) {
+                try {
+                    if (receiver.mExpectOtDaemonDied) {
+                        receiver.mReceiver.onComplete();
+                    } else {
+                        receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                    }
+                } catch (RemoteException e) {
+                    // The client is dead, do nothing
+                }
+            }
+            sPendingReceivers.clear();
+        }
+    }
+
+    public void onOutput(String output) {
+        try {
+            mReceiver.onOutput(output);
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onComplete() {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onComplete();
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onError(Throwable e) {
+        if (e instanceof ThreadNetworkException) {
+            ThreadNetworkException threadException = (ThreadNetworkException) e;
+            onError(threadException.getErrorCode(), threadException.getMessage());
+        } else if (e instanceof RemoteException) {
+            onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } else {
+            throw new AssertionError(e);
+        }
+    }
+
+    public void onError(int errorCode, String errorMessage, Object... messageArgs) {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onError(errorCode, String.format(errorMessage, messageArgs));
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 6edaae9..af16d19 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -15,9 +15,11 @@
 package com.android.server.thread;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 import static android.net.MulticastRoutingConfig.CONFIG_FORWARD_NONE;
 import static android.net.MulticastRoutingConfig.FORWARD_SELECTED;
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
+import static android.net.NetworkCapabilities.TRANSPORT_THREAD;
 import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
 import static android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID;
 import static android.net.thread.ActiveOperationalDataset.LENGTH_MESH_LOCAL_PREFIX_BITS;
@@ -26,6 +28,7 @@
 import static android.net.thread.ActiveOperationalDataset.MESH_LOCAL_PREFIX_FIRST_BYTE;
 import static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
@@ -43,6 +46,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
@@ -75,6 +79,8 @@
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
 import android.net.InetAddresses;
+import android.net.IpPrefix;
+import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.LocalNetworkConfig;
 import android.net.LocalNetworkInfo;
@@ -94,6 +100,7 @@
 import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOperationalDatasetCallback;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.IStateCallback;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.OperationalDatasetTimestamp;
@@ -116,6 +123,8 @@
 
 import com.android.connectivity.resources.R;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.IIpv4PrefixRequest;
+import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.SharedLog;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
@@ -124,6 +133,7 @@
 import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
 import com.android.server.thread.openthread.IOtDaemonCallback;
+import com.android.server.thread.openthread.IOtOutputReceiver;
 import com.android.server.thread.openthread.IOtStatusReceiver;
 import com.android.server.thread.openthread.InfraLinkState;
 import com.android.server.thread.openthread.Ipv6AddressInfo;
@@ -136,6 +146,7 @@
 
 import java.io.IOException;
 import java.net.Inet6Address;
+import java.net.InetAddress;
 import java.security.SecureRandom;
 import java.time.Clock;
 import java.time.DateTimeException;
@@ -188,10 +199,12 @@
     private final NetworkProvider mNetworkProvider;
     private final Supplier<IOtDaemon> mOtDaemonSupplier;
     private final ConnectivityManager mConnectivityManager;
+    private final RoutingCoordinatorManager mRoutingCoordinatorManager;
     private final TunInterfaceController mTunIfController;
     private final InfraInterfaceController mInfraIfController;
     private final NsdPublisher mNsdPublisher;
     private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy();
+    private final Nat64CidrController mNat64CidrController = new Nat64CidrController();
     private final ConnectivityResources mResources;
     private final Supplier<String> mCountryCodeSupplier;
     private final Map<IConfigurationReceiver, IBinder.DeathRecipient> mConfigurationReceivers =
@@ -209,13 +222,13 @@
     private NetworkRequest mUpstreamNetworkRequest;
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
-    private final HashMap<Network, String> mNetworkToInterface;
+    private ThreadNetworkCallback mThreadNetworkCallback;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
     private boolean mUserRestricted;
     private boolean mForceStopOtDaemonEnabled;
 
-    private OtDaemonConfiguration mOtDaemonConfig;
     private InfraLinkState mInfraLinkState;
 
     @VisibleForTesting
@@ -225,23 +238,27 @@
             NetworkProvider networkProvider,
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
+            RoutingCoordinatorManager routingCoordinatorManager,
             TunInterfaceController tunIfController,
             InfraInterfaceController infraIfController,
             ThreadPersistentSettings persistentSettings,
             NsdPublisher nsdPublisher,
             UserManager userManager,
             ConnectivityResources resources,
-            Supplier<String> countryCodeSupplier) {
+            Supplier<String> countryCodeSupplier,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mContext = context;
         mHandler = handler;
         mNetworkProvider = networkProvider;
         mOtDaemonSupplier = otDaemonSupplier;
         mConnectivityManager = connectivityManager;
+        mRoutingCoordinatorManager = routingCoordinatorManager;
         mTunIfController = tunIfController;
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
-        mNetworkToInterface = new HashMap<Network, String>();
-        mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
+        // TODO: networkToLinkProperties should be shared with NsdPublisher, add a test/assert to
+        // verify they are the same.
+        mNetworkToLinkProperties = networkToLinkProperties;
         mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
         mNsdPublisher = nsdPublisher;
@@ -259,20 +276,28 @@
         Handler handler = new Handler(handlerThread.getLooper());
         NetworkProvider networkProvider =
                 new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider");
+        Map<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
+        final ConnectivityManager connectivityManager =
+                context.getSystemService(ConnectivityManager.class);
+        final RoutingCoordinatorManager routingCoordinatorManager =
+                new RoutingCoordinatorManager(
+                        context, connectivityManager.getRoutingCoordinatorService());
 
         return new ThreadNetworkControllerService(
                 context,
                 handler,
                 networkProvider,
                 () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")),
-                context.getSystemService(ConnectivityManager.class),
+                connectivityManager,
+                routingCoordinatorManager,
                 new TunInterfaceController(TUN_IF_NAME),
                 new InfraInterfaceController(),
                 persistentSettings,
-                NsdPublisher.newInstance(context, handler),
+                NsdPublisher.newInstance(context, handler, networkToLinkProperties),
                 context.getSystemService(UserManager.class),
                 new ConnectivityResources(context),
-                countryCodeSupplier);
+                countryCodeSupplier,
+                networkToLinkProperties);
     }
 
     private NetworkRequest newUpstreamNetworkRequest() {
@@ -291,14 +316,6 @@
                 .build();
     }
 
-    private LocalNetworkConfig newLocalNetworkConfig() {
-        return new LocalNetworkConfig.Builder()
-                .setUpstreamMulticastRoutingConfig(mUpstreamMulticastRoutingConfig)
-                .setDownstreamMulticastRoutingConfig(mDownstreamMulticastRoutingConfig)
-                .setUpstreamSelector(mUpstreamNetworkRequest)
-                .build();
-    }
-
     private void maybeInitializeOtDaemon() {
         if (!shouldEnableThread()) {
             return;
@@ -334,14 +351,17 @@
         }
 
         otDaemon.initialize(
-                mTunIfController.getTunFd(),
                 shouldEnableThread(),
+                newOtDaemonConfig(mPersistentSettings.getConfiguration()),
+                mTunIfController.getTunFd(),
                 mNsdPublisher,
                 getMeshcopTxtAttributes(mResources.get()),
-                mOtDaemonCallbackProxy,
-                mCountryCodeSupplier.get());
+                mCountryCodeSupplier.get(),
+                FeatureFlags.isTrelEnabled(),
+                mOtDaemonCallbackProxy);
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
+        mHandler.post(mNat64CidrController::maybeUpdateNat64Cidr);
         return mOtDaemon;
     }
 
@@ -426,6 +446,7 @@
         LOG.w("OT daemon is dead, clean up...");
 
         OperationReceiverWrapper.onOtDaemonDied();
+        OutputReceiverWrapper.onOtDaemonDied();
         mOtDaemonCallbackProxy.onOtDaemonDied();
         mTunIfController.onOtDaemonDied();
         mNsdPublisher.onOtDaemonDied();
@@ -434,24 +455,32 @@
     }
 
     public void initialize() {
-        mHandler.post(
-                () -> {
-                    LOG.v(
-                            "Initializing Thread system service: Thread is "
-                                    + (shouldEnableThread() ? "enabled" : "disabled"));
-                    try {
-                        mTunIfController.createTunInterface();
-                    } catch (IOException e) {
-                        throw new IllegalStateException(
-                                "Failed to create Thread tunnel interface", e);
-                    }
-                    mConnectivityManager.registerNetworkProvider(mNetworkProvider);
-                    requestUpstreamNetwork();
-                    requestThreadNetwork();
-                    mUserRestricted = isThreadUserRestricted();
-                    registerUserRestrictionsReceiver();
-                    maybeInitializeOtDaemon();
-                });
+        mHandler.post(() -> initializeInternal());
+    }
+
+    private void initializeInternal() {
+        checkOnHandlerThread();
+
+        LOG.v(
+                "Initializing Thread system service: Thread is "
+                        + (shouldEnableThread() ? "enabled" : "disabled"));
+        try {
+            mTunIfController.createTunInterface();
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to create Thread tunnel interface", e);
+        }
+        mConnectivityManager.registerNetworkProvider(mNetworkProvider);
+        mUserRestricted = isThreadUserRestricted();
+        registerUserRestrictionsReceiver();
+
+        if (isBorderRouterMode()) {
+            requestUpstreamNetwork();
+            registerThreadNetworkCallback();
+        } else {
+            cancelRequestUpstreamNetwork();
+            unregisterThreadNetworkCallback();
+        }
+        maybeInitializeOtDaemon();
     }
 
     /**
@@ -545,22 +574,34 @@
     public void setConfiguration(
             @NonNull ThreadConfiguration configuration, @NonNull IOperationReceiver receiver) {
         enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-        mHandler.post(() -> setConfigurationInternal(configuration, receiver));
+        mHandler.post(
+                () ->
+                        setConfigurationInternal(
+                                configuration, new OperationReceiverWrapper(receiver)));
     }
 
     private void setConfigurationInternal(
             @NonNull ThreadConfiguration configuration,
-            @NonNull IOperationReceiver operationReceiver) {
+            @NonNull OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
         LOG.i("Set Thread configuration: " + configuration);
 
         final boolean changed = mPersistentSettings.putConfiguration(configuration);
-        try {
-            operationReceiver.onSuccess();
-        } catch (RemoteException e) {
-            // do nothing if the client is dead
+
+        if (changed) {
+            if (isBorderRouterMode()) {
+                requestUpstreamNetwork();
+                registerThreadNetworkCallback();
+            } else {
+                cancelRequestUpstreamNetwork();
+                unregisterThreadNetworkCallback();
+                disableBorderRouting();
+            }
         }
+
+        receiver.onSuccess();
+
         if (changed) {
             for (IConfigurationReceiver configReceiver : mConfigurationReceivers.keySet()) {
                 try {
@@ -570,7 +611,35 @@
                 }
             }
         }
-        // TODO: set the configuration at ot-daemon
+
+        try {
+            getOtDaemon()
+                    .setConfiguration(
+                            newOtDaemonConfig(configuration),
+                            new LoggingOtStatusReceiver("setConfiguration"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.setConfiguration failed. Config: " + configuration, e);
+        }
+        mNat64CidrController.maybeUpdateNat64Cidr();
+    }
+
+    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);
+        return new OtDaemonConfiguration.Builder()
+                .setBorderRouterEnabled(threadConfig.isBorderRouterEnabled())
+                .setNat64Enabled(threadConfig.isNat64Enabled())
+                .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
+                .setSrpServerWaitForBorderRoutingEnabled(srpServerWaitEnabled)
+                .setBorderRouterAutoJoinEnabled(autoJoinEnabled)
+                .build();
+    }
+
+    /** Returns {@code true} if this device is operating as a border router. */
+    private boolean isBorderRouterMode() {
+        return mPersistentSettings.getConfiguration().isBorderRouterEnabled();
     }
 
     @Override
@@ -679,7 +748,7 @@
 
     private void requestUpstreamNetwork() {
         if (mUpstreamNetworkCallback != null) {
-            throw new AssertionError("The upstream network request is already there.");
+            return;
         }
         mUpstreamNetworkCallback = new UpstreamNetworkCallback();
         mConnectivityManager.registerNetworkCallback(
@@ -688,9 +757,9 @@
 
     private void cancelRequestUpstreamNetwork() {
         if (mUpstreamNetworkCallback == null) {
-            throw new AssertionError("The upstream network request null.");
+            return;
         }
-        mNetworkToInterface.clear();
+        mNetworkToLinkProperties.clear();
         mConnectivityManager.unregisterNetworkCallback(mUpstreamNetworkCallback);
         mUpstreamNetworkCallback = null;
     }
@@ -712,20 +781,19 @@
 
         @Override
         public void onLinkPropertiesChanged(
-                @NonNull Network network, @NonNull LinkProperties linkProperties) {
+                @NonNull Network network, @NonNull LinkProperties newLinkProperties) {
             checkOnHandlerThread();
 
-            String existingIfName = mNetworkToInterface.get(network);
-            String newIfName = linkProperties.getInterfaceName();
-            if (Objects.equals(existingIfName, newIfName)) {
+            LinkProperties oldLinkProperties = mNetworkToLinkProperties.get(network);
+            if (Objects.equals(oldLinkProperties, newLinkProperties)) {
                 return;
             }
-            LOG.i("Upstream network changed: " + existingIfName + " -> " + newIfName);
-            mNetworkToInterface.put(network, newIfName);
+            LOG.i("Upstream network changed: " + oldLinkProperties + " -> " + newLinkProperties);
+            mNetworkToLinkProperties.put(network, newLinkProperties);
 
             // TODO: disable border routing if netIfName is null
             if (network.equals(mUpstreamNetwork)) {
-                enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
+                setInfraLinkState(newInfraLinkStateBuilder(newLinkProperties).build());
             }
         }
     }
@@ -741,7 +809,7 @@
         public void onLost(@NonNull Network network) {
             checkOnHandlerThread();
             LOG.i("Thread network is lost: " + network);
-            disableBorderRouting();
+            setInfraLinkState(newInfraLinkStateBuilder().build());
         }
 
         @Override
@@ -754,31 +822,43 @@
                             + ", localNetworkInfo: "
                             + localNetworkInfo
                             + "}");
-            if (localNetworkInfo.getUpstreamNetwork() == null) {
-                disableBorderRouting();
+            mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
+            if (mUpstreamNetwork == null) {
+                setInfraLinkState(newInfraLinkStateBuilder().build());
                 return;
             }
-            if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
-                mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
-                if (mNetworkToInterface.containsKey(mUpstreamNetwork)) {
-                    enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
-                }
-                mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
+            if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
+                setInfraLinkState(
+                        newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
+                                .build());
             }
+            mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
         }
     }
 
-    private void requestThreadNetwork() {
-        mConnectivityManager.registerNetworkCallback(
+    private void registerThreadNetworkCallback() {
+        if (mThreadNetworkCallback != null) {
+            return;
+        }
+
+        mThreadNetworkCallback = new ThreadNetworkCallback();
+        NetworkRequest request =
                 new NetworkRequest.Builder()
                         // clearCapabilities() is needed to remove forbidden capabilities and UID
                         // requirement.
                         .clearCapabilities()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .addTransportType(TRANSPORT_THREAD)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
-                        .build(),
-                new ThreadNetworkCallback(),
-                mHandler);
+                        .build();
+        mConnectivityManager.registerNetworkCallback(request, mThreadNetworkCallback, mHandler);
+    }
+
+    private void unregisterThreadNetworkCallback() {
+        if (mThreadNetworkCallback == null) {
+            return;
+        }
+        mConnectivityManager.unregisterNetworkCallback(mThreadNetworkCallback);
+        mThreadNetworkCallback = null;
     }
 
     /** Injects a {@link NetworkAgent} for testing. */
@@ -792,27 +872,46 @@
             return mTestNetworkAgent;
         }
 
-        final NetworkCapabilities netCaps =
+        final var netCapsBuilder =
                 new NetworkCapabilities.Builder()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                        .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
+                        .addTransportType(TRANSPORT_THREAD)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
-                        .build();
-        final NetworkScore score =
-                new NetworkScore.Builder()
-                        .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build();
+                        .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);
+        }
+
         return new NetworkAgent(
                 mContext,
                 mHandler.getLooper(),
                 LOG.getTag(),
-                netCaps,
-                mTunIfController.getLinkProperties(),
-                newLocalNetworkConfig(),
-                score,
+                netCapsBuilder.build(),
+                getTunIfLinkProperties(),
+                isBorderRouterMode() ? newLocalNetworkConfig() : null,
+                scoreBuilder.build(),
                 new NetworkAgentConfig.Builder().build(),
-                mNetworkProvider) {};
+                mNetworkProvider) {
+
+            // TODO(b/374037595): use NetworkFactory to handle dynamic network requests
+            @Override
+            public void onNetworkUnwanted() {
+                LOG.i("Thread network is unwanted by ConnectivityService");
+                if (!isBorderRouterMode()) {
+                    leave(false /* eraseDataset */, new LoggingOperationReceiver("leave"));
+                }
+            }
+        };
+    }
+
+    private LocalNetworkConfig newLocalNetworkConfig() {
+        return new LocalNetworkConfig.Builder()
+                .setUpstreamMulticastRoutingConfig(mUpstreamMulticastRoutingConfig)
+                .setDownstreamMulticastRoutingConfig(mDownstreamMulticastRoutingConfig)
+                .setUpstreamSelector(mUpstreamNetworkRequest)
+                .build();
     }
 
     private void registerThreadNetwork() {
@@ -845,6 +944,59 @@
     }
 
     @Override
+    public void activateEphemeralKeyMode(long lifetimeMillis, IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () ->
+                        activateEphemeralKeyModeInternal(
+                                lifetimeMillis, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void activateEphemeralKeyModeInternal(
+            long lifetimeMillis, OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        if (!isBorderRouterMode()) {
+            receiver.onError(
+                    ERROR_FAILED_PRECONDITION, "This device is not configured a Border Router");
+            return;
+        }
+
+        try {
+            getOtDaemon().activateEphemeralKeyMode(lifetimeMillis, newOtStatusReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.activateEphemeralKeyMode failed", e);
+            receiver.onError(e);
+        }
+    }
+
+    @Override
+    public void deactivateEphemeralKeyMode(IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () -> deactivateEphemeralKeyModeInternal(new OperationReceiverWrapper(receiver)));
+    }
+
+    private void deactivateEphemeralKeyModeInternal(OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        if (!isBorderRouterMode()) {
+            receiver.onError(
+                    ERROR_FAILED_PRECONDITION, "This device is not configured a Border Router");
+            return;
+        }
+
+        try {
+            getOtDaemon().deactivateEphemeralKeyMode(newOtStatusReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.deactivateEphemeralKeyMode failed", e);
+            receiver.onError(e);
+        }
+    }
+
+    @Override
     public void createRandomizedDataset(
             String networkName, IActiveOperationalDatasetReceiver receiver) {
         ActiveOperationalDatasetReceiverWrapper receiverWrapper =
@@ -993,7 +1145,14 @@
     @Override
     public void registerStateCallback(IStateCallback stateCallback) throws RemoteException {
         enforceAllPermissionsGranted(permission.ACCESS_NETWORK_STATE);
-        mHandler.post(() -> mOtDaemonCallbackProxy.registerStateCallback(stateCallback));
+        boolean hasThreadPrivilegedPermission =
+                (mContext.checkCallingOrSelfPermission(PERMISSION_THREAD_NETWORK_PRIVILEGED)
+                        == PERMISSION_GRANTED);
+
+        mHandler.post(
+                () ->
+                        mOtDaemonCallbackProxy.registerStateCallback(
+                                stateCallback, hasThreadPrivilegedPermission));
     }
 
     @Override
@@ -1042,6 +1201,25 @@
         };
     }
 
+    private IOtOutputReceiver newOtOutputReceiver(OutputReceiverWrapper receiver) {
+        return new IOtOutputReceiver.Stub() {
+            @Override
+            public void onOutput(String output) {
+                receiver.onOutput(output);
+            }
+
+            @Override
+            public void onComplete() {
+                receiver.onComplete();
+            }
+
+            @Override
+            public void onError(int otError, String message) {
+                receiver.onError(otErrorToAndroidError(otError), message);
+            }
+        };
+    }
+
     @ErrorCode
     private static int otErrorToAndroidError(int otError) {
         // See external/openthread/include/openthread/error.h for OT error definition
@@ -1123,16 +1301,20 @@
 
     @Override
     public void leave(@NonNull IOperationReceiver receiver) {
-        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-
-        mHandler.post(() -> leaveInternal(new OperationReceiverWrapper(receiver)));
+        leave(true /* eraseDataset */, receiver);
     }
 
-    private void leaveInternal(@NonNull OperationReceiverWrapper receiver) {
+    private void leave(boolean eraseDataset, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(() -> leaveInternal(eraseDataset, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void leaveInternal(boolean eraseDataset, @NonNull OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
         try {
-            getOtDaemon().leave(newOtStatusReceiver(receiver));
+            getOtDaemon().leave(eraseDataset, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
             LOG.e("otDaemon.leave failed", e);
             receiver.onError(e);
@@ -1228,45 +1410,69 @@
         }
     }
 
-    private void setInfraLinkState(InfraLinkState infraLinkState) {
-        if (mInfraLinkState.equals(infraLinkState)) {
+    private void setInfraLinkState(InfraLinkState newInfraLinkState) {
+        if (Objects.equals(mInfraLinkState, newInfraLinkState)) {
             return;
         }
-        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + infraLinkState);
-        mInfraLinkState = infraLinkState;
+        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
+        setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
+        setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
+        setInfraLinkDnsServers(newInfraLinkState.dnsServers);
+        mInfraLinkState = newInfraLinkState;
+    }
+
+    private void setInfraLinkInterfaceName(String newInfraLinkInterfaceName) {
+        if (Objects.equals(mInfraLinkState.interfaceName, newInfraLinkInterfaceName)) {
+            return;
+        }
         ParcelFileDescriptor infraIcmp6Socket = null;
-        if (mInfraLinkState.interfaceName != null) {
+        if (newInfraLinkInterfaceName != null) {
             try {
-                infraIcmp6Socket =
-                        mInfraIfController.createIcmp6Socket(mInfraLinkState.interfaceName);
+                infraIcmp6Socket = mInfraIfController.createIcmp6Socket(newInfraLinkInterfaceName);
             } catch (IOException e) {
                 LOG.e("Failed to create ICMPv6 socket on infra network interface", e);
             }
         }
         try {
             getOtDaemon()
-                    .setInfraLinkState(
-                            mInfraLinkState,
+                    .setInfraLinkInterfaceName(
+                            newInfraLinkInterfaceName,
                             infraIcmp6Socket,
-                            new LoggingOtStatusReceiver("setInfraLinkState"));
+                            new LoggingOtStatusReceiver("setInfraLinkInterfaceName"));
         } catch (RemoteException | ThreadNetworkException e) {
-            LOG.e("Failed to configure border router " + mOtDaemonConfig, e);
+            LOG.e("Failed to set infra link interface name " + newInfraLinkInterfaceName, e);
         }
     }
 
-    private void enableBorderRouting(String infraIfName) {
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(infraIfName).build();
-        LOG.i("Enable border routing on AIL: " + infraIfName);
-        setInfraLinkState(infraLinkState);
+    private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
+        if (Objects.equals(newNat64Prefix, mInfraLinkState.nat64Prefix)) {
+            return;
+        }
+        try {
+            getOtDaemon()
+                    .setInfraLinkNat64Prefix(
+                            newNat64Prefix, new LoggingOtStatusReceiver("setInfraLinkNat64Prefix"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("Failed to set infra link NAT64 prefix " + newNat64Prefix, e);
+        }
+    }
+
+    private void setInfraLinkDnsServers(List<String> newDnsServers) {
+        if (Objects.equals(newDnsServers, mInfraLinkState.dnsServers)) {
+            return;
+        }
+        try {
+            getOtDaemon()
+                    .setInfraLinkDnsServers(
+                            newDnsServers, new LoggingOtStatusReceiver("setInfraLinkDnsServers"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("Failed to set infra link DNS servers " + newDnsServers, e);
+        }
     }
 
     private void disableBorderRouting() {
-        mUpstreamNetwork = null;
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(null).build();
         LOG.i("Disabling border routing");
-        setInfraLinkState(infraLinkState);
+        setInfraLinkState(newInfraLinkStateBuilder().build());
     }
 
     private void handleThreadInterfaceStateChanged(boolean isUp) {
@@ -1301,9 +1507,7 @@
 
         // The OT daemon can send link property updates before the networkAgent is
         // registered
-        if (mNetworkAgent != null) {
-            mNetworkAgent.sendLinkProperties(mTunIfController.getLinkProperties());
-        }
+        maybeSendLinkProperties();
     }
 
     private void handlePrefixChanged(List<OnMeshPrefixConfig> onMeshPrefixConfigList) {
@@ -1313,8 +1517,42 @@
 
         // The OT daemon can send link property updates before the networkAgent is
         // registered
-        if (mNetworkAgent != null) {
-            mNetworkAgent.sendLinkProperties(mTunIfController.getLinkProperties());
+        maybeSendLinkProperties();
+    }
+
+    private void maybeSendLinkProperties() {
+        if (mNetworkAgent == null) {
+            return;
+        }
+        mNetworkAgent.sendLinkProperties(getTunIfLinkProperties());
+    }
+
+    private LinkProperties getTunIfLinkProperties() {
+        return mTunIfController.getLinkPropertiesWithNat64Cidr(mNat64CidrController.mNat64Cidr);
+    }
+
+    @RequiresPermission(
+            allOf = {PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING})
+    public void runOtCtlCommand(
+            @NonNull String command, boolean isInteractive, @NonNull IOutputReceiver receiver) {
+        enforceAllPermissionsGranted(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING);
+
+        mHandler.post(
+                () ->
+                        runOtCtlCommandInternal(
+                                command, isInteractive, new OutputReceiverWrapper(receiver)));
+    }
+
+    private void runOtCtlCommandInternal(
+            String command, boolean isInteractive, @NonNull OutputReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().runOtCtlCommand(command, isInteractive, newOtOutputReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.runOtCtlCommand failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
         }
     }
 
@@ -1367,13 +1605,32 @@
         return builder.build();
     }
 
-    private static OtDaemonConfiguration.Builder newOtDaemonConfigBuilder(
-            OtDaemonConfiguration config) {
-        return new OtDaemonConfiguration.Builder();
+    private static InfraLinkState.Builder newInfraLinkStateBuilder() {
+        return new InfraLinkState.Builder().setInterfaceName("");
     }
 
-    private static InfraLinkState.Builder newInfraLinkStateBuilder(InfraLinkState infraLinkState) {
-        return new InfraLinkState.Builder().setInterfaceName(infraLinkState.interfaceName);
+    private static InfraLinkState.Builder newInfraLinkStateBuilder(
+            @Nullable LinkProperties linkProperties) {
+        if (linkProperties == null) {
+            return newInfraLinkStateBuilder();
+        }
+        String nat64Prefix = null;
+        if (linkProperties.getNat64Prefix() != null) {
+            nat64Prefix = linkProperties.getNat64Prefix().toString();
+        }
+        return new InfraLinkState.Builder()
+                .setInterfaceName(linkProperties.getInterfaceName())
+                .setNat64Prefix(nat64Prefix)
+                .setDnsServers(addressesToStrings(linkProperties.getDnsServers()));
+    }
+
+    private static List<String> addressesToStrings(List<InetAddress> addresses) {
+        List<String> strings = new ArrayList<>();
+
+        for (InetAddress address : addresses) {
+            strings.add(address.getHostAddress());
+        }
+        return strings;
     }
 
     private static final class CallbackMetadata {
@@ -1384,9 +1641,13 @@
 
         final IBinder.DeathRecipient deathRecipient;
 
-        CallbackMetadata(IBinder.DeathRecipient deathRecipient) {
+        final boolean hasThreadPrivilegedPermission;
+
+        CallbackMetadata(
+                IBinder.DeathRecipient deathRecipient, boolean hasThreadPrivilegedPermission) {
             this.id = allocId();
             this.deathRecipient = deathRecipient;
+            this.hasThreadPrivilegedPermission = hasThreadPrivilegedPermission;
         }
 
         private static long allocId() {
@@ -1397,6 +1658,25 @@
         }
     }
 
+    /** An implementation of {@link IOperationReceiver} that simply logs the operation result. */
+    private static class LoggingOperationReceiver extends IOperationReceiver.Stub {
+        private final String mOperation;
+
+        LoggingOperationReceiver(String operation) {
+            mOperation = operation;
+        }
+
+        @Override
+        public void onSuccess() {
+            LOG.i("The operation " + mOperation + " succeeded");
+        }
+
+        @Override
+        public void onError(int errorCode, String errorMessage) {
+            LOG.w("The operation " + mOperation + " failed: " + errorCode + " " + errorMessage);
+        }
+    }
+
     private static class LoggingOtStatusReceiver extends IOtStatusReceiver.Stub {
         private final String mAction;
 
@@ -1428,7 +1708,8 @@
         private ActiveOperationalDataset mActiveDataset;
         private PendingOperationalDataset mPendingDataset;
 
-        public void registerStateCallback(IStateCallback callback) {
+        public void registerStateCallback(
+                IStateCallback callback, boolean hasThreadPrivilegedPermission) {
             checkOnHandlerThread();
             if (mStateCallbacks.containsKey(callback)) {
                 throw new IllegalStateException("Registering the same IStateCallback twice");
@@ -1436,7 +1717,8 @@
 
             IBinder.DeathRecipient deathRecipient =
                     () -> mHandler.post(() -> unregisterStateCallback(callback));
-            CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient);
+            CallbackMetadata callbackMetadata =
+                    new CallbackMetadata(deathRecipient, hasThreadPrivilegedPermission);
             mStateCallbacks.put(callback, callbackMetadata);
             try {
                 callback.asBinder().linkToDeath(deathRecipient, 0);
@@ -1469,7 +1751,8 @@
 
             IBinder.DeathRecipient deathRecipient =
                     () -> mHandler.post(() -> unregisterDatasetCallback(callback));
-            CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient);
+            CallbackMetadata callbackMetadata =
+                    new CallbackMetadata(deathRecipient, true /* hasThreadPrivilegedPermission */);
             mOpDatasetCallbacks.put(callback, callbackMetadata);
             try {
                 callback.asBinder().linkToDeath(deathRecipient, 0);
@@ -1516,6 +1799,7 @@
                     // do nothing if the client is dead
                 }
             }
+            mInfraLinkState = newInfraLinkStateBuilder().build();
         }
 
         private void onThreadEnabledChanged(int state, long listenerId) {
@@ -1548,16 +1832,18 @@
         }
 
         @Override
-        public void onStateChanged(OtDaemonState newState, long listenerId) {
+        public void onStateChanged(@NonNull OtDaemonState newState, long listenerId) {
             mHandler.post(() -> onStateChangedInternal(newState, listenerId));
         }
 
         private void onStateChangedInternal(OtDaemonState newState, long listenerId) {
             checkOnHandlerThread();
+
             onInterfaceStateChanged(newState.isInterfaceUp);
             onDeviceRoleChanged(newState.deviceRole, listenerId);
             onPartitionIdChanged(newState.partitionId, listenerId);
             onThreadEnabledChanged(newState.threadEnabled, listenerId);
+            onEphemeralKeyStateChanged(newState, listenerId);
             mState = newState;
 
             ActiveOperationalDataset newActiveDataset;
@@ -1633,6 +1919,43 @@
             }
         }
 
+        private void onEphemeralKeyStateChanged(OtDaemonState newState, long listenerId) {
+            checkOnHandlerThread();
+            boolean hasChange = isEphemeralKeyStateChanged(mState, newState);
+
+            for (var callbackEntry : mStateCallbacks.entrySet()) {
+                if (!hasChange && callbackEntry.getValue().id != listenerId) {
+                    continue;
+                }
+                String passcode =
+                        callbackEntry.getValue().hasThreadPrivilegedPermission
+                                ? newState.ephemeralKeyPasscode
+                                : null;
+                if (newState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) {
+                    passcode = null;
+                }
+                try {
+                    callbackEntry
+                            .getKey()
+                            .onEphemeralKeyStateChanged(
+                                    newState.ephemeralKeyState,
+                                    passcode,
+                                    newState.ephemeralKeyLifetimeMillis);
+                } catch (RemoteException ignored) {
+                    // do nothing if the client is dead
+                }
+            }
+        }
+
+        private static boolean isEphemeralKeyStateChanged(
+                OtDaemonState oldState, @NonNull OtDaemonState newState) {
+            if (oldState == null) return true;
+            if (oldState.ephemeralKeyState != newState.ephemeralKeyState) return true;
+            if (oldState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) return false;
+            return (!Objects.equals(oldState.ephemeralKeyPasscode, newState.ephemeralKeyPasscode)
+                    || oldState.ephemeralKeyLifetimeMillis != newState.ephemeralKeyLifetimeMillis);
+        }
+
         private void onActiveOperationalDatasetChanged(
                 ActiveOperationalDataset activeDataset, long listenerId) {
             checkOnHandlerThread();
@@ -1681,4 +2004,64 @@
             mHandler.post(() -> handlePrefixChanged(onMeshPrefixConfigList));
         }
     }
+
+    private final class Nat64CidrController extends IIpv4PrefixRequest.Stub {
+        private static final int RETRY_DELAY_ON_FAILURE_MILLIS = 600_000; // 10 minutes
+
+        @Nullable private LinkAddress mNat64Cidr;
+
+        @Override
+        public void onIpv4PrefixConflict(IpPrefix prefix) {
+            mHandler.post(() -> onIpv4PrefixConflictInternal(prefix));
+        }
+
+        private void onIpv4PrefixConflictInternal(IpPrefix prefix) {
+            checkOnHandlerThread();
+
+            LOG.i("Conflict on NAT64 CIDR: " + prefix);
+            maybeReleaseNat64Cidr();
+            maybeUpdateNat64Cidr();
+        }
+
+        public void maybeUpdateNat64Cidr() {
+            checkOnHandlerThread();
+
+            if (mPersistentSettings.getConfiguration().isNat64Enabled()) {
+                maybeRequestNat64Cidr();
+            } else {
+                maybeReleaseNat64Cidr();
+            }
+            try {
+                getOtDaemon()
+                        .setNat64Cidr(
+                                mNat64Cidr == null ? null : mNat64Cidr.toString(),
+                                new LoggingOtStatusReceiver("setNat64Cidr"));
+            } catch (RemoteException | ThreadNetworkException e) {
+                LOG.e("Failed to set NAT64 CIDR at otd-daemon", e);
+            }
+            maybeSendLinkProperties();
+        }
+
+        private void maybeRequestNat64Cidr() {
+            if (mNat64Cidr != null) {
+                return;
+            }
+            final LinkAddress downstreamAddress =
+                    mRoutingCoordinatorManager.requestDownstreamAddress(this);
+            if (downstreamAddress == null) {
+                mHandler.postDelayed(() -> maybeUpdateNat64Cidr(), RETRY_DELAY_ON_FAILURE_MILLIS);
+            }
+            mNat64Cidr = downstreamAddress;
+            LOG.i("Allocated NAT64 CIDR: " + mNat64Cidr);
+        }
+
+        private void maybeReleaseNat64Cidr() {
+            if (mNat64Cidr == null) {
+                return;
+            }
+            LOG.i("Released NAT64 CIDR: " + mNat64Cidr);
+            mNat64Cidr = null;
+            mRoutingCoordinatorManager.releaseDownstream(this);
+        }
+    }
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 54155ee..18ab1ca 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -19,10 +19,15 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
+import android.os.Binder;
+import android.os.Process;
 import android.text.TextUtils;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -52,6 +57,8 @@
     private static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
+    private static final Duration OT_CTL_COMMAND_TIMEOUT = Duration.ofSeconds(5);
+    private static final Duration CONFIG_TIMEOUT = Duration.ofSeconds(1);
     private static final String PERMISSION_THREAD_NETWORK_TESTING =
             "android.permission.THREAD_NETWORK_TESTING";
 
@@ -62,7 +69,8 @@
     @Nullable private PrintWriter mOutputWriter;
     @Nullable private PrintWriter mErrorWriter;
 
-    public ThreadNetworkShellCommand(
+    @VisibleForTesting
+    ThreadNetworkShellCommand(
             Context context,
             ThreadNetworkControllerService controllerService,
             ThreadNetworkCountryCode countryCode) {
@@ -77,6 +85,10 @@
         mErrorWriter = errorWriter;
     }
 
+    private static boolean isRootProcess() {
+        return Binder.getCallingUid() == Process.ROOT_UID;
+    }
+
     private PrintWriter getOutputWriter() {
         return (mOutputWriter != null) ? mOutputWriter : getOutPrintWriter();
     }
@@ -107,6 +119,10 @@
         pw.println("    Gets country code as a two-letter string");
         pw.println("  force-country-code enabled <two-letter code> | disabled ");
         pw.println("    Sets country code to <two-letter code> or left for normal value");
+        pw.println("  ot-ctl <subcommand>");
+        pw.println("    Runs ot-ctl command");
+        pw.println("  config [name] [value]");
+        pw.println("    Gets the config or sets the value for a config entry");
     }
 
     @Override
@@ -121,6 +137,8 @@
                 return setThreadEnabled(true);
             case "disable":
                 return setThreadEnabled(false);
+            case "config":
+                return handleConfigCommand();
             case "join":
                 return join();
             case "leave":
@@ -133,6 +151,8 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
+            case "ot-ctl":
+                return handleOtCtlCommand();
             default:
                 return handleDefaultCommands(cmd);
         }
@@ -248,6 +268,113 @@
         return 0;
     }
 
+    private int handleConfigCommand() {
+        ensureTestingPermission();
+
+        // Get config
+        if (peekNextArg() == null) {
+            try {
+                final ThreadConfiguration config = getConfig();
+                getOutputWriter().println("Thread configuration = " + config);
+            } catch (AssertionError e) {
+                getErrorWriter().println("Failed: " + e.getMessage());
+                return -1;
+            }
+            return 0;
+        }
+
+        // Set config
+        final String name = getNextArg();
+        final String value = getNextArg();
+        try {
+            setConfig(name, value);
+        } catch (AssertionError | IllegalArgumentException e) {
+            getErrorWriter().println(e.getMessage());
+            return -1;
+        }
+        return 0;
+    }
+
+    private ThreadConfiguration getConfig() throws AssertionError {
+        final CompletableFuture<ThreadConfiguration> future = new CompletableFuture<>();
+        mControllerService.registerConfigurationCallback(
+                new IConfigurationReceiver.Stub() {
+                    @Override
+                    public void onConfigurationChanged(ThreadConfiguration config) {
+                        future.complete(config);
+                    }
+                });
+        try {
+            return future.get(CONFIG_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            throw new AssertionError("Failed to get config within timeout", e);
+        }
+    }
+
+    private void setConfig(String name, String value)
+            throws IllegalArgumentException, AssertionError {
+        if (name == null || value == null) {
+            throw new IllegalArgumentException(
+                    "Invalid config name = " + name + ", value=" + value);
+        }
+        final ThreadConfiguration oldConfig = getConfig();
+        final ThreadConfiguration.Builder newConfigBuilder =
+                new ThreadConfiguration.Builder(oldConfig);
+        switch (name) {
+            case "br" -> newConfigBuilder.setBorderRouterEnabled(argEnabledOrDisabled(value));
+            case "nat64" -> newConfigBuilder.setNat64Enabled(argEnabledOrDisabled(value));
+            case "pd" -> newConfigBuilder.setDhcpv6PdEnabled(argEnabledOrDisabled(value));
+            default -> throw new IllegalArgumentException("Invalid config name: " + name);
+        }
+        CompletableFuture<Void> future = new CompletableFuture();
+        mControllerService.setConfiguration(newConfigBuilder.build(), newOperationReceiver(future));
+        waitForFuture(future, CONFIG_TIMEOUT, mErrorWriter);
+    }
+
+    private static final class OutputReceiver extends IOutputReceiver.Stub {
+        private final CompletableFuture<Void> future;
+        private final PrintWriter outputWriter;
+
+        public OutputReceiver(CompletableFuture<Void> future, PrintWriter outputWriter) {
+            this.future = future;
+            this.outputWriter = outputWriter;
+        }
+
+        @Override
+        public void onOutput(String output) {
+            outputWriter.print(output);
+            outputWriter.flush();
+        }
+
+        @Override
+        public void onComplete() {
+            future.complete(null);
+        }
+
+        @Override
+        public void onError(int errorCode, String errorMessage) {
+            future.completeExceptionally(new ThreadNetworkException(errorCode, errorMessage));
+        }
+    }
+
+    private int handleOtCtlCommand() {
+        ensureTestingPermission();
+
+        if (!isRootProcess()) {
+            getErrorWriter().println("No access to ot-ctl command");
+            return -1;
+        }
+
+        final String subCommand = String.join(" ", peekRemainingArgs());
+
+        CompletableFuture<Void> completeFuture = new CompletableFuture<>();
+        mControllerService.runOtCtlCommand(
+                subCommand,
+                false /* isInteractive */,
+                new OutputReceiver(completeFuture, getOutputWriter()));
+        return waitForFuture(completeFuture, OT_CTL_COMMAND_TIMEOUT, getErrorWriter());
+    }
+
     private static IOperationReceiver newOperationReceiver(CompletableFuture<Void> future) {
         return new IOperationReceiver.Stub() {
             @Override
@@ -302,6 +429,10 @@
         }
     }
 
+    private static boolean argEnabledOrDisabled(String arg) {
+        return argTrueOrFalse(arg, "enabled", "disabled");
+    }
+
     private boolean getNextArgRequiredTrueOrFalse(String trueString, String falseString) {
         String nextArg = getNextArgRequired();
         return argTrueOrFalse(nextArg, trueString, falseString);
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index fc18ef9..746b587 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -77,6 +77,13 @@
     /** 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);
@@ -197,6 +204,7 @@
         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());
         writeToStoreFile();
@@ -206,6 +214,7 @@
     /** 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))
                 .build();
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index 85a0371..520a434 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -92,8 +92,19 @@
     }
 
     /** Returns link properties of the Thread TUN interface. */
-    public LinkProperties getLinkProperties() {
-        return mLinkProperties;
+    private LinkProperties getLinkProperties() {
+        return new LinkProperties(mLinkProperties);
+    }
+
+    /** Returns link properties of the Thread TUN interface with the given NAT64 CIDR. */
+    // TODO: manage the NAT64 CIDR in the TunInterfaceController
+    public LinkProperties getLinkPropertiesWithNat64Cidr(@Nullable LinkAddress nat64Cidr) {
+        final LinkProperties lp = getLinkProperties();
+        if (nat64Cidr != null) {
+            lp.addLinkAddress(nat64Cidr);
+            lp.addRoute(getRouteForAddress(nat64Cidr));
+        }
+        return lp;
     }
 
     /**
@@ -148,6 +159,9 @@
 
     /** Adds a new address to the interface. */
     public void addAddress(LinkAddress address) {
+        if (!(address.getAddress() instanceof Inet6Address)) {
+            return;
+        }
         LOG.v("Adding address " + address + " with flags: " + address.getFlags());
 
         long preferredLifetimeSeconds;
@@ -172,7 +186,7 @@
                             (address.getExpirationTime() - SystemClock.elapsedRealtime()) / 1000L,
                             0L);
         }
-
+        // Only apply to Ipv6 address
         if (!NetlinkUtils.sendRtmNewAddressRequest(
                 Os.if_nametoindex(mIfName),
                 address.getAddress(),
@@ -190,6 +204,9 @@
 
     /** Removes an address from the interface. */
     public void removeAddress(LinkAddress address) {
+        if (!(address.getAddress() instanceof Inet6Address)) {
+            return;
+        }
         LOG.v("Removing address " + address);
 
         // Intentionally update the mLinkProperties before send netlink message because the
@@ -197,6 +214,7 @@
         // when the netlink request below fails
         mLinkProperties.removeLinkAddress(address);
         mLinkProperties.removeRoute(getRouteForAddress(address));
+        // Only apply to Ipv6 address
         if (!NetlinkUtils.sendRtmDelAddressRequest(
                 Os.if_nametoindex(mIfName),
                 (Inet6Address) address.getAddress(),
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 34aabe2..e954d3b 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -56,4 +56,14 @@
         <!-- Ignores tests introduced by guava-android-testlib -->
         <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/ThreadConfigurationTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
index 386412e..e2f0e47 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
@@ -41,6 +41,7 @@
 public final class ThreadConfigurationTest {
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
+    public final boolean mIsBorderRouterEnabled;
     public final boolean mIsNat64Enabled;
     public final boolean mIsDhcpv6PdEnabled;
 
@@ -48,14 +49,16 @@
     public static Collection configArguments() {
         return Arrays.asList(
                 new Object[][] {
-                    {false, false}, // All disabled
-                    {true, false}, // NAT64 enabled
-                    {false, true}, // DHCP6-PD enabled
-                    {true, true}, // All enabled
+                    {false, false, false}, // All disabled
+                    {false, true, false}, // NAT64 enabled
+                    {false, false, true}, // DHCP6-PD enabled
+                    {true, true, true}, // All enabled
                 });
     }
 
-    public ThreadConfigurationTest(boolean isNat64Enabled, boolean isDhcpv6PdEnabled) {
+    public ThreadConfigurationTest(
+            boolean isBorderRouterEnabled, boolean isNat64Enabled, boolean isDhcpv6PdEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
         mIsNat64Enabled = isNat64Enabled;
         mIsDhcpv6PdEnabled = isDhcpv6PdEnabled;
     }
@@ -64,6 +67,7 @@
     public void parcelable_parcelingIsLossLess() {
         ThreadConfiguration config =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
@@ -74,10 +78,12 @@
     public void builder_correctValuesAreSet() {
         ThreadConfiguration config =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
 
+        assertThat(config.isBorderRouterEnabled()).isEqualTo(mIsBorderRouterEnabled);
         assertThat(config.isNat64Enabled()).isEqualTo(mIsNat64Enabled);
         assertThat(config.isDhcpv6PdEnabled()).isEqualTo(mIsDhcpv6PdEnabled);
     }
@@ -86,6 +92,7 @@
     public void builderConstructor_configsAreEqual() {
         ThreadConfiguration config1 =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
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 c048394..2d487ca 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -27,11 +27,14 @@
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_ENABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
 import static android.net.thread.ThreadNetworkException.ERROR_ABORTED;
+import static android.net.thread.ThreadNetworkException.ERROR_BUSY;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
@@ -72,6 +75,8 @@
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.util.SparseIntArray;
 
 import androidx.annotation.NonNull;
@@ -82,6 +87,8 @@
 import com.android.net.thread.flags.Flags;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
+import kotlin.Triple;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
@@ -96,6 +103,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -134,9 +142,13 @@
                     put(VALID_CHANNEL, VALID_POWER);
                 }
             };
+    private static final Duration EPHEMERAL_KEY_LIFETIME = Duration.ofSeconds(1);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
     private ThreadNetworkController mController;
@@ -164,11 +176,13 @@
 
         setEnabledAndWait(mController, true);
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @After
     public void tearDown() throws Exception {
         dropAllPermissions();
+        setEnabledAndWait(mController, true);
         leaveAndWait(mController);
         tearDownTestNetwork();
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
@@ -183,6 +197,7 @@
             }
         }
         mConfigurationCallbacksToCleanUp.clear();
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @Test
@@ -819,6 +834,264 @@
         listener.unregisterStateCallback();
     }
 
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void getMaxEphemeralKeyLifetime_isLargerThanZero() {
+        assertThat(mController.getMaxEphemeralKeyLifetime()).isGreaterThan(Duration.ZERO);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(Duration.ZERO, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(lifetime, Runnable::run, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
+            throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> future1 = new CompletableFuture<>();
+        CompletableFuture<Void> future2 = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future1));
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future2));
+                });
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> {
+                            future2.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+                        });
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_BUSY);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
+            throws Exception {
+        setConfigurationAndWait(
+                mController,
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        CompletableFuture<Void> future = new CompletableFuture<>();
+
+        mController.activateEphemeralKeyMode(
+                Duration.ofSeconds(1), mExecutor, newOutcomeReceiver(future));
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS));
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () -> mController.deactivateEphemeralKeyMode(mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void deactivateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
+            throws Exception {
+        setConfigurationAndWait(
+                mController,
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        CompletableFuture<Void> future = new CompletableFuture<>();
+
+        mController.deactivateEphemeralKeyMode(mExecutor, newOutcomeReceiver(future));
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS));
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                THREAD_NETWORK_PRIVILEGED,
+                () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_DISABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(expiryFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
+            throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+        joinRandomizedDatasetAndWait(mController);
+        activateEphemeralKeyModeAndWait(mController);
+
+        runAsShell(
+                ACCESS_NETWORK_STATE, () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_ENABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(
+                            expiryFuture
+                                    .get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)
+                                    .isAfter(Instant.now()))
+                    .isTrue();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+        EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
+        joinRandomizedDatasetAndWait(mController);
+
+        try {
+            activateEphemeralKeyModeAndWait(mController);
+            deactivateEphemeralKeyModeAndWait(mController);
+
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+        } finally {
+            listener.unregisterStateCallback();
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+        EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
+        Triple<Integer, String, Instant> epskc1;
+        try {
+            joinRandomizedDatasetAndWait(mController);
+            activateEphemeralKeyModeAndWait(mController);
+            epskc1 = listener1.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+        } finally {
+            listener1.unregisterStateCallback();
+        }
+
+        EphemeralKeyStateListener listener2 = new EphemeralKeyStateListener(mController);
+        try {
+            Triple<Integer, String, Instant> epskc2 =
+                    listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+
+            assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
+            // allow time precision loss of a second since the value is passed via IPC
+            assertThat(epskc2.getThird()).isGreaterThan(epskc1.getThird().minusSeconds(1));
+            assertThat(epskc2.getThird()).isLessThan(epskc1.getThird().plusSeconds(1));
+        } finally {
+            listener2.unregisterStateCallback();
+        }
+    }
+
     // TODO (b/322437869): add test case to verify when Thread is in DISABLING state, any commands
     // (join/leave/scheduleMigration/setEnabled) fail with ERROR_BUSY. This is not currently tested
     // because DISABLING has very short lifecycle, it's not possible to guarantee the command can be
@@ -920,13 +1193,13 @@
         ConfigurationListener listener = new ConfigurationListener(mController);
         ThreadConfiguration config1 =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(true)
                         .setNat64Enabled(true)
-                        .setDhcpv6PdEnabled(true)
                         .build();
         ThreadConfiguration config2 =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(false)
                         .setNat64Enabled(false)
-                        .setDhcpv6PdEnabled(true)
                         .build();
 
         try {
@@ -1039,7 +1312,10 @@
 
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
-        assertThat(txtMap.get("sb")).isNotNull();
+        // Border Agent State Bitmap is 32 bits
+        assertThat(txtMap.get("sb").length).isEqualTo(4);
+        // The 12th bit (4th bit of the second byte) for ePSKc support should be set to 1.
+        assertThat(txtMap.get("sb")[2] & 8).isEqualTo(8);
     }
 
     @Test
@@ -1064,7 +1340,10 @@
         Map<String, byte[]> txtMap = resolvedService.getAttributes();
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
-        assertThat(txtMap.get("sb")).isNotNull();
+        // Border Agent State Bitmap is 32 bits
+        assertThat(txtMap.get("sb").length).isEqualTo(4);
+        // The 12th bit (4th bit of the second byte) for ePSKc support should be set to 1.
+        assertThat(txtMap.get("sb")[2] & 8).isEqualTo(8);
         assertThat(txtMap.get("id").length).isEqualTo(16);
     }
 
@@ -1274,6 +1553,71 @@
         setFuture.get(SET_CONFIGURATION_TIMEOUT_MILLIS, MILLISECONDS);
     }
 
+    private void deactivateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> clearFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.deactivateEphemeralKeyMode(
+                                mExecutor, newOutcomeReceiver(clearFuture)));
+        clearFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private void activateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private class EphemeralKeyStateListener {
+        private ArrayTrackRecord<Triple<Integer, String, Instant>> mEphemeralKeyStates =
+                new ArrayTrackRecord<>();
+        private final ArrayTrackRecord<Triple<Integer, String, Instant>>.ReadHead mReadHead =
+                mEphemeralKeyStates.newReadHead();
+        ThreadNetworkController mController;
+        StateCallback mCallback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        mEphemeralKeyStates.add(new Triple<>(state, ephemeralKey, expiry));
+                    }
+                };
+
+        EphemeralKeyStateListener(ThreadNetworkController controller) {
+            this.mController = controller;
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    THREAD_NETWORK_PRIVILEGED,
+                    () -> controller.registerStateCallback(mExecutor, mCallback));
+        }
+
+        // Expect that EphemeralKey has the expected state, and return a Triple of <state,
+        // passcode, expiry>.
+        public Triple<Integer, String, Instant> expectThreadEphemeralKeyMode(int state) {
+            Triple<Integer, String, Instant> epskc =
+                    mReadHead.poll(
+                            ENABLED_TIMEOUT_MILLIS, e -> Objects.equals(e.getFirst(), state));
+            assertThat(epskc).isNotNull();
+            return epskc;
+        }
+
+        public void unregisterStateCallback() {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+        }
+    }
+
     private CompletableFuture joinRandomizedDataset(
             ThreadNetworkController controller, String networkName) throws Exception {
         ActiveOperationalDataset activeDataset = newRandomizedDataset(networkName, controller);
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 8f082a4..798a51e 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -62,3 +62,23 @@
     ],
     compile_multilib: "both",
 }
+
+android_test {
+    name: "ThreadNetworkTrelDisabledTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTestTrelDisabled.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults",
+    ],
+    test_suites: [
+        "mts-tethering",
+        "general-tests",
+    ],
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    compile_multilib: "both",
+}
diff --git a/thread/tests/integration/AndroidTest.xml b/thread/tests/integration/AndroidTest.xml
index 8f98941..08409b4 100644
--- a/thread/tests/integration/AndroidTest.xml
+++ b/thread/tests/integration/AndroidTest.xml
@@ -48,4 +48,10 @@
     <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/AndroidTestTrelDisabled.xml b/thread/tests/integration/AndroidTestTrelDisabled.xml
new file mode 100644
index 0000000..600652a
--- /dev/null
+++ b/thread/tests/integration/AndroidTestTrelDisabled.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    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.
+ -->
+
+<configuration description="Config for Thread integration tests with TREL disabled">
+    <option name="test-tag" value="ThreadNetworkTrelDisabledTests" />
+    <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="ThreadNetworkTrelDisabledTests.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>
+
+    <!-- Disable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=false"/>
+    </target_preparer>
+</configuration>
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 103282a..875a4ad 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -16,9 +16,10 @@
 
 package android.net.thread;
 
-import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
 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.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv4Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
@@ -26,15 +27,16 @@
 import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.isTo;
 import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr;
+import static android.net.thread.utils.IntegrationTestUtils.leaveNetworkAndDisableThread;
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
 import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
 import static android.net.thread.utils.IntegrationTestUtils.sendUdpMessage;
+import static android.net.thread.utils.IntegrationTestUtils.stopOtDaemon;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 import static android.system.OsConstants.ICMP_ECHO;
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
-import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -48,10 +50,12 @@
 import android.content.Context;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.RouteInfo;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
-import android.net.thread.utils.IntegrationTestUtils;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.TestTunNetworkUtils;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
@@ -65,18 +69,23 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
+import org.junit.AfterClass;
 import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.IOException;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
+import java.nio.ByteBuffer;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
@@ -98,35 +107,43 @@
             (Inet6Address) parseNumericAddress("ff03::1234");
     private static final Inet4Address IPV4_SERVER_ADDR =
             (Inet4Address) parseNumericAddress("8.8.8.8");
-    private static final String NAT64_CIDR = "192.168.255.0/24";
+    private static final IpPrefix DHCP6_PD_PREFIX = new IpPrefix("2001:db8::/64");
+    private static final IpPrefix AIL_NAT64_PREFIX = new IpPrefix("2001:db8:1234::/96");
+    private static final Inet6Address AIL_NAT64_SYNTHESIZED_SERVER_ADDR =
+            (Inet6Address) parseNumericAddress("2001:db8:1234::8.8.8.8");
+    private static final Duration UPDATE_NAT64_PREFIX_TIMEOUT = Duration.ofSeconds(10);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private final ThreadNetworkControllerWrapper mController =
             ThreadNetworkControllerWrapper.newInstance(mContext);
-    private OtDaemonController mOtCtl;
+    private final OtDaemonController mOtCtl = new OtDaemonController();
     private HandlerThread mHandlerThread;
     private Handler mHandler;
     private TestNetworkTracker mInfraNetworkTracker;
     private List<FullThreadDevice> mFtds;
-    private TapPacketReader mInfraNetworkReader;
+    private PollPacketReader mInfraNetworkReader;
     private InfraNetworkDevice mInfraDevice;
 
+    @BeforeClass
+    public static void beforeClass() throws Exception {
+        enableThreadAndJoinNetwork(DEFAULT_DATASET);
+    }
+
+    @AfterClass
+    public static void afterClass() throws Exception {
+        leaveNetworkAndDisableThread();
+    }
+
     @Before
     public void setUp() throws Exception {
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        mOtCtl = new OtDaemonController();
-        mOtCtl.factoryReset();
-
         mHandlerThread = new HandlerThread(getClass().getSimpleName());
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
         mFtds = new ArrayList<>();
 
         setUpInfraNetwork();
-        mController.setEnabledAndWait(true);
-        mController.joinAndWait(DEFAULT_DATASET);
 
         // Creates a infra network device.
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
@@ -141,8 +158,7 @@
     @After
     public void tearDown() throws Exception {
         mController.setTestNetworkAsUpstreamAndWait(null);
-        mController.leaveAndWait();
-        tearDownInfraNetwork();
+        TestTunNetworkUtils.tearDownAllInfraNetworks();
 
         mHandlerThread.quitSafely();
         mHandlerThread.join();
@@ -209,19 +225,13 @@
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
         Inet6Address ftdOmr = ftd.getOmrAddress();
-        // Create a new infra network and let Thread prefer it
-        TestNetworkTracker oldInfraNetworkTracker = mInfraNetworkTracker;
-        try {
-            setUpInfraNetwork();
-            mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
-            startInfraDeviceAndWaitForOnLinkAddr();
+        setUpInfraNetwork();
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        startInfraDeviceAndWaitForOnLinkAddr();
 
-            mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
 
-            assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
-        } finally {
-            runAsShell(MANAGE_TEST_NETWORKS, () -> oldInfraNetworkTracker.teardown());
-        }
+        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
     }
 
     @Test
@@ -266,6 +276,28 @@
     }
 
     @Test
+    public void unicastRouting_otDaemonRestarts_borderRoutingWorks() throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
+
+        stopOtDaemon();
+        ftd.waitForStateAnyOf(List.of("leader", "router", "child"), Duration.ofSeconds(40));
+
+        startInfraDeviceAndWaitForOnLinkAddr();
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
     @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
@@ -576,8 +608,6 @@
         subscribeMulticastAddressAndWait(ftd, GROUP_ADDR_SCOPE_5);
         Inet6Address ftdOmr = ftd.getOmrAddress();
 
-        // Destroy infra link and re-create
-        tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         startInfraDeviceAndWaitForOnLinkAddr();
@@ -603,8 +633,6 @@
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
         Inet6Address ftdOmr = ftd.getOmrAddress();
 
-        // Destroy infra link and re-create
-        tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         startInfraDeviceAndWaitForOnLinkAddr();
@@ -617,30 +645,73 @@
     }
 
     @Test
-    public void nat64_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded() throws Exception {
+    public void nat64_threadDevicePingIpv4InfraDevice_outboundPacketIsForwardedAndReplyIsReceived()
+            throws Exception {
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
-        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
-        mOtCtl.setNat64Cidr(NAT64_CIDR);
-        mOtCtl.setNat64Enabled(true);
-        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), Duration.ofSeconds(10));
+        mController.setNat64EnabledAndWait(true);
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
+        Thread echoReplyThread = new Thread(() -> respondToEchoRequestOnce(IPV4_SERVER_ADDR));
+        echoReplyThread.start();
+
+        assertThat(ftd.ping(IPV4_SERVER_ADDR, 1 /* count */)).isEqualTo(1);
+
+        echoReplyThread.join();
+    }
+
+    private void respondToEchoRequestOnce(Inet4Address dstAddress) {
+        byte[] echoRequest = pollForIcmpPacketOnInfraNetwork(ICMP_ECHO, null, dstAddress);
+        assertNotNull(echoRequest);
+        try {
+            mInfraNetworkReader.sendResponse(buildIcmpv4EchoReply(ByteBuffer.wrap(echoRequest)));
+        } catch (IOException ignored) {
+        }
+    }
+
+    @Ignore("TODO: b/376573921 - Enable when it's not flaky at all")
+    @Test
+    public void nat64_withAilNat64Prefix_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded()
+            throws Exception {
+        TestTunNetworkUtils.tearDownInfraNetwork(mInfraNetworkTracker);
+        LinkProperties lp = new LinkProperties();
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("0.0.0.0/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("::/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.setNat64Prefix(AIL_NAT64_PREFIX);
+        mInfraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(mContext, mController, lp);
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        FullThreadDevice ftd = mFtds.get(0);
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
+        mController.setNat64EnabledAndWait(true);
+        mOtCtl.addPrefixInNetworkData(DHCP6_PD_PREFIX, "paros", "med");
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
         ftd.ping(IPV4_SERVER_ADDR);
 
-        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMP_ECHO, null, IPV4_SERVER_ADDR));
+        assertNotNull(
+                pollForIcmpPacketOnInfraNetwork(
+                        ICMPV6_ECHO_REQUEST_TYPE, null, AIL_NAT64_SYNTHESIZED_SERVER_ADDR));
     }
 
     private void setUpInfraNetwork() throws Exception {
-        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController);
-    }
-
-    private void tearDownInfraNetwork() {
-        IntegrationTestUtils.tearDownInfraNetwork(mInfraNetworkTracker);
+        mInfraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(mContext, mController);
     }
 
     private void startInfraDeviceAndWaitForOnLinkAddr() {
         mInfraDevice =
-                IntegrationTestUtils.startInfraDeviceAndWaitForOnLinkAddr(mInfraNetworkReader);
+                TestTunNetworkUtils.startInfraDeviceAndWaitForOnLinkAddr(mInfraNetworkReader);
     }
 
     private void assertInfraLinkMemberOfGroup(Inet6Address address) throws Exception {
diff --git a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
new file mode 100644
index 0000000..3c9aa07
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread
+
+import android.content.Context
+import android.net.DnsResolver.CLASS_IN
+import android.net.DnsResolver.TYPE_A
+import android.net.DnsResolver.TYPE_AAAA
+import android.net.InetAddresses.parseNumericAddress
+import android.net.thread.utils.FullThreadDevice
+import android.net.thread.utils.InfraNetworkDevice
+import android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET
+import android.net.thread.utils.IntegrationTestUtils.enableThreadAndJoinNetwork
+import android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr
+import android.net.thread.utils.IntegrationTestUtils.leaveNetworkAndDisableThread
+import android.net.thread.utils.IntegrationTestUtils.newPacketReader
+import android.net.thread.utils.IntegrationTestUtils.waitFor
+import android.net.thread.utils.OtDaemonController
+import android.net.thread.utils.TestDnsServer
+import android.net.thread.utils.TestTunNetworkUtils
+import android.net.thread.utils.TestUdpEchoServer
+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.os.Handler
+import android.os.HandlerThread
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.DnsPacket.ANSECTION
+import com.android.testutils.PollPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.google.common.truth.Truth.assertThat
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.time.Duration
+import org.junit.After
+import org.junit.AfterClass
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Integration test cases for Thread Internet Access features. */
+@RunWith(AndroidJUnit4::class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
+@LargeTest
+class InternetAccessTest {
+    companion object {
+        private val TAG = BorderRoutingTest::class.java.simpleName
+        private val NUM_FTD = 1
+        private val DNS_SERVER_ADDR = parseNumericAddress("8.8.8.8") as Inet4Address
+        private val UDP_ECHO_SERVER_ADDRESS =
+            InetSocketAddress(parseNumericAddress("1.2.3.4"), 12345)
+        private val ANSWER_RECORDS =
+            listOf(
+                DnsPacket.DnsRecord.makeAOrAAAARecord(
+                    ANSECTION,
+                    "google.com",
+                    CLASS_IN,
+                    30 /* ttl */,
+                    parseNumericAddress("1.2.3.4"),
+                ),
+                DnsPacket.DnsRecord.makeAOrAAAARecord(
+                    ANSECTION,
+                    "google.com",
+                    CLASS_IN,
+                    30 /* ttl */,
+                    parseNumericAddress("2001::234"),
+                ),
+            )
+
+        @BeforeClass
+        @JvmStatic
+        fun beforeClass() {
+            enableThreadAndJoinNetwork(DEFAULT_DATASET)
+        }
+
+        @AfterClass
+        @JvmStatic
+        fun afterClass() {
+            leaveNetworkAndDisableThread()
+        }
+    }
+
+    @get:Rule val threadRule = ThreadFeatureCheckerRule()
+
+    private val context: Context = ApplicationProvider.getApplicationContext()
+    private val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context))
+    private lateinit var otCtl: OtDaemonController
+    private lateinit var handlerThread: HandlerThread
+    private lateinit var handler: Handler
+    private lateinit var infraNetworkTracker: TestNetworkTracker
+    private lateinit var ftds: ArrayList<FullThreadDevice>
+    private lateinit var infraNetworkReader: PollPacketReader
+    private lateinit var infraDevice: InfraNetworkDevice
+    private lateinit var dnsServer: TestDnsServer
+    private lateinit var udpEchoServer: TestUdpEchoServer
+
+    @Before
+    @Throws(Exception::class)
+    fun setUp() {
+        otCtl = OtDaemonController()
+
+        handlerThread = HandlerThread(javaClass.simpleName)
+        handlerThread.start()
+        handler = Handler(handlerThread.looper)
+        ftds = ArrayList()
+
+        infraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(context, controller)
+
+        // Create an infra network device.
+        infraNetworkReader = newPacketReader(infraNetworkTracker.testIface, handler)
+        infraDevice = TestTunNetworkUtils.startInfraDeviceAndWaitForOnLinkAddr(infraNetworkReader)
+
+        // Create a DNS server
+        dnsServer = TestDnsServer(infraNetworkReader, DNS_SERVER_ADDR, ANSWER_RECORDS)
+
+        // Create a UDP echo server
+        udpEchoServer = TestUdpEchoServer(infraNetworkReader, UDP_ECHO_SERVER_ADDRESS)
+
+        // Create Ftds
+        for (i in 0 until NUM_FTD) {
+            ftds.add(FullThreadDevice(15 + i /* node ID */))
+        }
+    }
+
+    @After
+    @Throws(Exception::class)
+    fun tearDown() {
+        controller.setTestNetworkAsUpstreamAndWait(null)
+        TestTunNetworkUtils.tearDownAllInfraNetworks()
+
+        dnsServer.stop()
+        udpEchoServer.stop()
+
+        handlerThread.quitSafely()
+        handlerThread.join()
+
+        ftds.forEach { it.destroy() }
+        ftds.clear()
+    }
+
+    @Test
+    fun nat64Enabled_threadDeviceResolvesHost_hostIsResolved() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        dnsServer.start()
+
+        val ipv4Addresses =
+            ftd.resolveHost("google.com", TYPE_A).map { extractIpv4AddressFromMappedAddress(it) }
+        assertThat(ipv4Addresses).isEqualTo(listOf(parseNumericAddress("1.2.3.4")))
+        val ipv6Addresses = ftd.resolveHost("google.com", TYPE_AAAA)
+        assertThat(ipv6Addresses).isEqualTo(listOf(parseNumericAddress("2001::234")))
+    }
+
+    @Test
+    fun nat64Disabled_threadDeviceResolvesHost_hostIsNotResolved() {
+        controller.setNat64EnabledAndWait(false)
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        dnsServer.start()
+
+        assertThat(ftd.resolveHost("google.com", TYPE_A)).isEmpty()
+        assertThat(ftd.resolveHost("google.com", TYPE_AAAA)).isEmpty()
+    }
+
+    @Test
+    fun nat64Enabled_threadDeviceSendsUdpToEchoServer_replyIsReceived() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        udpEchoServer.start()
+
+        ftd.udpOpen()
+        ftd.udpSend("Hello,Thread", UDP_ECHO_SERVER_ADDRESS.address, UDP_ECHO_SERVER_ADDRESS.port)
+        val reply = ftd.udpReceive()
+        assertThat(reply).isEqualTo("Hello,Thread")
+    }
+
+    @Test
+    fun nat64Enabled_afterInfraNetworkSwitch_threadDeviceSendsUdpToEchoServer_replyIsReceived() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        infraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(context, controller)
+        infraNetworkReader = newPacketReader(infraNetworkTracker.testIface, handler)
+        udpEchoServer = TestUdpEchoServer(infraNetworkReader, UDP_ECHO_SERVER_ADDRESS)
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        udpEchoServer.start()
+
+        ftd.udpOpen()
+        ftd.udpSend("Hello,Thread", UDP_ECHO_SERVER_ADDRESS.address, UDP_ECHO_SERVER_ADDRESS.port)
+        val reply = ftd.udpReceive()
+        assertThat(reply).isEqualTo("Hello,Thread")
+    }
+
+    private fun extractIpv4AddressFromMappedAddress(address: InetAddress): Inet4Address {
+        return InetAddress.getByAddress(address.address.slice(12 until 16).toByteArray())
+            as Inet4Address
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 2afca5f..6c2a9bb 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -30,6 +30,8 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -50,6 +52,9 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.HexDump;
+
 import com.google.common.truth.Correspondence;
 
 import org.junit.After;
@@ -64,6 +69,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Random;
 import java.util.concurrent.CompletableFuture;
@@ -441,6 +447,40 @@
                 .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(3));
     }
 
+    @Test
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
+    // are decoupled from this test.
+    public void trelFeatureFlagEnabled_trelServicePublished() throws Exception {
+        assumeTrue(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_trel._udp");
+        assertThat(discoveredService).isNotNull();
+        // Resolve service with the current TREL port, otherwise it may return stale service from
+        // a previous infra link setup.
+        NsdServiceInfo trelService =
+                resolveServiceUntil(
+                        mNsdManager, discoveredService, s -> s.getPort() == mOtCtl.getTrelPort());
+
+        Map<String, byte[]> txtMap = trelService.getAttributes();
+        assertThat(HexDump.toHexString(txtMap.get("xa")).toLowerCase(Locale.ROOT))
+                .isEqualTo(mOtCtl.getExtendedAddr().toLowerCase(Locale.ROOT));
+        assertThat(HexDump.toHexString(txtMap.get("xp")).toLowerCase(Locale.ROOT))
+                .isEqualTo(mOtCtl.getExtendedPanId().toLowerCase(Locale.ROOT));
+    }
+
+    @Test
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
+    // are decoupled from this test.
+    public void trelFeatureFlagDisabled_trelServiceNotPublished() throws Exception {
+        assumeFalse(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_trel._udp"));
+    }
+
     private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
             throws InterruptedException, ExecutionException, TimeoutException {
         mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, listener);
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 61b6eac..d41550b 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -16,36 +16,47 @@
 
 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.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.ThreadNetworkControllerWrapper.JOIN_TIMEOUT;
 
 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 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.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
+import android.net.thread.utils.ThreadStateListener;
 import android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -66,6 +77,7 @@
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
@@ -83,6 +95,8 @@
     // 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);
+
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -93,6 +107,8 @@
                                     + "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");
@@ -124,8 +140,11 @@
 
     @After
     public void tearDown() throws Exception {
+        ThreadStateListener.stopAllListeners();
+
         mController.setTestNetworkAsUpstreamAndWait(null);
         mController.leaveAndWait();
+        mController.setConfigurationAndWait(DEFAULT_CONFIG);
 
         mFtd.destroy();
         mExecutor.shutdownNow();
@@ -253,6 +272,20 @@
     }
 
     @Test
+    public void joinNetwork_joinTheSameNetworkTwice_neverDetached() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mController.waitForRole(DEVICE_ROLE_LEADER, JOIN_TIMEOUT);
+
+        var listener = ThreadStateListener.startListener(mController.get());
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        assertThat(
+                        listener.pollForAnyRoleOf(
+                                List.of(DEVICE_ROLE_DETACHED, DEVICE_ROLE_STOPPED), JOIN_TIMEOUT))
+                .isNull();
+    }
+
+    @Test
     public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
@@ -327,6 +360,44 @@
                 .isFalse();
     }
 
+    @Test
+    public void setConfiguration_disableBorderRouter_noBrfunctionsEnabled() throws Exception {
+        NetworkRequest request =
+                new NetworkRequest.Builder()
+                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .build();
+        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
+    }
+
+    private NetworkCapabilities registerNetworkCallbackAndWait(NetworkRequest request)
+            throws Exception {
+        CompletableFuture<Network> networkFuture = new CompletableFuture<>();
+        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+        ConnectivityManager.NetworkCallback callback =
+                new ConnectivityManager.NetworkCallback() {
+                    @Override
+                    public void onAvailable(Network network) {
+                        networkFuture.complete(network);
+                    }
+                };
+
+        runAsShell(ACCESS_NETWORK_STATE, () -> cm.registerNetworkCallback(request, callback));
+
+        assertThat(networkFuture.get(NETWORK_CALLBACK_TIMEOUT.getSeconds(), SECONDS)).isNotNull();
+        return runAsShell(
+                ACCESS_NETWORK_STATE, () -> cm.getNetworkCapabilities(networkFuture.get()));
+    }
+
     // TODO (b/323300829): add more tests for integration with linux platform and
     // ConnectivityService
 
@@ -341,6 +412,14 @@
         ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
     }
 
+    /** Starts a Thread FTD device as a leader. */
+    private void startFtdLeader(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("leader"), Duration.ofSeconds(8));
+    }
+
     /**
      * Starts a UDP echo server and replies to the first UDP message.
      *
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 8835f40..2f0ab34 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,14 +19,19 @@
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_CONFIG;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 
 import android.content.Context;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
@@ -41,6 +46,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.net.Inet6Address;
+import java.time.Duration;
+import java.util.List;
 import java.util.concurrent.ExecutionException;
 
 /** Integration tests for {@link ThreadNetworkShellCommand}. */
@@ -53,15 +61,26 @@
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private final ThreadNetworkControllerWrapper mController =
             ThreadNetworkControllerWrapper.newInstance(mContext);
+    private final OtDaemonController mOtCtl = new OtDaemonController();
+    private FullThreadDevice mFtd;
 
     @Before
-    public void setUp() {
+    public void setUp() throws Exception {
+        // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
+        // results in timeout error.
+        // A future fix will provide proper support for factoryreset, allowing us to replace the
+        // legacy "ot-ctl".
+        mOtCtl.factoryReset();
+
+        mFtd = new FullThreadDevice(10 /* nodeId */);
         ensureThreadEnabled();
     }
 
     @After
-    public void tearDown() {
+    public void tearDown() throws Exception {
+        mFtd.destroy();
         ensureThreadEnabled();
+        mController.setConfigurationAndWait(DEFAULT_CONFIG);
     }
 
     private static void ensureThreadEnabled() {
@@ -69,6 +88,13 @@
         runThreadCommand("enable");
     }
 
+    private static void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+    }
+
     @Test
     public void enable_threadStateIsEnabled() throws Exception {
         runThreadCommand("enable");
@@ -123,6 +149,59 @@
         assertThat(result).contains("Thread country code = CN");
     }
 
+    @Test
+    public void handleOtCtlCommand_enableIfconfig_getIfconfigReturnsUP() {
+        runThreadCommand("ot-ctl ifconfig up");
+
+        final String result = runThreadCommand("ot-ctl ifconfig");
+
+        assertThat(result).isEqualTo("up\r\nDone\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_disableIfconfig_startThreadFailsWithInvalidState() {
+        runThreadCommand("ot-ctl ifconfig down");
+
+        final String result = runThreadCommand("ot-ctl thread start");
+
+        assertThat(result).isEqualTo("Error 13: InvalidState\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        startFtdChild(mFtd, DEFAULT_DATASET);
+        final Inet6Address ftdMlEid = mFtd.getMlEid();
+        assertNotNull(ftdMlEid);
+
+        final String result = runThreadCommand("ot-ctl ping " + ftdMlEid.getHostAddress());
+
+        assertThat(result).contains("1 packets transmitted, 1 packets received");
+        assertThat(result).contains("Packet loss = 0.0%");
+        assertThat(result).endsWith("Done\r\n");
+    }
+
+    @Test
+    public void config_getConfig_expectedValueIsPrinted() throws Exception {
+        ThreadConfiguration config =
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
+        mController.setConfigurationAndWait(config);
+
+        final String result = runThreadCommand("config");
+
+        assertThat(result).contains("nat64Enabled=true");
+    }
+
+    @Test
+    public void config_setConfig_expectedValueIsSet() throws Exception {
+        ThreadConfiguration config = new ThreadConfiguration.Builder().build();
+        mController.setConfigurationAndWait(config);
+
+        runThreadCommand("config nat64 enabled");
+
+        assertThat(mController.getConfiguration().isNat64Enabled()).isTrue();
+    }
+
     private static String runThreadCommand(String cmd) {
         return runShellCommandOrThrow("cmd thread_network " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 083a841..209eed6 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -15,6 +15,8 @@
  */
 package android.net.thread.utils;
 
+import static android.net.DnsResolver.TYPE_A;
+import static android.net.DnsResolver.TYPE_AAAA;
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 
@@ -232,8 +234,8 @@
         return matcher.group(4);
     }
 
-    /** Sends a UDP message to given IPv6 address and port. */
-    public void udpSend(String message, Inet6Address serverAddr, int serverPort) {
+    /** Sends a UDP message to given IP address and port. */
+    public void udpSend(String message, InetAddress serverAddr, int serverPort) {
         executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message);
     }
 
@@ -354,6 +356,31 @@
         executeCommand("dns config " + address);
     }
 
+    /** Resolves the {@code queryType} record of the {@code hostname} via DNS. */
+    public List<InetAddress> resolveHost(String hostname, int queryType) {
+        // CLI output:
+        // DNS response for hostname.com. - fd12::abc1 TTL:50 fd12::abc2 TTL:50 fd12::abc3 TTL:50
+
+        String command;
+        switch (queryType) {
+            case TYPE_A -> command = "resolve4";
+            case TYPE_AAAA -> command = "resolve";
+            default -> throw new IllegalArgumentException("Invalid query type: " + queryType);
+        }
+        final List<InetAddress> addresses = new ArrayList<>();
+        String line;
+        try {
+            line = executeCommand("dns " + command + " " + hostname).get(0);
+        } catch (IllegalStateException e) {
+            return addresses;
+        }
+        final String[] addressTtlPairs = line.split("-")[1].strip().split(" ");
+        for (int i = 0; i < addressTtlPairs.length; i += 2) {
+            addresses.add(InetAddresses.parseNumericAddress(addressTtlPairs[i]));
+        }
+        return addresses;
+    }
+
     /** Returns the first browsed service instance of {@code serviceType}. */
     public NsdServiceInfo browseService(String serviceType) {
         // CLI output:
diff --git a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
index 72a278c..cb0c8ee 100644
--- a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
@@ -28,7 +28,7 @@
 import com.android.net.module.util.Ipv6Utils;
 import com.android.net.module.util.structs.LlaOption;
 import com.android.net.module.util.structs.PrefixInformationOption;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import java.io.IOException;
 import java.net.Inet6Address;
@@ -49,18 +49,18 @@
     // The MAC address of this device.
     public final MacAddress macAddr;
     // The packet reader of the TUN interface of the test network.
-    public final TapPacketReader packetReader;
+    public final PollPacketReader packetReader;
     // The IPv6 address generated by SLAAC for the device.
     public Inet6Address ipv6Addr;
 
     /**
      * Constructs an InfraNetworkDevice with the given {@link MAC address} and {@link
-     * TapPacketReader}.
+     * PollPacketReader}.
      *
      * @param macAddr the MAC address of the device
      * @param packetReader the packet reader of the TUN interface of the test network.
      */
-    public InfraNetworkDevice(MacAddress macAddr, TapPacketReader packetReader) {
+    public InfraNetworkDevice(MacAddress macAddr, PollPacketReader packetReader) {
         this.macAddr = macAddr;
         this.packetReader = packetReader;
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index fa9855e..801e21e 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -32,14 +32,22 @@
 import android.net.nsd.NsdManager
 import android.net.nsd.NsdServiceInfo
 import android.net.thread.ActiveOperationalDataset
+import android.net.thread.ThreadConfiguration
 import android.net.thread.ThreadNetworkController
 import android.os.Build
 import android.os.Handler
 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
 import com.android.net.module.util.NetworkStackConstants
+import com.android.net.module.util.NetworkStackConstants.ICMP_CHECKSUM_OFFSET
+import com.android.net.module.util.NetworkStackConstants.IPV4_CHECKSUM_OFFSET
+import com.android.net.module.util.NetworkStackConstants.IPV4_HEADER_MIN_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV4_LENGTH_OFFSET
 import com.android.net.module.util.Struct
 import com.android.net.module.util.structs.Icmpv4Header
 import com.android.net.module.util.structs.Icmpv6Header
@@ -47,7 +55,7 @@
 import com.android.net.module.util.structs.Ipv6Header
 import com.android.net.module.util.structs.PrefixInformationOption
 import com.android.net.module.util.structs.RaHeader
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestNetworkTracker
 import com.android.testutils.initTestNetwork
 import com.android.testutils.runAsShell
@@ -77,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
@@ -108,6 +118,9 @@
     val DEFAULT_DATASET: ActiveOperationalDataset =
         ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS)
 
+    @JvmField
+    val DEFAULT_CONFIG = ThreadConfiguration.Builder().build()
+
     /**
      * Waits for the given [Supplier] to be true until given timeout.
      *
@@ -136,18 +149,18 @@
     }
 
     /**
-     * Creates a [TapPacketReader] given the [TestNetworkInterface] and [Handler].
+     * Creates a [PollPacketReader] given the [TestNetworkInterface] and [Handler].
      *
      * @param testNetworkInterface the TUN interface of the test network
      * @param handler the handler to process the packets
-     * @return the [TapPacketReader]
+     * @return the [PollPacketReader]
      */
     @JvmStatic
     fun newPacketReader(
         testNetworkInterface: TestNetworkInterface, handler: Handler
-    ): TapPacketReader {
+    ): PollPacketReader {
         val fd = testNetworkInterface.fileDescriptor.fileDescriptor
-        val reader = TapPacketReader(handler, fd, testNetworkInterface.mtu)
+        val reader = PollPacketReader(handler, fd, testNetworkInterface.mtu)
         handler.post { reader.start() }
         handler.waitForIdle(timeoutMs = 5000)
         return reader
@@ -191,7 +204,7 @@
     }
 
     /**
-     * Polls for a packet from a given [TapPacketReader] that satisfies the `filter`.
+     * Polls for a packet from a given [PollPacketReader] that satisfies the `filter`.
      *
      * @param packetReader a TUN packet reader
      * @param filter the filter to be applied on the packet
@@ -199,7 +212,7 @@
      * than 3000ms to read the next packet, the method will return null
      */
     @JvmStatic
-    fun pollForPacket(packetReader: TapPacketReader, filter: Predicate<ByteArray>): ByteArray? {
+    fun pollForPacket(packetReader: PollPacketReader, filter: Predicate<ByteArray>): ByteArray? {
         var packet: ByteArray?
         while ((packetReader.poll(3000 /* timeoutMs */, filter).also { packet = it }) != null) {
             return packet
@@ -303,6 +316,73 @@
         return null
     }
 
+    /** Builds an ICMPv4 Echo Reply packet to respond to the given ICMPv4 Echo Request packet. */
+    @JvmStatic
+    fun buildIcmpv4EchoReply(request: ByteBuffer): ByteBuffer? {
+        val requestIpv4Header = Struct.parse(Ipv4Header::class.java, request) ?: return null
+        val requestIcmpv4Header = Struct.parse(Icmpv4Header::class.java, request) ?: return null
+
+        val id = request.getShort()
+        val seq = request.getShort()
+
+        val payload = ByteBuffer.allocate(4 + request.limit() - request.position())
+        payload.putShort(id)
+        payload.putShort(seq)
+        payload.put(request)
+        payload.rewind()
+
+        val ipv4HeaderLen = Struct.getSize(Ipv4Header::class.java)
+        val Icmpv4HeaderLen = Struct.getSize(Icmpv4Header::class.java)
+        val payloadLen = payload.limit();
+
+        val reply = ByteBuffer.allocate(ipv4HeaderLen + Icmpv4HeaderLen + payloadLen)
+
+        // IPv4 header
+        val replyIpv4Header = Ipv4Header(
+            0 /* TYPE OF SERVICE */,
+            0.toShort().toInt()/* totalLength, calculate later */,
+            requestIpv4Header.id,
+            requestIpv4Header.flagsAndFragmentOffset,
+            0x40 /* ttl */,
+            IPPROTO_ICMP.toByte(),
+            0.toShort()/* checksum, calculate later */,
+            requestIpv4Header.dstIp /* srcIp */,
+            requestIpv4Header.srcIp /* dstIp */
+        )
+        replyIpv4Header.writeToByteBuffer(reply)
+
+        // ICMPv4 header
+        val replyIcmpv4Header = Icmpv4Header(
+            0 /* type, ICMP_ECHOREPLY */,
+            requestIcmpv4Header.code,
+            0.toShort() /* checksum, calculate later */
+        )
+        replyIcmpv4Header.writeToByteBuffer(reply)
+
+        // Payload
+        reply.put(payload)
+        reply.flip()
+
+        // Populate the IPv4 totalLength field.
+        reply.putShort(
+            IPV4_LENGTH_OFFSET, (ipv4HeaderLen + Icmpv4HeaderLen + payloadLen).toShort()
+        )
+
+        // Populate the IPv4 header checksum field.
+        reply.putShort(
+            IPV4_CHECKSUM_OFFSET, IpUtils.ipChecksum(reply, 0 /* headerOffset */)
+        )
+
+        // Populate the ICMP checksum field.
+        reply.putShort(
+            IPV4_HEADER_MIN_LEN + ICMP_CHECKSUM_OFFSET, IpUtils.icmpChecksum(
+                reply, IPV4_HEADER_MIN_LEN, Icmpv4HeaderLen + payloadLen
+            )
+        )
+
+        return reply
+    }
+
     /** Returns the Prefix Information Options (PIO) extracted from an ICMPv6 RA message.  */
     @JvmStatic
     fun getRaPios(raMsg: ByteArray?): List<PrefixInformationOption> {
@@ -311,7 +391,12 @@
         raMsg ?: return pioList
 
         val buf = ByteBuffer.wrap(raMsg)
-        val ipv6Header = Struct.parse(Ipv6Header::class.java, buf)
+        val ipv6Header = try {
+            Struct.parse(Ipv6Header::class.java, buf)
+        } catch (e: IllegalArgumentException) {
+            // the packet is not IPv6
+            return pioList
+        }
         if (ipv6Header.nextHeader != OsConstants.IPPROTO_ICMPV6.toByte()) {
             return pioList
         }
@@ -401,6 +486,7 @@
         val serviceInfoFuture = CompletableFuture<NsdServiceInfo>()
         val listener: NsdManager.DiscoveryListener = object : DefaultDiscoveryListener() {
             override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+                Log.d(TAG, "onServiceFound: $serviceInfo")
                 serviceInfoFuture.complete(serviceInfo)
             }
         }
@@ -448,6 +534,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)
                 }
@@ -513,6 +600,27 @@
         return ftd.omrAddress
     }
 
+    /** 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));
+        controller.setEnabledAndWait(true);
+        controller.joinAndWait(dataset);
+    }
+
+    /** Leaves the Thread network and disables Thread. */
+    @JvmStatic
+    fun leaveNetworkAndDisableThread() {
+        val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
+        val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+        controller.leaveAndWait();
+        controller.setEnabledAndWait(false);
+    }
+
     private open class DefaultDiscoveryListener : NsdManager.DiscoveryListener {
         override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
         override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
@@ -551,48 +659,11 @@
         )
     }
 
+    /**
+     * Stop the ot-daemon by shell command.
+     */
     @JvmStatic
-    @JvmOverloads
-    fun startInfraDeviceAndWaitForOnLinkAddr(
-        tapPacketReader: TapPacketReader,
-        macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6")
-    ): InfraNetworkDevice {
-        val infraDevice = InfraNetworkDevice(macAddress, tapPacketReader)
-        infraDevice.runSlaac(Duration.ofSeconds(60))
-        requireNotNull(infraDevice.ipv6Addr)
-        return infraDevice
-    }
-
-    @JvmStatic
-    @Throws(java.lang.Exception::class)
-    fun setUpInfraNetwork(
-        context: Context, controller: ThreadNetworkControllerWrapper
-    ): TestNetworkTracker {
-        val lp = LinkProperties()
-
-        // TODO: use a fake DNS server
-        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
-        // NAT64 feature requires the infra network to have an IPv4 default route.
-        lp.addRoute(
-            RouteInfo(
-                IpPrefix("0.0.0.0/0") /* destination */,
-                null /* gateway */,
-                null /* iface */,
-                RouteInfo.RTN_UNICAST, 1500 /* mtu */
-            )
-        )
-        val infraNetworkTracker: TestNetworkTracker =
-            runAsShell(
-                MANAGE_TEST_NETWORKS,
-                supplier = { initTestNetwork(context, lp, setupTimeoutMs = 5000) })
-        val infraNetworkName: String = infraNetworkTracker.testIface.getInterfaceName()
-        controller.setTestNetworkAsUpstreamAndWait(infraNetworkName)
-
-        return infraNetworkTracker
-    }
-
-    @JvmStatic
-    fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
-        runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
+    fun stopOtDaemon() {
+        runShellCommandOrThrow("stop ot-daemon")
     }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
index 15a3f5c..9fbfa45 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
@@ -54,6 +54,16 @@
         SystemClock.sleep(500);
     }
 
+    /** Returns the output string of the "ot-ctl br state" command. */
+    public String getBorderRoutingState() {
+        return executeCommandAndParse("br state").getFirst();
+    }
+
+    /** Returns the output string of the "ot-ctl srp server state" command. */
+    public String getSrpServerState() {
+        return executeCommandAndParse("srp server state").getFirst();
+    }
+
     /** Returns the list of IPv6 addresses on ot-daemon. */
     public List<Inet6Address> getAddresses() {
         return executeCommandAndParse("ipaddr").stream()
@@ -128,6 +138,24 @@
         return false;
     }
 
+    /** Adds a prefix in the Network Data. */
+    public void addPrefixInNetworkData(IpPrefix ipPrefix, String flags, String preference) {
+        executeCommand("prefix add " + ipPrefix + " " + flags + " " + preference);
+        executeCommand("netdata register");
+    }
+
+    public int getTrelPort() {
+        return Integer.parseInt(executeCommandAndParse("trel port").get(0));
+    }
+
+    public String getExtendedAddr() {
+        return executeCommandAndParse("extaddr").get(0);
+    }
+
+    public String getExtendedPanId() {
+        return executeCommandAndParse("extpanid").get(0);
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
new file mode 100644
index 0000000..f97c0f2
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.system.OsConstants.IPPROTO_IP
+import android.system.OsConstants.IPPROTO_UDP
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.PacketBuilder
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+/**
+ * A class that simulates a DNS server.
+ *
+ * <p>The server responds to DNS requests with the given {@code answerRecords}.
+ *
+ * @param packetReader the packet reader to poll DNS requests from
+ * @param serverAddress the address of the DNS server
+ * @param answerRecords the records to respond to the DNS requests
+ */
+class TestDnsServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetAddress,
+    private val serverAnswers: List<DnsPacket.DnsRecord>,
+) : TestUdpServer(packetReader, InetSocketAddress(serverAddress, DNS_UDP_PORT)) {
+    companion object {
+        private val TAG = TestDnsServer::class.java.simpleName
+        private const val DNS_UDP_PORT = 53
+    }
+
+    private class TestDnsPacket : DnsPacket {
+
+        constructor(buf: ByteArray) : super(buf)
+
+        constructor(
+            header: DnsHeader,
+            qd: List<DnsRecord>,
+            an: List<DnsRecord>,
+        ) : super(header, qd, an) {}
+
+        val header = super.mHeader
+        val records = super.mRecords
+    }
+
+    override fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer? {
+        val requestDnsPacket = TestDnsPacket(requestUdpPayload)
+        val requestDnsHeader = requestDnsPacket.header
+
+        val answerRecords =
+            buildDnsAnswerRecords(requestDnsPacket.records[DnsPacket.QDSECTION], serverAnswers)
+        // TODO: return NXDOMAIN if no answer is found.
+        val responseFlags = 1 shl 15 // QR bit
+        val responseDnsHeader =
+            DnsPacket.DnsHeader(
+                requestDnsHeader.id,
+                responseFlags,
+                requestDnsPacket.records[DnsPacket.QDSECTION].size,
+                answerRecords.size,
+            )
+        val responseDnsPacket =
+            TestDnsPacket(
+                responseDnsHeader,
+                requestDnsPacket.records[DnsPacket.QDSECTION],
+                answerRecords,
+            )
+
+        val buf =
+            PacketBuilder.allocate(
+                false /* hasEther */,
+                IPPROTO_IP,
+                IPPROTO_UDP,
+                responseDnsPacket.bytes.size,
+            )
+
+        val packetBuilder = PacketBuilder(buf)
+        packetBuilder.writeIpv4Header(
+            requestIpv4Header.tos,
+            requestIpv4Header.id,
+            requestIpv4Header.flagsAndFragmentOffset,
+            0x40 /* ttl */,
+            IPPROTO_UDP.toByte(),
+            requestIpv4Header.dstIp, /* srcIp */
+            requestIpv4Header.srcIp, /* dstIp */
+        )
+        packetBuilder.writeUdpHeader(
+            requestUdpHeader.dstPort.toShort() /* srcPort */,
+            requestUdpHeader.srcPort.toShort(), /* dstPort */
+        )
+        buf.put(responseDnsPacket.bytes)
+
+        return packetBuilder.finalizePacket()
+    }
+
+    private fun buildDnsAnswerRecords(
+        questions: List<DnsPacket.DnsRecord>,
+        serverAnswers: List<DnsPacket.DnsRecord>,
+    ): List<DnsPacket.DnsRecord> {
+        val answers = ArrayList<DnsPacket.DnsRecord>()
+        for (answer in serverAnswers) {
+            if (
+                questions.any {
+                    answer.dName.equals(it.dName, ignoreCase = true) && answer.nsType == it.nsType
+                }
+            ) {
+                answers.add(answer)
+            }
+        }
+        return answers
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt b/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
new file mode 100644
index 0000000..1667980
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.content.Context
+import android.net.InetAddresses.parseNumericAddress
+import android.net.IpPrefix
+import android.net.LinkProperties
+import android.net.MacAddress
+import android.net.RouteInfo
+import com.android.testutils.PollPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.android.testutils.initTestNetwork
+import com.android.testutils.runAsShell
+import java.time.Duration
+
+object TestTunNetworkUtils {
+    private val networkTrackers = mutableListOf<TestNetworkTracker>()
+
+    @JvmStatic
+    @JvmOverloads
+    fun setUpInfraNetwork(
+        context: Context,
+        controller: ThreadNetworkControllerWrapper,
+        lp: LinkProperties = defaultLinkProperties(),
+    ): TestNetworkTracker {
+        val infraNetworkTracker: TestNetworkTracker =
+            runAsShell(
+                MANAGE_TEST_NETWORKS,
+                supplier = { initTestNetwork(context, lp, setupTimeoutMs = 5000) },
+            )
+        val infraNetworkName: String = infraNetworkTracker.testIface.getInterfaceName()
+        controller.setTestNetworkAsUpstreamAndWait(infraNetworkName)
+        networkTrackers.add(infraNetworkTracker)
+
+        return infraNetworkTracker
+    }
+
+    @JvmStatic
+    fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
+        runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
+    }
+
+    @JvmStatic
+    fun tearDownAllInfraNetworks() {
+        networkTrackers.forEach { tearDownInfraNetwork(it) }
+        networkTrackers.clear()
+    }
+
+    @JvmStatic
+    @JvmOverloads
+    fun startInfraDeviceAndWaitForOnLinkAddr(
+        pollPacketReader: PollPacketReader,
+        macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6"),
+    ): InfraNetworkDevice {
+        val infraDevice = InfraNetworkDevice(macAddress, pollPacketReader)
+        infraDevice.runSlaac(Duration.ofSeconds(60))
+        requireNotNull(infraDevice.ipv6Addr)
+        return infraDevice
+    }
+
+    private fun defaultLinkProperties(): LinkProperties {
+        val lp = LinkProperties()
+        // TODO: use a fake DNS server
+        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+            RouteInfo(
+                IpPrefix("0.0.0.0/0") /* destination */,
+                null /* gateway */,
+                null /* iface */,
+                RouteInfo.RTN_UNICAST,
+                1500, /* mtu */
+            )
+        )
+        return lp
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
new file mode 100644
index 0000000..9fcd6a4
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.system.OsConstants.IPPROTO_IP
+import android.system.OsConstants.IPPROTO_UDP
+import com.android.net.module.util.PacketBuilder
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+/**
+ * A class that simulates a UDP echo server that replies to incoming UDP message with the same
+ * payload.
+ *
+ * @param packetReader the packet reader to poll UDP requests from
+ * @param serverAddress the address and port of the UDP server
+ */
+class TestUdpEchoServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetSocketAddress,
+) : TestUdpServer(packetReader, serverAddress) {
+    companion object {
+        private val TAG = TestUdpEchoServer::class.java.simpleName
+    }
+
+    override fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer? {
+        val buf =
+            PacketBuilder.allocate(
+                false /* hasEther */,
+                IPPROTO_IP,
+                IPPROTO_UDP,
+                requestUdpPayload.size,
+            )
+
+        val packetBuilder = PacketBuilder(buf)
+        packetBuilder.writeIpv4Header(
+            requestIpv4Header.tos,
+            requestIpv4Header.id,
+            requestIpv4Header.flagsAndFragmentOffset,
+            0x40 /* ttl */,
+            IPPROTO_UDP.toByte(),
+            requestIpv4Header.dstIp, /* srcIp */
+            requestIpv4Header.srcIp, /* dstIp */
+        )
+        packetBuilder.writeUdpHeader(
+            requestUdpHeader.dstPort.toShort() /* srcPort */,
+            requestUdpHeader.srcPort.toShort(), /* dstPort */
+        )
+        buf.put(requestUdpPayload)
+
+        return packetBuilder.finalizePacket()
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
new file mode 100644
index 0000000..fb0942e
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.net.thread.utils.IntegrationTestUtils.pollForPacket
+import com.android.net.module.util.Struct
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import kotlin.concurrent.thread
+
+/**
+ * A class that simulates a UDP server that replies to incoming UDP messages.
+ *
+ * @param packetReader the packet reader to poll UDP requests from
+ * @param serverAddress the address and port of the UDP server
+ */
+abstract class TestUdpServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetSocketAddress,
+) {
+    private val TAG = TestUdpServer::class.java.simpleName
+    private var workerThread: Thread? = null
+
+    /**
+     * Starts the UDP server to respond to UDP messages.
+     *
+     * <p> The server polls the UDP messages from the {@code packetReader} and responds with a
+     * message built by {@code buildResponse}. The server will automatically stop when it fails to
+     * poll a UDP request within the timeout (3000 ms, as defined in IntegrationTestUtils).
+     */
+    fun start() {
+        workerThread = thread {
+            var requestPacket: ByteArray
+            while (true) {
+                requestPacket = pollForUdpPacket() ?: break
+                val buf = ByteBuffer.wrap(requestPacket)
+                packetReader.sendResponse(buildResponse(buf) ?: break)
+            }
+        }
+    }
+
+    /** Stops the UDP server. */
+    fun stop() {
+        workerThread?.join()
+    }
+
+    /**
+     * Builds the UDP response for the given UDP request.
+     *
+     * @param ipv4Header the IPv4 header of the UDP request
+     * @param udpHeader the UDP header of the UDP request
+     * @param udpPayload the payload of the UDP request
+     * @return the UDP response
+     */
+    abstract fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer?
+
+    private fun pollForUdpPacket(): ByteArray? {
+        val filter =
+            fun(packet: ByteArray): Boolean {
+                val buf = ByteBuffer.wrap(packet)
+                val ipv4Header = Struct.parse(Ipv4Header::class.java, buf) ?: return false
+                val udpHeader = Struct.parse(UdpHeader::class.java, buf) ?: return false
+                return ipv4Header.dstIp == serverAddress.address &&
+                    udpHeader.dstPort == serverAddress.port
+            }
+        return pollForPacket(packetReader, filter)
+    }
+
+    private fun buildResponse(requestPacket: ByteBuffer): ByteBuffer? {
+        val requestIpv4Header = Struct.parse(Ipv4Header::class.java, requestPacket) ?: return null
+        val requestUdpHeader = Struct.parse(UdpHeader::class.java, requestPacket) ?: return null
+        val remainingRequestPacket = ByteArray(requestPacket.remaining())
+        requestPacket.get(remainingRequestPacket)
+
+        return buildResponse(requestIpv4Header, requestUdpHeader, remainingRequestPacket)
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
index 7e84233..b6114f3 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -29,6 +29,7 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.ThreadNetworkException;
@@ -36,10 +37,12 @@
 import android.os.OutcomeReceiver;
 
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
 
 /** A helper class which provides synchronous API wrappers for {@link ThreadNetworkController}. */
 public final class ThreadNetworkControllerWrapper {
@@ -47,9 +50,13 @@
     public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
     private static final Duration SET_ENABLED_TIMEOUT = Duration.ofSeconds(2);
+    private static final Duration CONFIG_TIMEOUT = Duration.ofSeconds(1);
 
     private final ThreadNetworkController mController;
 
+    private final List<Integer> mDeviceRoleUpdates = new ArrayList<>();
+    @Nullable private StateCallback mStateCallback;
+
     /**
      * Returns a new {@link ThreadNetworkControllerWrapper} instance or {@code null} if Thread
      * feature is not supported on this device.
@@ -68,6 +75,15 @@
     }
 
     /**
+     * Returns the underlying {@link ThreadNetworkController} object or {@code null} if the current
+     * platform doesn't support it.
+     */
+    @Nullable
+    public ThreadNetworkController get() {
+        return mController;
+    }
+
+    /**
      * Returns the Thread enabled state.
      *
      * <p>The value can be one of {@code ThreadNetworkController#STATE_*}.
@@ -191,6 +207,36 @@
         future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
     }
 
+    public ThreadConfiguration getConfiguration() throws Exception {
+        CompletableFuture<ThreadConfiguration> future = new CompletableFuture<>();
+        Consumer<ThreadConfiguration> callback = future::complete;
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.registerConfigurationCallback(directExecutor(), callback));
+        future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS);
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.unregisterConfigurationCallback(callback));
+        return future.getNow(null);
+    }
+
+    public void setConfigurationAndWait(ThreadConfiguration config) throws Exception {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.setConfiguration(
+                                config, directExecutor(), newOutcomeReceiver(future)));
+        future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    public void setNat64EnabledAndWait(boolean enabled) throws Exception {
+        final ThreadConfiguration config = getConfiguration();
+        final ThreadConfiguration newConfig =
+                new ThreadConfiguration.Builder(config).setNat64Enabled(enabled).build();
+        setConfigurationAndWait(newConfig);
+    }
+
     private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
             CompletableFuture<V> future) {
         return new OutcomeReceiver<V, ThreadNetworkException>() {
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index c6a24ea..53b1eca 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -35,6 +35,7 @@
     static_libs: [
         "androidx.test.rules",
         "frameworks-base-testutils",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
         "framework-location.stubs.module_lib",
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index 0423578..e3c83f1 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -17,6 +17,8 @@
 package android.net.thread;
 
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
+import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
@@ -26,6 +28,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 
@@ -134,6 +137,24 @@
         return (IOperationalDatasetCallback) invocation.getArguments()[0];
     }
 
+    private static IOperationReceiver getActivateEphemeralKeyModeReceiver(
+            InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[1];
+    }
+
+    private static IOperationReceiver getDeactivateEphemeralKeyModeReceiver(
+            InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[0];
+    }
+
+    private static IOperationReceiver getSetConfigurationReceiver(InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[1];
+    }
+
+    private static IConfigurationReceiver getConfigurationReceiver(InvocationOnMock invocation) {
+        return (IConfigurationReceiver) invocation.getArguments()[0];
+    }
+
     @Test
     public void registerStateCallback_callbackIsInvokedWithCallingAppIdentity() throws Exception {
         setBinderUid(SYSTEM_UID);
@@ -440,4 +461,152 @@
         assertThat(callbackUid.get()).isNotEqualTo(SYSTEM_UID);
         assertThat(callbackUid.get()).isEqualTo(Process.myUid());
     }
+
+    @Test
+    public void activateEphemeralKeyMode_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        Duration lifetime = Duration.ofSeconds(100);
+        doAnswer(
+                        invoke -> {
+                            getActivateEphemeralKeyModeReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .activateEphemeralKeyMode(anyLong(), any(IOperationReceiver.class));
+        mController.activateEphemeralKeyMode(
+                lifetime, Runnable::run, v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getActivateEphemeralKeyModeReceiver(invoke)
+                                    .onError(ERROR_FAILED_PRECONDITION, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .activateEphemeralKeyMode(anyLong(), any(IOperationReceiver.class));
+        mController.activateEphemeralKeyMode(
+                lifetime,
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
+    public void deactivateEphemeralKeyMode_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getDeactivateEphemeralKeyModeReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .deactivateEphemeralKeyMode(any(IOperationReceiver.class));
+        mController.deactivateEphemeralKeyMode(
+                Runnable::run, v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getDeactivateEphemeralKeyModeReceiver(invoke)
+                                    .onError(ERROR_INTERNAL_ERROR, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .deactivateEphemeralKeyMode(any(IOperationReceiver.class));
+        mController.deactivateEphemeralKeyMode(
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
+    public void setConfiguration_callbackIsInvokedWithCallingAppIdentity() throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getSetConfigurationReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .setConfiguration(any(ThreadConfiguration.class), any(IOperationReceiver.class));
+        mController.setConfiguration(
+                new ThreadConfiguration.Builder().build(),
+                Runnable::run,
+                v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getSetConfigurationReceiver(invoke).onError(ERROR_INTERNAL_ERROR, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .setConfiguration(any(ThreadConfiguration.class), any(IOperationReceiver.class));
+        mController.setConfiguration(
+                new ThreadConfiguration.Builder().build(),
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
+    public void registerConfigurationCallback_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger callbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getConfigurationReceiver(invoke)
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder().build());
+                            return null;
+                        })
+                .when(mMockService)
+                .registerConfigurationCallback(any(IConfigurationReceiver.class));
+
+        mController.registerConfigurationCallback(
+                Runnable::run, v -> callbackUid.set(Binder.getCallingUid()));
+
+        assertThat(callbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(callbackUid.get()).isEqualTo(Process.myUid());
+    }
 }
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java
new file mode 100644
index 0000000..c83cb7a
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SmallTest;
+
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+
+/** Tests for {@link ThreadNetworkSpecifier}. */
+@SmallTest
+@RunWith(Parameterized.class)
+public final class ThreadNetworkSpecifierTest {
+    public final byte[] mExtendedPanId;
+    public final OperationalDatasetTimestamp mActiveTimestamp;
+    public final boolean mRouterEligibleForLeader;
+
+    @Parameterized.Parameters
+    public static Collection specifierArguments() {
+        var timestampNow = OperationalDatasetTimestamp.fromInstant(Instant.now());
+        return Arrays.asList(
+                new Object[][] {
+                    {new byte[] {0, 1, 2, 3, 4, 5, 6, 7}, null, false},
+                    {new byte[] {1, 1, 1, 1, 2, 2, 2, 2}, timestampNow, true},
+                    {new byte[] {1, 1, 1, 1, 2, 2, 2, 2}, timestampNow, false},
+                });
+    }
+
+    public ThreadNetworkSpecifierTest(
+            byte[] extendedPanId,
+            OperationalDatasetTimestamp activeTimestamp,
+            boolean routerEligibleForLeader) {
+        mExtendedPanId = extendedPanId.clone();
+        mActiveTimestamp = activeTimestamp;
+        mRouterEligibleForLeader = routerEligibleForLeader;
+    }
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        ThreadNetworkSpecifier specifier =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+        assertParcelingIsLossless(specifier);
+    }
+
+    @Test
+    public void builder_correctValuesAreSet() {
+        ThreadNetworkSpecifier specifier =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+
+        assertThat(specifier.getExtendedPanId()).isEqualTo(mExtendedPanId);
+        assertThat(specifier.getActiveTimestamp()).isEqualTo(mActiveTimestamp);
+        assertThat(specifier.isRouterEligibleForLeader()).isEqualTo(mRouterEligibleForLeader);
+    }
+
+    @Test
+    public void builderConstructor_specifiersAreEqual() {
+        ThreadNetworkSpecifier specifier1 =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+
+        ThreadNetworkSpecifier specifier2 = new ThreadNetworkSpecifier.Builder(specifier1).build();
+
+        assertThat(specifier1).isEqualTo(specifier2);
+    }
+
+    @Test
+    public void equalsTester() {
+        var timestampNow = OperationalDatasetTimestamp.fromInstant(Instant.now());
+        new EqualsTester()
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(timestampNow)
+                                .setRouterEligibleForLeader(true)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(timestampNow)
+                                .setRouterEligibleForLeader(true)
+                                .build())
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build())
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {1, 1, 1, 1, 2, 2, 2, 2})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {1, 1, 1, 1, 2, 2, 2, 2})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build())
+                .testEquals();
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
index b32986d..d52191a 100644
--- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
@@ -34,6 +34,7 @@
 
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -61,6 +62,7 @@
 import java.net.InetAddress;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Executor;
@@ -584,6 +586,7 @@
         verify(mResolveServiceCallback, times(1))
                 .onServiceResolved(
                         eq("test-host"),
+                        eq(0),
                         eq("test"),
                         eq("_test._tcp"),
                         eq(12345),
@@ -811,7 +814,10 @@
     private void prepareTest() {
         mTestLooper = new TestLooper();
         Handler handler = new Handler(mTestLooper.getLooper());
-        mNsdPublisher = new NsdPublisher(mMockNsdManager, mMockDnsResolver, handler);
+        HashMap<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
+        mNsdPublisher =
+                new NsdPublisher(
+                        mMockNsdManager, mMockDnsResolver, handler, networkToLinkProperties);
         mNsdPublisher.setNetworkForHostResolution(mNetwork);
     }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index be32764..bc8da8b 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -17,6 +17,8 @@
 package com.android.server.thread;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
@@ -30,9 +32,11 @@
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
@@ -43,6 +47,8 @@
 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;
@@ -63,12 +69,16 @@
 import android.content.Intent;
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
 import android.net.NetworkAgent;
 import android.net.NetworkProvider;
 import android.net.NetworkRequest;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
 import android.os.Handler;
@@ -78,6 +88,7 @@
 import android.os.SystemClock;
 import android.os.UserManager;
 import android.os.test.TestLooper;
+import android.provider.DeviceConfig;
 import android.util.AtomicFile;
 
 import androidx.test.annotation.UiThreadTest;
@@ -87,11 +98,15 @@
 
 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.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;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -110,6 +125,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicReference;
@@ -159,8 +175,10 @@
     private static final byte[] TEST_VENDOR_OUI_BYTES = new byte[] {(byte) 0xAC, (byte) 0xDE, 0x48};
     private static final String TEST_VENDOR_NAME = "test vendor";
     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 ConnectivityManager mMockConnectivityManager;
+    @Mock private RoutingCoordinatorManager mMockRoutingCoordinatorManager;
     @Mock private NetworkAgent mMockNetworkAgent;
     @Mock private TunInterfaceController mMockTunIfController;
     @Mock private ParcelFileDescriptor mMockTunFd;
@@ -170,6 +188,7 @@
     @Mock private IBinder mIBinder;
     @Mock Resources mResources;
     @Mock ConnectivityResources mConnectivityResources;
+    @Mock Map<Network, LinkProperties> mMockNetworkToLinkProperties;
 
     private Context mContext;
     private TestLooper mTestLooper;
@@ -192,6 +211,9 @@
                         eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), anyString());
         doNothing()
                 .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), anyString());
+        doNothing()
+                .when(mContext)
                 .enforceCallingOrSelfPermission(eq(NETWORK_SETTINGS), anyString());
 
         mTestLooper = new TestLooper();
@@ -199,13 +221,21 @@
         NetworkProvider networkProvider =
                 new NetworkProvider(mContext, mTestLooper.getLooper(), "ThreadNetworkProvider");
 
-        mFakeOtDaemon = new FakeOtDaemon(handler);
+        when(mMockRoutingCoordinatorManager.requestDownstreamAddress(any()))
+                .thenReturn(TEST_NAT64_CIDR);
+
+        mFakeOtDaemon = spy(new FakeOtDaemon(handler));
         when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
 
         when(mMockUserManager.hasUserRestriction(eq(DISALLOW_THREAD_NETWORK))).thenReturn(false);
 
         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_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)))
@@ -226,16 +256,26 @@
                         networkProvider,
                         () -> mFakeOtDaemon,
                         mMockConnectivityManager,
+                        mMockRoutingCoordinatorManager,
                         mMockTunIfController,
                         mMockInfraIfController,
                         mPersistentSettings,
                         mMockNsdPublisher,
                         mMockUserManager,
                         mConnectivityResources,
-                        () -> DEFAULT_COUNTRY_CODE);
+                        () -> DEFAULT_COUNTRY_CODE,
+                        mMockNetworkToLinkProperties);
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                () -> DeviceConfig.deleteProperty("thread_network", "TrelFeature__enabled"));
+    }
+
     @Test
     public void initialize_tunInterfaceAndNsdPublisherSetToOtDaemon() throws Exception {
         when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
@@ -250,6 +290,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)))
@@ -262,6 +307,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);
@@ -271,6 +318,66 @@
     }
 
     @Test
+    public void initialize_nat64Disabled_doesNotRequestNat64CidrAndConfiguresOtDaemon()
+            throws Exception {
+        ThreadConfiguration config =
+                new ThreadConfiguration.Builder().setNat64Enabled(false).build();
+        mPersistentSettings.putConfiguration(config);
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        verify(mMockRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
+        verify(mFakeOtDaemon, times(1)).setNat64Cidr(isNull(), any());
+        verify(mFakeOtDaemon, never()).setNat64Cidr(isNotNull(), any());
+    }
+
+    @Test
+    public void initialize_nat64Enabled_requestsNat64CidrAndConfiguresAtOtDaemon()
+            throws Exception {
+        ThreadConfiguration config =
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
+        mPersistentSettings.putConfiguration(config);
+        mService.initialize();
+        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());
+    }
+
+    @Test
+    public void initialize_trelFeatureDisabled_trelDisabledAtOtDaemon() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                () ->
+                        DeviceConfig.setProperty(
+                                "thread_network", "TrelFeature__enabled", "false", false));
+
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        assertThat(mFakeOtDaemon.isTrelEnabled()).isFalse();
+    }
+
+    @Test
+    public void initialize_trelFeatureEnabled_setTrelEnabledAtOtDamon() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                () ->
+                        DeviceConfig.setProperty(
+                                "thread_network", "TrelFeature__enabled", "true", false));
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        assertThat(mFakeOtDaemon.isTrelEnabled()).isTrue();
+    }
+
+    @Test
     public void getMeshcopTxtAttributes_emptyVendorName_accepted() {
         when(mResources.getString(eq(R.string.config_thread_vendor_name))).thenReturn("");
 
@@ -731,10 +838,7 @@
                         .setDhcpv6PdEnabled(false)
                         .build();
         ThreadConfiguration config2 =
-                new ThreadConfiguration.Builder()
-                        .setNat64Enabled(true)
-                        .setDhcpv6PdEnabled(true)
-                        .build();
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
         ThreadConfiguration config3 =
                 new ThreadConfiguration.Builder(config2).build(); // Same as config2
 
@@ -751,6 +855,71 @@
     }
 
     @Test
+    public void setConfiguration_enablesNat64_requestsNat64CidrAndConfiguresOtdaemon()
+            throws Exception {
+        mService.initialize();
+        mTestLooper.dispatchAll();
+        clearInvocations(mMockRoutingCoordinatorManager, mFakeOtDaemon);
+
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        mService.setConfiguration(
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build(), mockReceiver);
+        mTestLooper.dispatchAll();
+
+        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));
+    }
+
+    @Test
+    public void setConfiguration_enablesNat64_otDaemonRemoteFailure_serviceDoesNotCrash()
+            throws Exception {
+        mService.initialize();
+        mTestLooper.dispatchAll();
+        clearInvocations(mMockRoutingCoordinatorManager, mFakeOtDaemon);
+        mFakeOtDaemon.setSetNat64CidrException(
+                new RemoteException("ot-daemon setNat64Cidr() throws"));
+
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        mService.setConfiguration(
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build(), mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mFakeOtDaemon, times(1))
+                .setNat64Cidr(eq(TEST_NAT64_CIDR.toString()), any(IOtStatusReceiver.class));
+    }
+
+    @Test
+    public void setConfiguration_disablesNat64_releasesNat64CidrAndConfiguresOtdaemon()
+            throws Exception {
+        mPersistentSettings.putConfiguration(
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build());
+        mService.initialize();
+        mTestLooper.dispatchAll();
+        clearInvocations(mMockRoutingCoordinatorManager, mFakeOtDaemon);
+
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        mService.setConfiguration(
+                new ThreadConfiguration.Builder().setNat64Enabled(false).build(), mockReceiver);
+        mTestLooper.dispatchAll();
+
+        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));
+    }
+
+    @Test
     public void initialize_upstreamNetworkRequestHasCertainTransportTypesAndCapabilities() {
         mService.initialize();
         mTestLooper.dispatchAll();
@@ -801,4 +970,53 @@
         assertThat(networkRequest2.getNetworkSpecifier()).isNull();
         assertThat(networkRequest2.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
     }
+
+    @Test
+    public void runOtCtlCommand_noPermission_throwsSecurityException() {
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), any());
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), any());
+
+        assertThrows(
+                SecurityException.class,
+                () -> mService.runOtCtlCommand("", false, new IOutputReceiver.Default()));
+    }
+
+    @Test
+    public void runOtCtlCommand_otDaemonRemoteFailure_receiverOnErrorIsCalled() throws Exception {
+        mService.initialize();
+        final IOutputReceiver mockReceiver = mock(IOutputReceiver.class);
+        mFakeOtDaemon.setRunOtCtlCommandException(
+                new RemoteException("ot-daemon runOtCtlCommand() throws"));
+
+        mService.runOtCtlCommand("ot-ctl state", false, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+    }
+
+    @Test
+    public void activateEphemeralKeyMode_succeed() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+
+        mService.activateEphemeralKeyMode(1_000L, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+    }
+
+    @Test
+    public void deactivateEphemeralKeyMode_succeed() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+
+        mService.deactivateEphemeralKeyMode(mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index dfb3129..640b0f1 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -20,12 +20,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
@@ -35,8 +38,13 @@
 
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IConfigurationReceiver;
+import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.os.Binder;
+import android.os.Process;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
@@ -47,6 +55,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -95,6 +104,9 @@
 
         mShellCommand = new ThreadNetworkShellCommand(mContext, mControllerService, mCountryCode);
         mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+
+        // by default emulate shell uid.
+        BinderUtil.setUid(Process.SHELL_UID);
     }
 
     @After
@@ -102,16 +114,20 @@
         validateMockitoUsage();
     }
 
-    @Test
-    public void getCountryCode_testingPermissionIsChecked() {
-        when(mCountryCode.getCountryCode()).thenReturn("US");
-
+    private void runShellCommand(String... args) {
         mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
-                new String[] {"get-country-code"});
+                args);
+    }
+
+    @Test
+    public void getCountryCode_testingPermissionIsChecked() {
+        when(mCountryCode.getCountryCode()).thenReturn("US");
+
+        runShellCommand("get-country-code");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -122,24 +138,14 @@
     public void getCountryCode_currentCountryCodePrinted() {
         when(mCountryCode.getCountryCode()).thenReturn("US");
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"get-country-code"});
+        runShellCommand("get-country-code");
 
         verify(mOutputWriter).println(contains("US"));
     }
 
     @Test
     public void forceSetCountryCodeEnabled_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -148,36 +154,21 @@
 
     @Test
     public void forceSetCountryCodeEnabled_countryCodeIsOverridden() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mCountryCode).setOverrideCountryCode(eq("US"));
     }
 
     @Test
     public void forceSetCountryCodeDisabled_overriddenCountryCodeIsCleared() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "disabled"});
+        runShellCommand("force-country-code", "disabled");
 
         verify(mCountryCode).clearOverrideCountryCode();
     }
 
     @Test
     public void forceStopOtDaemon_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -190,12 +181,7 @@
                 .when(mControllerService)
                 .forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mOutputWriter, never()).println();
@@ -205,12 +191,7 @@
     public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() {
         doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
@@ -221,12 +202,7 @@
     public void join_controllerServiceJoinIsCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", DEFAULT_ACTIVE_DATASET_TLVS});
+        runShellCommand("join", DEFAULT_ACTIVE_DATASET_TLVS);
 
         var activeDataset =
                 ActiveOperationalDataset.fromThreadTlvs(
@@ -239,12 +215,7 @@
     public void join_invalidDataset_controllerServiceJoinIsNotCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", "000102"});
+        runShellCommand("join", "000102");
 
         verify(mControllerService, never()).join(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -254,12 +225,7 @@
     public void migrate_controllerServiceMigrateIsCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300"});
+        runShellCommand("migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300");
 
         ArgumentCaptor<PendingOperationalDataset> captor =
                 ArgumentCaptor.forClass(PendingOperationalDataset.class);
@@ -276,12 +242,7 @@
     public void migrate_invalidDataset_controllerServiceMigrateIsNotCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", "000102", "300"});
+        runShellCommand("migrate", "000102", "300");
 
         verify(mControllerService, never()).scheduleMigration(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -291,14 +252,179 @@
     public void leave_controllerServiceLeaveIsCalled() {
         doNothing().when(mControllerService).leave(any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"leave"});
+        runShellCommand("leave");
 
         verify(mControllerService, times(1)).leave(any());
         verify(mErrorWriter, never()).println();
     }
+
+    @Test
+    public void handleOtCtlCommand_testingPermissionIsChecked() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(1);
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(anyString(), anyBoolean(), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void handleOtCtlCommand_failsWithNonRootProcess() {
+        runShellCommand("ot-ctl", "state");
+
+        verify(mErrorWriter, times(1)).println(contains("No access to ot-ctl command"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_serviceTimeout_failsWithTimeoutError() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doNothing().when(mControllerService).runOtCtlCommand(anyString(), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mControllerService, times(1)).runOtCtlCommand(anyString(), eq(false), any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_state_outputIsPrinted() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(2);
+
+                            receiver.onOutput("leader");
+                            receiver.onOutput("\r\n");
+                            receiver.onOutput("Done");
+                            receiver.onOutput("\r\n");
+
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(eq("state"), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        InOrder inOrder = inOrder(mOutputWriter);
+        inOrder.verify(mOutputWriter).print("leader");
+        inOrder.verify(mOutputWriter).print("\r\n");
+        inOrder.verify(mOutputWriter).print("Done");
+        inOrder.verify(mOutputWriter).print("\r\n");
+    }
+
+    @Test
+    public void config_getConfig_testingPermissionIsChecked() {
+        runShellCommand("config");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void config_getConfig_serviceTimeOut_failsWithTimeoutError() {
+        runShellCommand("config");
+
+        verify(mControllerService, times(1)).registerConfigurationCallback(any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_getConfig_expectedValueIsPrinted() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder()
+                                                    .setNat64Enabled(true)
+                                                    .build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+
+        runShellCommand("config");
+
+        verify(mErrorWriter, never()).println();
+        verify(mOutputWriter, times(1)).println(contains("nat64Enabled=true"));
+    }
+
+    @Test
+    public void config_setConfig_testingPermissionIsChecked() {
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void config_setConfig_serviceTimeOut_failedWithTimeoutError() {
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mControllerService, times(1)).registerConfigurationCallback(any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_invalidArgument_failsWithInvalidArgumentError() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder().build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+
+        runShellCommand("config", "invalidName", "invalidValue");
+
+        verify(mErrorWriter, atLeastOnce()).println(contains("Invalid config"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_setConfig_expectedValueIsSet() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder()
+                                                    .setNat64Enabled(false)
+                                                    .build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+        doAnswer(
+                        inv -> {
+                            ((IOperationReceiver) inv.getArgument(0)).onSuccess();
+                            return null;
+                        })
+                .when(mControllerService)
+                .setConfiguration(any(), any());
+
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mControllerService, times(1))
+                .setConfiguration(
+                        eq(new ThreadConfiguration.Builder().setNat64Enabled(true).build()), any());
+        verify(mErrorWriter, never()).println();
+        verify(mOutputWriter, never()).println();
+    }
 }
diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java b/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java
new file mode 100644
index 0000000..21eb7d9
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils;
+
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.annotation.Nullable;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkController.StateCallback;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.ArrayTrackRecord;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A listener for sequential Thread state updates.
+ *
+ * <p>This is a wrapper around {@link ThreadNetworkController#registerStateCallback} to make
+ * synchronized access to Thread state updates easier.
+ */
+@VisibleForTesting
+public final class ThreadStateListener {
+    private static final List<ThreadStateListener> sListeners = new ArrayList<>();
+    private final ArrayTrackRecord<Integer> mDeviceRoleUpdates = new ArrayTrackRecord<>();
+    private final ArrayTrackRecord<Integer>.ReadHead mReadHead = mDeviceRoleUpdates.newReadHead();
+    private final ThreadNetworkController mController;
+    private final StateCallback mCallback =
+            new ThreadNetworkController.StateCallback() {
+                @Override
+                public void onDeviceRoleChanged(int newRole) {
+                    mDeviceRoleUpdates.add(newRole);
+                }
+                // Add more state update trackers here
+            };
+
+    /** Creates a new {@link ThreadStateListener} object and starts listening for state updates. */
+    public static ThreadStateListener startListener(ThreadNetworkController controller) {
+        var listener = new ThreadStateListener(controller);
+        sListeners.add(listener);
+        listener.start();
+        return listener;
+    }
+
+    /** Stops all listeners created by {@link #startListener}. */
+    public static void stopAllListeners() {
+        for (var listener : sListeners) {
+            listener.stop();
+        }
+        sListeners.clear();
+    }
+
+    private ThreadStateListener(ThreadNetworkController controller) {
+        mController = controller;
+    }
+
+    private void start() {
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), mCallback));
+    }
+
+    private void stop() {
+        runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+    }
+
+    /**
+     * Polls for any role in {@code roles} starting after call to {@link #startListener}.
+     *
+     * <p>Returns the matched device role or {@code null} if timeout.
+     */
+    @Nullable
+    public Integer pollForAnyRoleOf(List<Integer> roles, Duration timeout) {
+        return mReadHead.poll(timeout.toMillis(), newRole -> (roles.contains(newRole)));
+    }
+}
diff --git a/tools/Android.bp b/tools/Android.bp
index 2c2ed14..1351eb7 100644
--- a/tools/Android.bp
+++ b/tools/Android.bp
@@ -81,7 +81,7 @@
         "gen_jarjar.py",
         "gen_jarjar_test.py",
     ],
-    data: [
+    device_common_data: [
         "testdata/test-jarjar-excludes.txt",
         // txt with Test classes to test they aren't included when added to jarjar excludes
         "testdata/test-jarjar-excludes-testclass.txt",