Merge "Revert "Set diagnostics log-data-type to CONNDIAG"" into main
diff --git a/TEST_MAPPING b/TEST_MAPPING
index b773ed8..c1bc31e 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -424,6 +424,11 @@
       ]
     }
   ],
+  "automotive-mumd-presubmit": [
+    {
+      "name": "CtsNetTestCases"
+    }
+  ],
   "imports": [
     {
       "path": "frameworks/base/core/java/android/net"
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/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/common/TetheringLib/api/module-lib-current.txt b/Tethering/common/TetheringLib/api/module-lib-current.txt
index 460c216..e893894 100644
--- a/Tethering/common/TetheringLib/api/module-lib-current.txt
+++ b/Tethering/common/TetheringLib/api/module-lib-current.txt
@@ -46,5 +46,10 @@
     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 getPackageName();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public int getUid();
+  }
+
 }
 
diff --git a/Tethering/common/TetheringLib/api/system-current.txt b/Tethering/common/TetheringLib/api/system-current.txt
index 3efaac2..1728e16 100644
--- a/Tethering/common/TetheringLib/api/system-current.txt
+++ b/Tethering/common/TetheringLib/api/system-current.txt
@@ -97,16 +97,16 @@
   }
 
   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 @FlaggedApi("com.android.net.flags.tethering_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 @FlaggedApi("com.android.net.flags.tethering_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;
+    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 {
@@ -115,7 +115,7 @@
     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 @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);
     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/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 411971d..6b96397 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -33,6 +33,7 @@
 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;
@@ -698,7 +699,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 +708,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,13 +722,13 @@
             }
         };
 
-        @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);
@@ -746,6 +747,7 @@
                 mBuilderParcel.exemptFromEntitlementCheck = false;
                 mBuilderParcel.showProvisioningUi = true;
                 mBuilderParcel.connectivityScope = getDefaultConnectivityScope(type);
+                mBuilderParcel.uid = Process.INVALID_UID;
                 mBuilderParcel.softApConfig = null;
             }
 
@@ -818,7 +820,7 @@
              * @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) {
@@ -913,13 +915,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
          */
@@ -935,6 +978,8 @@
                     + ", exemptFromEntitlementCheck= " + mRequestParcel.exemptFromEntitlementCheck
                     + ", showProvisioningUi= " + mRequestParcel.showProvisioningUi
                     + ", softApConfig= " + mRequestParcel.softApConfig
+                    + ", uid= " + mRequestParcel.uid
+                    + ", packageName= " + mRequestParcel.packageName
                     + " ]";
         }
 
@@ -950,7 +995,9 @@
                     && parcel.exemptFromEntitlementCheck == otherParcel.exemptFromEntitlementCheck
                     && parcel.showProvisioningUi == otherParcel.showProvisioningUi
                     && parcel.connectivityScope == otherParcel.connectivityScope
-                    && Objects.equals(parcel.softApConfig, otherParcel.softApConfig);
+                    && Objects.equals(parcel.softApConfig, otherParcel.softApConfig)
+                    && parcel.uid == otherParcel.uid
+                    && Objects.equals(parcel.packageName, otherParcel.packageName);
         }
 
         @Override
@@ -958,7 +1005,8 @@
             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);
         }
     }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
index ea7a353..789d5bb 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringRequestParcel.aidl
@@ -31,4 +31,6 @@
     boolean showProvisioningUi;
     int connectivityScope;
     SoftApConfiguration softApConfig;
+    int uid;
+    String packageName;
 }
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 506fa56..a0604f2 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -70,13 +70,13 @@
 import com.android.internal.util.State;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.IIpv4PrefixRequest;
 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.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 +124,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 +242,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;
 
@@ -298,7 +302,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 +310,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 +323,7 @@
         mLinkProperties = new LinkProperties();
         mUsingLegacyDhcp = config.useLegacyDhcpServer();
         mP2pLeasesSubnetPrefixLength = config.getP2pLeasesSubnetPrefixLength();
-        mPrivateAddressCoordinator = addressCoordinator;
+        mIsWifiP2pDedicatedIpEnabled = config.shouldEnableWifiP2pDedicatedIp();
         mDeps = deps;
         mTetheringMetrics = tetheringMetrics;
         resetLinkProperties();
@@ -391,6 +401,11 @@
         return mInterfaceParams;
     }
 
+    @VisibleForTesting
+    public IIpv4PrefixRequest getIpv4PrefixRequest() {
+        return mIpv4PrefixRequest;
+    }
+
     /**
      * Get the latest list of DHCP leases that was reported. Must be called on the IpServer looper
      * thread.
@@ -639,7 +654,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;
@@ -698,12 +713,24 @@
         return (mInterfaceType == TetheringManager.TETHERING_BLUETOOTH) && !SdkLevel.isAtLeastT();
     }
 
+    private boolean shouldUseWifiP2pDedicatedIp() {
+        return mIsWifiP2pDedicatedIpEnabled
+                && mInterfaceType == TetheringManager.TETHERING_WIFI_P2P;
+    }
+
     private LinkAddress requestIpv4Address(final int scope, final boolean useLastAddress) {
         if (mStaticIpv4ServerAddr != null) return mStaticIpv4ServerAddr;
 
         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() {
diff --git a/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java b/Tethering/src/com/android/networkstack/tethering/EntitlementManager.java
index b88b13b..cd57c8d 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,115 @@
     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.
+            if (SdkLevel.isAtLeastT()) {
+                // Create a user context for the current foreground user as UserManager#isAdmin()
+                // operates on the context user.
+                final int currentUserId = getCurrentUser();
+                final UserHandle currentUser = UserHandle.of(currentUserId);
+                final Context userContext = mContext.createContextAsUser(currentUser, 0);
+                final 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.");
+                    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 +232,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 +240,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 +493,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 +506,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 +522,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 +754,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/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 49bc86e..61833c2 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -216,10 +216,10 @@
      * Cookie added when registering {@link android.net.TetheringManager.TetheringEventCallback}.
      */
     private static class CallbackCookie {
-        public final boolean hasListClientsPermission;
+        public final boolean hasSystemPrivilege;
 
-        private CallbackCookie(boolean hasListClientsPermission) {
-            this.hasListClientsPermission = hasListClientsPermission;
+        private CallbackCookie(boolean hasSystemPrivilege) {
+            this.hasSystemPrivilege = hasSystemPrivilege;
         }
     }
 
@@ -253,7 +253,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 +263,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 +357,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.
@@ -1096,16 +1090,44 @@
     }
 
     // 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.
+     *
+     * @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();
@@ -1123,41 +1145,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 +1163,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);
+        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,11 +2001,11 @@
             final UpstreamNetworkState ns = (UpstreamNetworkState) o;
             switch (arg1) {
                 case UpstreamNetworkMonitor.EVENT_ON_LINKPROPERTIES:
-                    mPrivateAddressCoordinator.updateUpstreamPrefix(
+                    mRoutingCoordinator.updateUpstreamPrefix(
                             ns.linkProperties, ns.networkCapabilities, ns.network);
                     break;
                 case UpstreamNetworkMonitor.EVENT_ON_LOST:
-                    mPrivateAddressCoordinator.removeUpstreamPrefix(ns.network);
+                    mRoutingCoordinator.removeUpstreamPrefix(ns.network);
                     break;
             }
 
@@ -2078,7 +2075,7 @@
                     return;
                 }
 
-                mPrivateAddressCoordinator.maybeRemoveDeprecatedUpstreams();
+                mRoutingCoordinator.maybeRemoveDeprecatedUpstreams();
                 mUpstreamNetworkMonitor.startObserveAllNetworks();
 
                 // TODO: De-duplicate with updateUpstreamWanted() below.
@@ -2396,19 +2393,18 @@
 
     /** Register tethering event callback */
     void registerTetheringEventCallback(ITetheringEventCallback callback) {
-        final boolean hasListPermission =
-                hasCallingPermission(NETWORK_SETTINGS)
-                        || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
-                        || hasCallingPermission(NETWORK_STACK);
+        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(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;
@@ -2420,17 +2416,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;
     }
@@ -2489,12 +2474,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.
                 }
@@ -2502,6 +2489,18 @@
         } finally {
             mTetheringEventCallbacks.finishBroadcast();
         }
+
+        if (DBG) {
+            // Use a CallbackCookie with system privilege so nothing is redacted.
+            TetherStatesParcel parcel =
+                    buildTetherStatesParcel(new CallbackCookie(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) {
@@ -2511,7 +2510,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.
@@ -2666,11 +2665,6 @@
 
         dumpBpf(pw);
 
-        pw.println("Private address coordinator:");
-        pw.increaseIndent();
-        mPrivateAddressCoordinator.dump(pw);
-        pw.decreaseIndent();
-
         if (mWearableConnectionManager != null) {
             pw.println("WearableConnectionManager:");
             pw.increaseIndent();
@@ -2751,7 +2745,7 @@
                     return;
             }
             mTetherMainSM.sendMessage(which, state, 0, who);
-            sendTetherStateChangedBroadcast();
+            notifyTetherStatesChanged();
         }
 
         @Override
@@ -2824,8 +2818,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 81f057c..a4823ca 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -36,6 +36,7 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.modules.utils.build.SdkLevel;
+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;
@@ -136,7 +137,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);
         }
@@ -175,18 +179,6 @@
     }
 
     /**
-     * Make PrivateAddressCoordinator to be used by Tethering.
-     */
-    public PrivateAddressCoordinator makePrivateAddressCoordinator(
-            Context ctx, TetheringConfiguration cfg) {
-        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
-        return new PrivateAddressCoordinator(
-                cm::getAllNetworks,
-                cfg.isRandomPrefixBaseEnabled(),
-                cfg.shouldEnableWifiP2pDedicatedIp());
-    }
-
-    /**
      * Make BluetoothPanShim object to enable/disable bluetooth tethering.
      *
      * TODO: use BluetoothPan directly when mainline module is built with API 32.
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index 454cbf1..3cb5f99 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;
@@ -138,8 +139,10 @@
                     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 +241,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 +265,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);
@@ -290,9 +305,9 @@
 
             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 +320,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 +345,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/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index 6de4062..087ce44 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -75,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;
@@ -433,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(
@@ -447,7 +447,7 @@
                     + ", userType: "
                     + reported.getUserType().getNumber()
                     + ", upstreamTypes: "
-                    + Arrays.toString(upstreamEvents)
+                    + Arrays.toString(reported.getUpstreamEvents().toByteArray())
                     + ", durationMillis: "
                     + reported.getDurationMillis());
         }
@@ -479,10 +479,7 @@
     @VisibleForTesting
     @NonNull
     DataUsage getLastReportedUsageFromUpstreamType(@NonNull UpstreamType type) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         return mLastReportedUpstreamUsage.getOrDefault(type, EMPTY);
     }
 
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 423b9b8..01f3af9 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 {
@@ -574,13 +574,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..1bbea94 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -80,7 +80,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 +213,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -253,7 +253,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -283,7 +283,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -357,7 +357,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -423,7 +423,7 @@
         // client, which is not possible in this test.
     }
 
-    private void checkTetheredClientCallbacks(final TapPacketReader packetReader,
+    private void checkTetheredClientCallbacks(final PollPacketReader packetReader,
             final MyTetheringEventCallback tetheringEventCallback) throws Exception {
         // Create a fake client.
         byte[] clientMacAddr = new byte[6];
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/EntitlementManagerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
index c2e1617..8626b18 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/EntitlementManagerTest.java
@@ -38,6 +38,7 @@
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
 import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 import static com.android.networkstack.apishim.ConstantsShim.KEY_CARRIER_SUPPORTS_TETHERING_BOOL;
+import static com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import static com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
@@ -71,11 +72,13 @@
 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;
 
@@ -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,8 +184,10 @@
         protected Intent runUiTetherProvisioning(int type,
                 final TetheringConfiguration config, final ResultReceiver receiver) {
             Intent intent = super.runUiTetherProvisioning(type, config, receiver);
-            assertUiTetherProvisioningIntent(type, config, receiver, intent);
-            uiProvisionCount++;
+            if (intent != null) {
+                assertUiTetherProvisioningIntent(type, config, receiver, intent);
+                uiProvisionCount++;
+            }
             receiver.send(fakeEntitlementResult, null);
             return intent;
         }
@@ -195,7 +213,7 @@
             Intent intent = super.runSilentTetherProvisioning(type, config, receiver);
             assertSilentTetherProvisioning(type, config, intent);
             silentProvisionCount++;
-            addDownstreamMapping(type, fakeEntitlementResult);
+            mEnMgr.addDownstreamMapping(type, fakeEntitlementResult);
             return intent;
         }
 
@@ -217,6 +235,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 +278,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,7 +347,7 @@
     @Test
     public void testRequestLastEntitlementCacheValue() throws Exception {
         // 1. Entitlement check is not required.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         ResultReceiver receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -329,8 +356,8 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
 
         setupForRequiredProvisioning();
         // 2. No cache value and don't need to run entitlement check.
@@ -342,10 +369,10 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 3. No cache value and ui entitlement check is needed.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_PROVISIONING_FAILED;
         receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -354,11 +381,11 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        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;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -367,10 +394,10 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        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;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -379,10 +406,10 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
         mLooper.dispatchAll();
-        assertEquals(1, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(1, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 6. Cache value is TETHER_ERROR_NO_ERROR.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -391,8 +418,8 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 7. Test get value for other downstream type.
         receiver = new ResultReceiver(null) {
             @Override
@@ -402,10 +429,10 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_USB, receiver, false);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
         // 8. Test get value for invalid downstream type.
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -414,8 +441,8 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI_P2P, receiver, true);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        assertEquals(0, mDeps.uiProvisionCount);
+        mDeps.reset();
     }
 
     private void assertPermissionChangeCallback(InOrder inOrder) {
@@ -431,7 +458,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 +470,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 +483,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 +510,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 +546,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,16 +636,43 @@
         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);
     }
 
+    @IgnoreUpTo(SC_V2)
+    @Test
+    public void testUiProvisioningMultiUser_aboveT() {
+        doTestUiProvisioningMultiUser(true, 1);
+        doTestUiProvisioningMultiUser(false, 0);
+    }
+
+    @IgnoreAfter(SC_V2)
+    @Test
+    public void testUiProvisioningMultiUser_belowT() {
+        doTestUiProvisioningMultiUser(true, 1);
+        doTestUiProvisioningMultiUser(false, 1);
+    }
+
+    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);
+    }
+
     @Test
     public void testsetExemptedDownstreamType() throws Exception {
         setupForRequiredProvisioning();
@@ -631,7 +685,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 +693,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 +714,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,7 +736,7 @@
             throws Exception {
         setupCarrierConfig(false);
         setupForRequiredProvisioning();
-        mEnMgr.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
+        mDeps.fakeEntitlementResult = TETHER_ERROR_NO_ERROR;
         ResultReceiver receiver = new ResultReceiver(null) {
             @Override
             protected void onReceiveResult(int resultCode, Bundle resultData) {
@@ -691,8 +745,8 @@
         };
         mEnMgr.requestLatestTetheringEntitlementResult(TETHERING_WIFI, receiver, false);
         mLooper.dispatchAll();
-        assertEquals(0, mEnMgr.uiProvisionCount);
-        mEnMgr.reset();
+        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 a5c06f3..1608e1a 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -24,7 +24,9 @@
 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;
@@ -33,6 +35,9 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+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;
@@ -46,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;
@@ -71,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");
@@ -91,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
@@ -106,25 +129,32 @@
         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(
-                                mConnectivityMgr::getAllNetworks,
-                                mConfig.isRandomPrefixBaseEnabled(),
-                                mConfig.shouldEnableWifiP2pDedicatedIp()));
+                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);
@@ -133,25 +163,22 @@
     @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);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
     }
 
     @Test
@@ -159,50 +186,47 @@
         // - 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 = requestDownstreamAddress(mHotspotIpServer);
         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 LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress usbAddress =
+                requestStickyDownstreamAddress(mUsbIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
 
-        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,
@@ -259,10 +283,11 @@
     }
 
     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) {
@@ -273,40 +298,22 @@
     }
 
     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 {
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertNotNull(hotspotAddress);
 
-        final LinkAddress localHotspotAddress = requestDownstreamAddress(mLocalHotspotIpServer,
-                CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
+        final LinkAddress localHotspotAddress =
+                requestStickyDownstreamAddress(mLocalHotspotIpServer, CONNECTIVITY_SCOPE_LOCAL);
         assertNotNull(localHotspotAddress);
 
         final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
@@ -317,7 +324,7 @@
 
     @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);
 
@@ -343,14 +350,9 @@
     private void startedPrefixBaseTest(final String expected, final int randomIntForPrefixBase)
             throws Exception {
         mPrivateAddressCoordinator =
-                spy(
-                        new PrivateAddressCoordinator(
-                                mConnectivityMgr::getAllNetworks,
-                                mConfig.isRandomPrefixBaseEnabled(),
-                                mConfig.shouldEnableWifiP2pDedicatedIp()));
+                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/TetheringServiceTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
index c0d7ad4..0dbf772 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
@@ -33,12 +33,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 +83,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 +93,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 +135,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
@@ -330,6 +341,15 @@
         });
 
         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();
+        });
+
+        runAsTetherPrivileged((result) -> {
             runStartTethering(result, request);
             verifyNoMoreInteractionsForTethering();
         });
@@ -445,6 +465,13 @@
             verifyNoMoreInteractionsForTethering();
         });
 
+        runAsTetherPrivileged((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, TEST_WRONG_PACKAGE, TEST_ATTRIBUTION_TAG);
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractions(mTethering);
+        });
+
         runAsWriteSettings((none) -> {
             runRequestLatestTetheringEntitlementResult();
             verify(mTethering).isTetherProvisioningRequired();
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 9a4945e..d0c036f 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -96,6 +96,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;
@@ -191,7 +192,9 @@
 import com.android.internal.util.test.FakeSettingsProvider;
 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;
@@ -292,7 +295,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());
@@ -316,12 +319,12 @@
     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;
@@ -487,8 +490,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;
         }
 
@@ -535,13 +546,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(
@@ -681,6 +685,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);
@@ -864,6 +869,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);
     }
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_helpers.h b/bpf/headers/include/bpf_helpers.h
index ac5ffda..b994a9f 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -403,6 +403,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/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 9a049c7..c2a1d6e 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -1288,6 +1288,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;
@@ -1657,8 +1659,17 @@
     }
 
     // unreachable before U QPR3
-    ALOGI("done, transferring control to platform bpfloader.");
+    {
+      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, };
     execve(args[0], (char**)args, envp);
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 8e4c2c6..50e0329 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -120,18 +120,22 @@
     }
 
     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,
@@ -161,12 +165,16 @@
     }
 
     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();
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index cbe856d..ed0eed5 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -709,32 +709,32 @@
     return block_port(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_14)
+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/common/flags.aconfig b/common/flags.aconfig
index 4c6d8ba..17ef94b 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"
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 14b70d0..8cc2bb4 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -35,3 +35,21 @@
     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/api/system-current.txt b/framework-t/api/system-current.txt
index 09a3681..5f8f0e3 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -500,12 +500,18 @@
 
   @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>);
@@ -520,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);
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/TrafficStats.java b/framework-t/src/android/net/TrafficStats.java
index 3b6a69b..caf3152 100644
--- a/framework-t/src/android/net/TrafficStats.java
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -18,7 +18,10 @@
 
 import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
 
+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,19 +32,21 @@
 import android.compat.annotation.UnsupportedAppUsage;
 import android.content.Context;
 import android.media.MediaPlayer;
+import android.net.netstats.StatsResult;
 import android.os.Binder;
 import android.os.Build;
 import android.os.RemoteException;
 import android.os.StrictMode;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+
 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;
 
 
 /**
@@ -177,10 +182,14 @@
     /** @hide */
     public static final int TAG_SYSTEM_PROBE = 0xFFFFFF42;
 
+    @GuardedBy("TrafficStats.class")
     private static INetworkStatsService sStatsService;
+    @GuardedBy("TrafficStats.class")
+    private static INetworkStatsService sStatsServiceForTest = 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 +197,23 @@
         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) {
+        synchronized (TrafficStats.class) {
+            sStatsServiceForTest = statsService;
+        }
+    }
+
     /**
      * Snapshot of {@link NetworkStats} when the currently active profiling
      * session started, or {@code null} if no session active.
@@ -242,8 +268,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 +476,7 @@
      */
     @Deprecated
     public static void setThreadStatsUidSelf() {
-        setThreadStatsUid(android.os.Process.myUid());
+        setThreadStatsUid(getMyUid());
     }
 
     /**
@@ -591,7 +617,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) {
@@ -959,45 +985,35 @@
 
     /** @hide */
     public static long getUidStats(int uid, int type) {
-        if (!isEntryValueTypeValid(type)
-                || android.os.Process.myUid() != uid) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessUidStats(uid);
+            stats = getStatsService().getUidStats(uid);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /** @hide */
     public static long getTotalStats(int type) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessTotalStats();
+            stats = getStatsService().getTotalStats();
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /** @hide */
     public static long getIfaceStats(String iface, int type) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessIfaceStats(iface);
+            stats = getStatsService().getIfaceStats(iface);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /**
@@ -1094,7 +1110,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 +1143,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 +1173,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/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 150394b..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;
@@ -129,16 +128,6 @@
     private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST =
             "persist.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";
-
     @GuardedBy("sScanListeners")
     private static final WeakHashMap<ScanCallback, WeakReference<ScanListenerTransport>>
             sScanListeners = new WeakHashMap<>();
@@ -479,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
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/service/Android.bp b/networksecurity/service/Android.bp
index 52667ae..a41e6a0 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -32,6 +32,15 @@
         "service-connectivity-pre-jarjar",
     ],
 
+    static_libs: [
+        "auto_value_annotations",
+    ],
+
+    plugins: [
+        "auto_value_plugin",
+        "auto_annotation_plugin",
+    ],
+
     // This is included in service-connectivity which is 30+
     // TODO (b/293613362): allow APEXes to have service jars with higher min_sdk than the APEX
     // (service-connectivity is only used on 31+) and use 31 here
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..f86d127 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -15,6 +15,7 @@
  */
 package com.android.server.net.ct;
 
+import android.annotation.NonNull;
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
@@ -28,13 +29,18 @@
 
 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.InvalidKeyException;
 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;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -42,41 +48,23 @@
 
     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;
+
+    @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
 
     @VisibleForTesting
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
-            CertificateTransparencyInstaller installer,
-            byte[] publicKey) {
+            CertificateTransparencyInstaller installer) {
         mContext = context;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
         mInstaller = installer;
-        mPublicKey = publicKey;
     }
 
     CertificateTransparencyDownloader(Context context, DataStore dataStore) {
@@ -84,11 +72,12 @@
                 context,
                 dataStore,
                 new DownloadHelper(context),
-                new CertificateTransparencyInstaller(),
-                PUBLIC_KEY_BYTES);
+                new CertificateTransparencyInstaller());
     }
 
-    void registerReceiver() {
+    void initialize() {
+        mInstaller.addCompatibilityVersion(Config.COMPATIBILITY_VERSION);
+
         IntentFilter intentFilter = new IntentFilter();
         intentFilter.addAction(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
         mContext.registerReceiver(this, intentFilter, Context.RECEIVER_EXPORTED);
@@ -98,6 +87,20 @@
         }
     }
 
+    void setPublicKey(String publicKey) throws GeneralSecurityException {
+        mPublicKey =
+                Optional.of(
+                        KeyFactory.getInstance("RSA")
+                                .generatePublic(
+                                        new X509EncodedKeySpec(
+                                                Base64.getDecoder().decode(publicKey))));
+    }
+
+    @VisibleForTesting
+    void resetPublicKey() {
+        mPublicKey = Optional.empty();
+    }
+
     void startMetadataDownload(String metadataUrl) {
         long downloadId = download(metadataUrl);
         if (downloadId == -1) {
@@ -146,19 +149,18 @@
     }
 
     private void handleMetadataDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Metadata download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
-
         startContentDownload(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
     }
 
     private void handleContentDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Content download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
 
@@ -186,7 +188,7 @@
         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 = mInstaller.install(Config.COMPATIBILITY_VERSION, inputStream, version);
         } catch (IOException e) {
             Log.e(TAG, "Could not install new content", e);
             return;
@@ -201,10 +203,17 @@
         }
     }
 
+    private void handleDownloadFailed(DownloadStatus status) {
+        Log.e(TAG, "Content download failed with " + status);
+        // TODO(378626065): Report failure via statsd.
+    }
+
     private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
+        if (!mPublicKey.isPresent()) {
+            throw new InvalidKeyException("Missing public key for signature verification");
+        }
         Signature verifier = Signature.getInstance("SHA256withRSA");
-        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
-        verifier.initVerify(keyFactory.generatePublic(new X509EncodedKeySpec(mPublicKey)));
+        verifier.initVerify(mPublicKey.get());
         ContentResolver contentResolver = mContext.getContentResolver();
 
         try (InputStream fileStream = contentResolver.openInputStream(file);
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index a263546..0ae982d 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -23,6 +23,7 @@
 import android.text.TextUtils;
 import android.util.Log;
 
+import java.security.GeneralSecurityException;
 import java.util.concurrent.Executors;
 
 /** Listener class for the Certificate Transparency Phenotype flags. */
@@ -42,7 +43,7 @@
 
     void initialize() {
         mDataStore.load();
-        mCertificateTransparencyDownloader.registerReceiver();
+        mCertificateTransparencyDownloader.initialize();
         DeviceConfig.addOnPropertiesChangedListener(
                 Config.NAMESPACE_NETWORK_SECURITY, Executors.newSingleThreadExecutor(), this);
         if (Config.DEBUG) {
@@ -57,21 +58,35 @@
             return;
         }
 
+        String newPublicKey =
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_PUBLIC_KEY,
+                        /* defaultValue= */ "");
         String newVersion =
-                DeviceConfig.getString(Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_VERSION, "");
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_VERSION,
+                        /* defaultValue= */ "");
         String newContentUrl =
                 DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_CONTENT_URL, "");
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_CONTENT_URL,
+                        /* defaultValue= */ "");
         String newMetadataUrl =
                 DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_METADATA_URL, "");
-        if (TextUtils.isEmpty(newVersion)
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_METADATA_URL,
+                        /* defaultValue= */ "");
+        if (TextUtils.isEmpty(newPublicKey)
+                || TextUtils.isEmpty(newVersion)
                 || TextUtils.isEmpty(newContentUrl)
                 || TextUtils.isEmpty(newMetadataUrl)) {
             return;
         }
 
         if (Config.DEBUG) {
+            Log.d(TAG, "newPublicKey=" + newPublicKey);
             Log.d(TAG, "newVersion=" + newVersion);
             Log.d(TAG, "newContentUrl=" + newContentUrl);
             Log.d(TAG, "newMetadataUrl=" + newMetadataUrl);
@@ -88,6 +103,13 @@
             return;
         }
 
+        try {
+            mCertificateTransparencyDownloader.setPublicKey(newPublicKey);
+        } catch (GeneralSecurityException e) {
+            Log.e(TAG, "Error setting the public Key", e);
+            return;
+        }
+
         // TODO: handle the case where there is already a pending download.
 
         mDataStore.setProperty(Config.VERSION_PENDING, newVersion);
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
index 82dcadf..4ca97eb 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyInstaller.java
@@ -15,148 +15,78 @@
  */
 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;
+import java.util.HashMap;
+import java.util.Map;
 
 /** 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 Map<String, CompatibilityVersion> mCompatVersions = new HashMap<>();
 
-    private final File mCertificateTransparencyDir;
-    private final File mCurrentDirSymlink;
+    // The CT root directory.
+    private final File mRootDirectory;
 
-    CertificateTransparencyInstaller(File certificateTransparencyDir) {
-        mCertificateTransparencyDir = certificateTransparencyDir;
-        mCurrentDirSymlink = new File(certificateTransparencyDir, CURRENT_DIR_SYMLINK_NAME);
+    public CertificateTransparencyInstaller(File rootDirectory) {
+        mRootDirectory = rootDirectory;
     }
 
-    CertificateTransparencyInstaller() {
-        this(new File(CT_DIR_NAME));
+    public CertificateTransparencyInstaller(String rootDirectoryPath) {
+        this(new File(rootDirectoryPath));
+    }
+
+    public CertificateTransparencyInstaller() {
+        this(Config.CT_ROOT_DIRECTORY_PATH);
+    }
+
+    void addCompatibilityVersion(String versionName) {
+        removeCompatibilityVersion(versionName);
+        CompatibilityVersion newCompatVersion =
+                new CompatibilityVersion(new File(mRootDirectory, versionName));
+        mCompatVersions.put(versionName, newCompatVersion);
+    }
+
+    void removeCompatibilityVersion(String versionName) {
+        CompatibilityVersion compatVersion = mCompatVersions.remove(versionName);
+        if (compatVersion != null && !compatVersion.delete()) {
+            Log.w(TAG, "Could not delete compatibility version directory.");
+        }
+    }
+
+    CompatibilityVersion getCompatibilityVersion(String versionName) {
+        return mCompatVersions.get(versionName);
     }
 
     /**
      * Install a new log list to use during SCT verification.
      *
+     * @param compatibilityVersion the compatibility version of the new log list
      * @param newContent an input stream providing the log list
-     * @param version the version of the new log list
+     * @param version the minor version of the new log list
      * @return true if the log list was installed successfully, false otherwise.
      * @throws IOException if the list cannot be saved in the CT directory.
      */
-    public boolean install(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 {
+    public boolean install(String compatibilityVersion, InputStream newContent, String version)
+            throws IOException {
+        CompatibilityVersion compatVersion = mCompatVersions.get(compatibilityVersion);
+        if (compatVersion == null) {
+            Log.e(TAG, "No compatibility version for " + compatibilityVersion);
             return false;
         }
-    }
+        // Ensure root directory exists and is readable.
+        DirectoryUtils.makeDir(mRootDirectory);
 
-    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;
-                }
-            }
+        if (!compatVersion.install(newContent, version)) {
+            Log.e(TAG, "Failed to install logs for compatibility version " + compatibilityVersion);
+            return false;
         }
-        return success;
+        Log.i(TAG, "New logs installed at " + compatVersion.getLogsDir());
+        return true;
     }
 }
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..27488b5
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
@@ -0,0 +1,135 @@
+/*
+ * 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.system.ErrnoException;
+import android.system.Os;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+
+/** Represents a compatibility version directory. */
+class CompatibilityVersion {
+
+    static final String LOGS_DIR_PREFIX = "logs-";
+    static final String LOGS_LIST_FILE_NAME = "log_list.json";
+
+    private static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
+
+    private final File mRootDirectory;
+    private final File mCurrentLogsDirSymlink;
+
+    private File mCurrentLogsDir = null;
+
+    CompatibilityVersion(File rootDirectory) {
+        mRootDirectory = rootDirectory;
+        mCurrentLogsDirSymlink = new File(mRootDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
+    }
+
+    /**
+     * Installs a log list within this compatibility version directory.
+     *
+     * @param newContent an input stream providing the log list
+     * @param version the version number of the log list
+     * @return true if the log list was installed successfully, false otherwise.
+     * @throws IOException if the list cannot be saved in the CT directory.
+     */
+    boolean install(InputStream newContent, String version) throws IOException {
+        // To support atomically replacing the old configuration directory with the new there's a
+        // bunch of steps. We create a new directory with the logs and then do an atomic update of
+        // the current symlink to point to the new directory.
+        // 1. Ensure that the root directory exists and is readable.
+        DirectoryUtils.makeDir(mRootDirectory);
+
+        File newLogsDir = new File(mRootDirectory, 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())) {
+                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 this to the target symlink to get an atomic update.
+            File tempSymlink = new File(mRootDirectory, "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
+        mCurrentLogsDir = newLogsDir;
+        deleteOldLogDirectories();
+        return true;
+    }
+
+    File getRootDir() {
+        return mRootDirectory;
+    }
+
+    File getLogsDir() {
+        return mCurrentLogsDir;
+    }
+
+    File getLogsDirSymlink() {
+        return mCurrentLogsDirSymlink;
+    }
+
+    File getLogsFile() {
+        return new File(mCurrentLogsDir, LOGS_LIST_FILE_NAME);
+    }
+
+    boolean delete() {
+        return DirectoryUtils.removeDir(mRootDirectory);
+    }
+
+    private void deleteOldLogDirectories() throws IOException {
+        if (!mRootDirectory.exists()) {
+            return;
+        }
+        File currentTarget = mCurrentLogsDirSymlink.getCanonicalFile();
+        for (File file : mRootDirectory.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 2a6b8e2..242f13a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,6 +33,10 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
+    // CT directory
+    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__";
@@ -40,6 +44,7 @@
     static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
     static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
     static final String FLAG_VERSION = FLAGS_PREFIX + "version";
+    static final String FLAG_PUBLIC_KEY = FLAGS_PREFIX + "public_key";
 
     // properties
     static final String VERSION_PENDING = "version_pending";
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/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index a056c35..fb55295 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
@@ -31,6 +31,8 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -48,9 +50,10 @@
 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)
@@ -60,18 +63,20 @@
     @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
 
     private PrivateKey mPrivateKey;
+    private PublicKey mPublicKey;
     private Context mContext;
     private File mTempFile;
     private DataStore mDataStore;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
     @Before
-    public void setUp() throws IOException, NoSuchAlgorithmException {
+    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");
@@ -80,16 +85,13 @@
 
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
-                        mContext,
-                        mDataStore,
-                        mDownloadHelper,
-                        mCertificateTransparencyInstaller,
-                        keyPair.getPublic().getEncoded());
+                        mContext, mDataStore, mDownloadHelper, mCertificateTransparencyInstaller);
     }
 
     @After
     public void tearDown() {
         mTempFile.delete();
+        mCertificateTransparencyDownloader.resetPublicKey();
     }
 
     @Test
@@ -115,11 +117,11 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteSuccessful() {
+    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
         long metadataId = 123;
         mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(true);
-
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(makeSuccessfulDownloadStatus(metadataId));
         long contentId = 666;
         String contentUrl = "http://test-content.org";
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
@@ -132,58 +134,91 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteFailed() {
+    public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
         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);
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(metadataId),
+                        makeStorageErrorDownloadStatus(metadataId));
 
-        mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
 
         verify(mDownloadHelper, never()).startDownload(contentUrl);
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallSuccessful() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
+        mCertificateTransparencyDownloader.setPublicKey(
+                Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
+        when(mCertificateTransparencyInstaller.install(
+                        eq(Config.COMPATIBILITY_VERSION), 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();
-
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
-        verify(mCertificateTransparencyInstaller, times(1)).install(any(), eq(version));
+        verify(mCertificateTransparencyInstaller, times(1))
+                .install(eq(Config.COMPATIBILITY_VERSION), 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());
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallFails() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
+        mDataStore.setProperty(Config.VERSION_PENDING, "123");
+        long contentId = 666;
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(contentId),
+                        makeStorageErrorDownloadStatus(contentId));
+
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+
+        verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(false);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
+        when(mCertificateTransparencyInstaller.install(
+                        eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
+                .thenReturn(false);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -194,19 +229,44 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteVerificationFails() throws IOException {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_verificationFail_doNotInstall()
+            throws IOException {
+        String version = "456";
         long contentId = 666;
         Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
         long metadataId = 123;
         Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
-        verify(mCertificateTransparencyInstaller, never()).install(any(), eq(version));
+        verify(mCertificateTransparencyInstaller, never())
+                .install(eq(Config.COMPATIBILITY_VERSION), any(), eq(version));
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_missingVerificationPublicKey_doNotInstall()
+            throws Exception {
+        String version = "456";
+        long contentId = 666;
+        File logListFile = File.createTempFile("log_list", "json");
+        Uri contentUri = Uri.fromFile(logListFile);
+        long metadataId = 123;
+        File metadataFile = sign(logListFile);
+        Uri metadataUri = Uri.fromFile(metadataFile);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        verify(mCertificateTransparencyInstaller, never())
+                .install(eq(Config.COMPATIBILITY_VERSION), any(), eq(version));
         assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
         assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
@@ -217,7 +277,7 @@
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
-    private void setUpDownloadComplete(
+    private void setUpContentDownloadCompleteSuccessful(
             String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
             throws IOException {
         mDataStore.setProperty(Config.VERSION_PENDING, version);
@@ -228,10 +288,34 @@
 
         mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(makeSuccessfulDownloadStatus(contentId));
         when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
     }
 
+    private DownloadStatus makeSuccessfulDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_SUCCESSFUL)
+                .build();
+    }
+
+    private DownloadStatus makeStorageErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_INSUFFICIENT_SPACE)
+                .build();
+    }
+
+    private DownloadStatus makeHttpErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_HTTP_DATA_ERROR)
+                .build();
+    }
+
     private File sign(File file) throws IOException, GeneralSecurityException {
         File signatureFile = File.createTempFile("log_list-metadata", "sig");
         Signature signer = Signature.getInstance("SHA256withRSA");
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
index bfb8bdf..50d3f23 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyInstallerTest.java
@@ -17,11 +17,9 @@
 
 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.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -39,98 +37,134 @@
 @RunWith(JUnit4.class)
 public class CertificateTransparencyInstallerTest {
 
+    private static final String TEST_VERSION = "test-v1";
+
     private File mTestDir =
             new File(
                     InstrumentationRegistry.getInstrumentation().getContext().getFilesDir(),
                     "test-dir");
-    private File mTestSymlink =
-            new File(mTestDir, CertificateTransparencyInstaller.CURRENT_DIR_SYMLINK_NAME);
     private CertificateTransparencyInstaller mCertificateTransparencyInstaller =
             new CertificateTransparencyInstaller(mTestDir);
 
     @Before
     public void setUp() {
-        CertificateTransparencyInstaller.deleteContentsAndDir(mTestDir);
+        mCertificateTransparencyInstaller.addCompatibilityVersion(TEST_VERSION);
+    }
+
+    @After
+    public void tearDown() {
+        mCertificateTransparencyInstaller.removeCompatibilityVersion(TEST_VERSION);
+        DirectoryUtils.removeDir(mTestDir);
+    }
+
+    @Test
+    public void testCompatibilityVersion_installSuccessful() throws IOException {
+        assertThat(mTestDir.mkdir()).isTrue();
+        String content = "i_am_compatible";
+        String version = "i_am_version";
+        CompatibilityVersion compatVersion =
+                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
+
+        try (InputStream inputStream = asStream(content)) {
+            assertThat(compatVersion.install(inputStream, version)).isTrue();
+        }
+        File logsDir = compatVersion.getLogsDir();
+        assertThat(logsDir.exists()).isTrue();
+        assertThat(logsDir.isDirectory()).isTrue();
+        assertThat(logsDir.getAbsolutePath())
+                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
+        File logsListFile = compatVersion.getLogsFile();
+        assertThat(logsListFile.exists()).isTrue();
+        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
+        assertThat(readAsString(logsListFile)).isEqualTo(content);
+        File logsSymlink = compatVersion.getLogsDirSymlink();
+        assertThat(logsSymlink.exists()).isTrue();
+        assertThat(logsSymlink.isDirectory()).isTrue();
+        assertThat(logsSymlink.getAbsolutePath())
+                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION + "/current");
+        assertThat(logsSymlink.getCanonicalPath()).isEqualTo(logsDir.getCanonicalPath());
+
+        assertThat(compatVersion.delete()).isTrue();
+        assertThat(logsDir.exists()).isFalse();
+        assertThat(logsSymlink.exists()).isFalse();
+        assertThat(logsListFile.exists()).isFalse();
+    }
+
+    @Test
+    public void testCompatibilityVersion_versionInstalledFailed() throws IOException {
+        assertThat(mTestDir.mkdir()).isTrue();
+
+        CompatibilityVersion compatVersion =
+                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
+        File rootDir = compatVersion.getRootDir();
+        assertThat(rootDir.mkdir()).isTrue();
+
+        String existingVersion = "666";
+        File existingLogDir =
+                new File(rootDir, CompatibilityVersion.LOGS_DIR_PREFIX + existingVersion);
+        assertThat(existingLogDir.mkdir()).isTrue();
+
+        String existingContent = "somebody_tried_to_install_me_but_failed_halfway_through";
+        File logsListFile = new File(existingLogDir, CompatibilityVersion.LOGS_LIST_FILE_NAME);
+        assertThat(logsListFile.createNewFile()).isTrue();
+        writeToFile(logsListFile, existingContent);
+
+        String newContent = "i_am_the_real_content";
+        try (InputStream inputStream = asStream(newContent)) {
+            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
+        }
+
+        assertThat(readAsString(logsListFile)).isEqualTo(newContent);
     }
 
     @Test
     public void testCertificateTransparencyInstaller_installSuccessfully() throws IOException {
         String content = "i_am_a_certificate_and_i_am_transparent";
         String version = "666";
-        boolean success = false;
 
         try (InputStream inputStream = asStream(content)) {
-            success = mCertificateTransparencyInstaller.install(inputStream, version);
+            assertThat(
+                            mCertificateTransparencyInstaller.install(
+                                    TEST_VERSION, inputStream, version))
+                    .isTrue();
         }
 
-        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);
+        CompatibilityVersion compatVersion =
+                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
+        File logsDir = compatVersion.getLogsDir();
         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(logsDir.getAbsolutePath())
+                .startsWith(mTestDir.getAbsolutePath() + "/" + TEST_VERSION);
+        File logsListFile = compatVersion.getLogsFile();
         assertThat(logsListFile.exists()).isTrue();
+        assertThat(logsListFile.getAbsolutePath()).startsWith(logsDir.getAbsolutePath());
         assertThat(readAsString(logsListFile)).isEqualTo(content);
     }
 
     @Test
     public void testCertificateTransparencyInstaller_versionIsAlreadyInstalled()
-            throws IOException, ErrnoException {
+            throws IOException {
         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;
+        CompatibilityVersion compatVersion =
+                mCertificateTransparencyInstaller.getCompatibilityVersion(TEST_VERSION);
+
+        DirectoryUtils.makeDir(mTestDir);
+        try (InputStream inputStream = asStream(existingContent)) {
+            assertThat(compatVersion.install(inputStream, existingVersion)).isTrue();
+        }
 
         try (InputStream inputStream = asStream("i_will_be_ignored")) {
-            success = mCertificateTransparencyInstaller.install(inputStream, existingVersion);
+            assertThat(
+                            mCertificateTransparencyInstaller.install(
+                                    TEST_VERSION, inputStream, existingVersion))
+                    .isFalse();
         }
 
-        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);
+        assertThat(readAsString(compatVersion.getLogsFile())).isEqualTo(existingContent);
     }
 
     private static InputStream asStream(String string) throws IOException {
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/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
index b16d8bd..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);
+        }
     }
 
     /**
@@ -469,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/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..58defa9 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -416,13 +416,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 +433,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..356b738 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
@@ -107,7 +107,7 @@
         final QueryTaskConfig nextRunConfig = currentConfig.getConfigForNextRun(queryMode);
         long timeToRun;
         if (mLastScheduledQueryTaskArgs == null && !forceEnableBackoff) {
-            timeToRun = now + nextRunConfig.delayUntilNextTaskWithoutBackoffMs;
+            timeToRun = now + nextRunConfig.getDelayBeforeTaskWithoutBackoff();
         } else {
             timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs,
                     nextRunConfig, now, minRemainingTtl, lastSentTime, numOfQueriesBeforeBackoff,
@@ -133,7 +133,7 @@
     private static long calculateTimeToRun(@Nullable ScheduledQueryTaskArgs taskArgs,
             QueryTaskConfig queryTaskConfig, long now, long minRemainingTtl, long lastSentTime,
             int numOfQueriesBeforeBackoff, boolean forceEnableBackoff) {
-        final long baseDelayInMs = queryTaskConfig.delayUntilNextTaskWithoutBackoffMs;
+        final long baseDelayInMs = queryTaskConfig.getDelayBeforeTaskWithoutBackoff();
         if (!(forceEnableBackoff
                 || queryTaskConfig.shouldUseQueryBackoff(numOfQueriesBeforeBackoff))) {
             return lastSentTime + baseDelayInMs;
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 22f7a03..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;
 
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 a5dd536..a43486e 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(
@@ -545,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)) {
@@ -556,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);
             }
         }
     }
@@ -810,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..b640c32 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
@@ -19,7 +19,8 @@
 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;
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..dd4073f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
+++ b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
@@ -52,118 +52,124 @@
     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;
+    private final int queryIndex;
+    private final int queryMode;
 
-    QueryTaskConfig(long queryCount, int transactionId,
-            boolean expectUnicastResponse, boolean isFirstBurst, int burstCounter,
-            int queriesPerBurst, int timeBetweenBurstsInMs,
-            long delayUntilNextTaskWithoutBackoffMs) {
+    QueryTaskConfig(int queryMode, int queryIndex, int transactionId,
+            boolean expectUnicastResponse) {
+        this.queryMode = queryMode;
         this.transactionId = transactionId;
+        this.queryIndex = queryIndex;
         this.expectUnicastResponse = expectUnicastResponse;
-        this.queriesPerBurst = queriesPerBurst;
-        this.timeBetweenBurstsInMs = timeBetweenBurstsInMs;
-        this.burstCounter = burstCounter;
-        this.delayUntilNextTaskWithoutBackoffMs = delayUntilNextTaskWithoutBackoffMs;
-        this.isFirstBurst = isFirstBurst;
-        this.queryCount = queryCount;
     }
 
     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;
+        this(queryMode, 0, 1, true);
     }
 
-    long getDelayUntilNextTaskWithoutBackoff(boolean isFirstQueryInBurst,
-            boolean isLastQueryInBurst, int queryMode) {
-        if (isFirstQueryInBurst && queryMode == AGGRESSIVE_QUERY_MODE) {
-            return 0;
+    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;
         }
-        if (isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
+    }
+
+    private static int getQueryIndexInBurst(int queryIndex, int queryMode) {
+        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
+            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
+            return queryIndexAfterFirstBurst % QUERIES_PER_BURST_PASSIVE_MODE;
+        } else {
+            return queryIndex % QUERIES_PER_BURST;
+        }
+    }
+
+    private static boolean isFirstBurst(int queryIndex, int queryMode) {
+        return getBurstIndex(queryIndex, queryMode) == 0;
+    }
+
+    private static boolean isFirstQueryInBurst(int queryIndex, int queryMode) {
+        return getQueryIndexInBurst(queryIndex, queryMode) == 0;
+    }
+
+    // TODO: move delay calculations to MdnsQueryScheduler
+    long getDelayBeforeTaskWithoutBackoff() {
+        return getDelayBeforeTaskWithoutBackoff(queryIndex, queryMode);
+    }
+
+    private static long getDelayBeforeTaskWithoutBackoff(int queryIndex, int queryMode) {
+        final int burstIndex = getBurstIndex(queryIndex, queryMode);
+        final int queryIndexInBurst = getQueryIndexInBurst(queryIndex, queryMode);
+        if (queryIndexInBurst == 0) {
+            return getTimeToBurstMs(burstIndex, queryMode);
+        } else if (queryIndexInBurst == 1 && queryMode == AGGRESSIVE_QUERY_MODE) {
+            // In aggressive mode, the first 2 queries are sent without delay.
+            return 0;
         }
         return queryMode == AGGRESSIVE_QUERY_MODE
                 ? TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS
                 : TIME_BETWEEN_QUERIES_IN_BURST_MS;
     }
 
-    boolean getNextExpectUnicastResponse(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return false;
-        }
+    private boolean getExpectUnicastResponse(int queryIndex, int queryMode) {
         if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            return true;
+            if (isFirstQueryInBurst(queryIndex, queryMode)) {
+                return true;
+            }
         }
         return alwaysAskForUnicastResponse;
     }
 
-    int getNextTimeBetweenBurstsMs(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
+    /**
+     * 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;
         }
-        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);
+        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);
+        }
     }
 
     /**
      * Get new QueryTaskConfig for next run.
      */
     public QueryTaskConfig getConfigForNextRun(int queryMode) {
-        long newQueryCount = queryCount + 1;
+        final int newQueryIndex = queryIndex + 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));
+        return new QueryTaskConfig(queryMode, newQueryIndex, newTransactionId,
+                getExpectUnicastResponse(newQueryIndex, queryMode));
     }
 
     /**
@@ -171,9 +177,9 @@
      */
     public boolean shouldUseQueryBackoff(int numOfQueriesBeforeBackoff) {
         // Don't enable backoff mode during the burst or in the first burst
-        if (burstCounter != 0 || isFirstBurst) {
+        if (!isFirstQueryInBurst(queryIndex, queryMode) || isFirstBurst(queryIndex, queryMode)) {
             return false;
         }
-        return queryCount > numOfQueriesBeforeBackoff;
+        return queryIndex > numOfQueriesBeforeBackoff;
     }
 }
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..41b15dd 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,22 @@
 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.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 +47,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 +87,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,
@@ -318,4 +308,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/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 71f289e..67d0891 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -49,6 +49,7 @@
 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;
@@ -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);
     }
 
     /**
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 294a85a..fb712a1 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;
@@ -485,16 +486,23 @@
     @GuardedBy("mStatsLock")
     private long mLatestNetworkStatsUpdatedBroadcastScheduledTime = Long.MIN_VALUE;
 
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsTotalCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsIfaceCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
+    // A feature flag to control whether the client-side rate limit cache should be enabled.
+    static final String TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG =
+            "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 mAlwaysUseTrafficStatsServiceRateLimitCache;
+    private final boolean mIsTrafficStatsServiceRateLimitCacheEnabled;
     private final int mTrafficStatsRateLimitCacheExpiryDuration;
     private final int mTrafficStatsServiceRateLimitCacheMaxEntries;
+    private final TrafficStatsRateLimitCacheConfig mTrafficStatsRateLimitCacheClientSideConfig;
     private final boolean mBroadcastNetworkStatsUpdatedRateLimitEnabled;
 
 
@@ -688,23 +696,34 @@
             mEventLogger = null;
         }
 
-        mAlwaysUseTrafficStatsServiceRateLimitCache =
-                mDeps.alwaysUseTrafficStatsServiceRateLimitCache(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();
         mTrafficStatsServiceRateLimitCacheMaxEntries =
                 mDeps.getTrafficStatsServiceRateLimitCacheMaxEntries();
-        mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
-        mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
-        mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
+        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
@@ -964,13 +983,42 @@
         }
 
         /**
-         * Get whether TrafficStats service side 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 alwaysUseTrafficStatsServiceRateLimitCache(@NonNull Context ctx) {
-            return SdkLevel.isAtLeastV() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+        @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);
         }
 
@@ -2133,30 +2181,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 (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || 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
@@ -2173,24 +2239,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 (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || 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
@@ -2203,30 +2269,39 @@
         return entry;
     }
 
-    @NonNull
+    @Nullable
     @Override
-    public NetworkStats getTypelessTotalStats() {
+    public StatsResult getTotalStats() {
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || 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) {
@@ -2996,8 +3071,8 @@
             } catch (IOException e) {
                 pw.println("(failed to dump FastDataInput counters)");
             }
-            pw.print("trafficstats.service.cache.alwaysuse",
-                    mAlwaysUseTrafficStatsServiceRateLimitCache);
+            pw.print("trafficstats.service.cache.isenabled",
+                    mIsTrafficStatsServiceRateLimitCacheEnabled);
             pw.println();
             pw.print(TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
                     mTrafficStatsRateLimitCacheExpiryDuration);
@@ -3005,6 +3080,9 @@
             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..667aad1 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;
@@ -31,9 +30,8 @@
  * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
  * with an adjustable expiry duration to manage data freshness.
  */
-class TrafficStatsRateLimitCache {
-    private final Clock mClock;
-    private final long mExpiryDurationMs;
+class TrafficStatsRateLimitCache extends
+        LruCacheWithExpiry<TrafficStatsRateLimitCache.TrafficStatsCacheKey, NetworkStats.Entry> {
 
     /**
      * Constructs a new {@link TrafficStatsRateLimitCache} with the specified expiry duration.
@@ -43,19 +41,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, 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 +59,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 +77,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 +95,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 +106,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..567c079 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -90,6 +90,7 @@
     static_libs: [
         "libnet_utils_device_common_bpfjni",
         "libnet_utils_device_common_bpfutils",
+        "libnet_utils_device_common_timerfdjni",
     ],
     shared_libs: [
         "liblog",
@@ -310,7 +311,7 @@
     apex_available: ["com.android.tethering"],
 }
 
-genrule {
+java_genrule {
     name: "connectivity-jarjar-rules",
     defaults: ["jarjar-rules-combine-defaults"],
     srcs: [
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..0d0f6fc
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -121,6 +121,7 @@
 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.provider.DeviceConfig.NAMESPACE_TETHERING;
 import static android.system.OsConstants.ETH_P_ALL;
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
@@ -145,10 +146,12 @@
 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.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;
@@ -1610,6 +1613,18 @@
                     connectivityServiceInternalHandler);
         }
 
+        /** Returns the data inactivity timeout to be used for cellular networks */
+        public int getDefaultCellularDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING,
+                    CELLULAR_DATA_INACTIVITY_TIMEOUT, 10);
+        }
+
+        /** Returns the data inactivity timeout to be used for WiFi networks */
+        public int getDefaultWifiDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING,
+                    WIFI_DATA_INACTIVITY_TIMEOUT, 15);
+        }
+
         /**
          * @see DeviceConfigUtils#isTetheringFeatureEnabled
          */
@@ -1958,8 +1973,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 +2048,8 @@
             mCdmps = null;
         }
 
-        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+        mRoutingCoordinatorService =
+                new RoutingCoordinatorService(netd, this::getAllNetworks, mContext);
         mMulticastRoutingCoordinatorService =
                 mDeps.makeMulticastRoutingCoordinatorService(mHandler);
 
@@ -5530,7 +5551,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 +6023,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);
                 }
@@ -9121,11 +9140,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
@@ -10270,8 +10285,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 +10295,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 +10303,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 +10332,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 +10456,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 ?
@@ -10791,16 +10816,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 +13047,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 +13065,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 +13272,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 +13402,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/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..93335f1 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -44,6 +44,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/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..94b655f 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -68,6 +68,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.WakeupMessage;
+import com.android.net.module.util.HandlerUtils;
 import com.android.server.ConnectivityService;
 
 import java.io.PrintWriter;
@@ -1138,11 +1139,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 +1158,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 +1180,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 +1211,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();
     }
 
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 85258f8..f484027 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -435,6 +435,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 +626,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 +671,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: [
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/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
similarity index 73%
rename from Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
rename to staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index 1d5df61..bb95585 100644
--- a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.android.networkstack.tethering;
+package com.android.net.module.util;
 
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -24,31 +24,31 @@
 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.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
-import android.net.ip.IpServer;
+import android.os.RemoteException;
 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.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;
@@ -60,39 +60,66 @@
  * 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.
+ * 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;
-    private final ArraySet<IpServer> mDownstreams;
+    // 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 boolean mIsRandomPrefixBaseEnabled;
-    private final boolean mShouldEnableWifiP2pDedicatedIp;
+    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,
-            boolean isRandomPrefixBase,
-            boolean shouldEnableWifiP2pDedicatedIp) {
-        mDownstreams = new ArraySet<>();
+                                     Dependencies deps) {
+        mDownstreams = new ArrayMap<>();
         mUpstreamPrefixMap = new ArrayMap<>();
         mGetAllNetworksSupplier = getAllNetworksSupplier;
-        mIsRandomPrefixBaseEnabled = isRandomPrefixBase;
-        mShouldEnableWifiP2pDedicatedIp = shouldEnableWifiP2pDedicatedIp;
+        mDeps = deps;
         mCachedAddresses = new ArrayMap<AddressKey, LinkAddress>();
         // Reserved static addresses for bluetooth and wifi p2p.
         mCachedAddresses.put(new AddressKey(TETHERING_BLUETOOTH, CONNECTIVITY_SCOPE_GLOBAL),
@@ -141,12 +168,18 @@
     }
 
     private void handleMaybePrefixConflict(final List<IpPrefix> prefixes) {
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
+        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)) {
-                    downstream.sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
+                    try {
+                        request.getRequest().onIpv4PrefixConflict(target);
+                    } catch (RemoteException ignored) {
+                        // ignore
+                    }
                     break;
                 }
             }
@@ -172,37 +205,51 @@
         mUpstreamPrefixMap.removeAll(toBeRemoved);
     }
 
+    // TODO: There needs to be a reserveDownstreamAddress() method for the cases where
+    // TetheringRequest has been set a static IPv4 address.
+
     /**
-     * Pick a random available address and mark its prefix as in use for the provided IpServer,
-     * returns null if there is no available 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 requestDownstreamAddress(final IpServer ipServer, final int scope,
-            boolean useLastAddress) {
-        if (mShouldEnableWifiP2pDedicatedIp
-                && ipServer.interfaceType() == TETHERING_WIFI_P2P) {
-            return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
-        }
-
-        final AddressKey addrKey = new AddressKey(ipServer.interfaceType(), scope);
+    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 (useLastAddress && cachedAddress != null
-                && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
-            mDownstreams.add(ipServer);
+        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.add(ipServer);
-                mCachedAddresses.put(addrKey, newAddress);
+                mDownstreams.put(wrappedRequest, newAddress);
                 return newAddress;
             }
         }
@@ -212,7 +259,7 @@
     }
 
     private int getRandomPrefixIndex() {
-        if (!mIsRandomPrefixBaseEnabled) return 0;
+        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
@@ -305,8 +352,8 @@
     }
 
     /** Release downstream record for IpServer. */
-    public void releaseDownstream(final IpServer ipServer) {
-        mDownstreams.remove(ipServer);
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        mDownstreams.remove(new Ipv4PrefixRequest(request));
     }
 
     /** Clear current upstream prefixes records. */
@@ -346,8 +393,8 @@
 
         // IpServer may use manually-defined address (mStaticIpv4ServerAddr) which does not include
         // in mCachedAddresses.
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
+        for (LinkAddress downstream : mDownstreams.values()) {
+            final IpPrefix target = asIpPrefix(downstream);
 
             if (isConflictPrefix(prefix, target)) return target;
         }
@@ -355,11 +402,33 @@
         return null;
     }
 
-    @NonNull
-    private IpPrefix getDownstreamPrefix(final IpServer downstream) {
-        final LinkAddress address = downstream.getAddress();
+    private static IpPrefix asIpPrefix(LinkAddress addr) {
+        return new IpPrefix(addr.getAddress(), addr.getPrefixLength());
+    }
 
-        return asIpPrefix(address);
+    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 {
@@ -390,33 +459,27 @@
         }
     }
 
-    void dump(final IndentingPrintWriter pw) {
+    // TODO: dump PrivateAddressCoordinator when dumping RoutingCoordinatorService and apply
+    // indentation.
+    void dump(final PrintWriter 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());
+        for (LinkAddress downstream : mDownstreams.values()) {
+            pw.println(downstream);
         }
-        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/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/netlink/NetlinkUtils.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
index f34159e..541a375 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
@@ -30,7 +30,6 @@
 import static android.system.OsConstants.SO_RCVTIMEO;
 import static android.system.OsConstants.SO_SNDTIMEO;
 
-import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
 import static com.android.net.module.util.netlink.NetlinkConstants.hexify;
 import static com.android.net.module.util.netlink.NetlinkConstants.NLMSG_DONE;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTNL_FAMILY_IP6MR;
@@ -58,7 +57,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 
 /**
@@ -227,96 +225,6 @@
     }
 
     /**
-     * Sends an RTM_NEWLINK message to kernel to set a network interface up or down.
-     *
-     * @param ifName  The name of the network interface to modify.
-     * @param isUp    {@code true} to set the interface up, {@code false} to set it down.
-     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
-     */
-    public static boolean sendRtmSetLinkStateRequest(@NonNull String ifName, boolean isUp) {
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
-                ifName, 1 /*sequenceNumber*/, isUp);
-        if (msg == null) {
-            return false;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
-            return true;
-        } catch (ErrnoException e) {
-            Log.e(TAG, "Fail to set the interface " + ifName + " " + (isUp ? "up" : "down"), e);
-            return false;
-        }
-    }
-
-    /**
-     * Sends an RTM_NEWLINK message to kernel to rename a network interface.
-     *
-     * @param ifName     The current name of the network interface.
-     * @param newIfName  The new name to assign to the interface.
-     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
-     */
-    public static boolean sendRtmSetLinkNameRequest(
-            @NonNull String ifName, @NonNull String newIfName) {
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
-                ifName, 1 /*sequenceNumber*/, newIfName);
-        if (msg == null) {
-            return false;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
-            return true;
-        } catch (ErrnoException e) {
-            Log.e(TAG, "Fail to rename the interface from " + ifName + " to " + newIfName, e);
-            return false;
-        }
-    }
-
-    /**
-     * Gets the information of a network interface using a Netlink message.
-     * <p>
-     * This method sends a Netlink message to the kernel to request information about the specified
-     * network interface and returns a {@link RtNetlinkLinkMessage} containing the interface status.
-     *
-     * @param ifName The name of the network interface to query.
-     * @return An {@link RtNetlinkLinkMessage} containing the interface status, or {@code null} if
-     *         the interface does not exist or an error occurred during the query.
-     */
-    @Nullable
-    public static RtNetlinkLinkMessage getLinkRequest(@NonNull String ifName) {
-        final int ifIndex = new OsAccess().if_nametoindex(ifName);
-        if (ifIndex == OsAccess.INVALID_INTERFACE_INDEX) {
-            return null;
-        }
-
-        final AtomicReference<RtNetlinkLinkMessage> recvMsg = new AtomicReference<>();
-        final Consumer<RtNetlinkLinkMessage> handleNlMsg = (msg) -> {
-            if (msg.getHeader().nlmsg_type == RTM_NEWLINK
-                    && msg.getIfinfoHeader().index == ifIndex) {
-                recvMsg.set(msg);
-            }
-        };
-
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createGetLinkMessage(
-                ifName, 1 /*sequenceNumber*/);
-        if (msg == null) {
-            return null;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.getAndProcessNetlinkDumpMessages(
-                    bytes, NETLINK_ROUTE, RtNetlinkLinkMessage.class, handleNlMsg);
-        } catch (SocketException | InterruptedIOException | ErrnoException e) {
-            // Nothing we can do here.
-        }
-        return recvMsg.get();
-    }
-
-    /**
      * Create netlink socket with the given netlink protocol type and buffersize.
      *
      * @param nlProto the netlink protocol
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..80088b9
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
@@ -0,0 +1,149 @@
+/*
+ * 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.time.Clock;
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * An 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 Clock mClock;
+    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 clock            The {@link Clock} 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 Clock clock, long expiryDurationMs, int maxSize,
+            Predicate<V> shouldCacheValue) {
+        mClock = clock;
+        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<>(mClock.millis(), value));
+        }
+    }
+
+    /**
+     * Clear the cache.
+     */
+    public void clear() {
+        synchronized (mMap) {
+            mMap.evictAll();
+        }
+    }
+
+    private boolean isExpired(long timestamp) {
+        return mClock.millis() > 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/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/native/timerfdutils/Android.bp b/staticlibs/native/timerfdutils/Android.bp
new file mode 100644
index 0000000..939a2d2
--- /dev/null
+++ b/staticlibs/native/timerfdutils/Android.bp
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_team: "trendy_team_fwk_core_networking",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+cc_library_static {
+    name: "libnet_utils_device_common_timerfdjni",
+    srcs: [
+        "com_android_net_module_util_TimerFdUtils.cpp",
+    ],
+    header_libs: [
+        "jni_headers",
+    ],
+    shared_libs: [
+        "liblog",
+        "libnativehelper_compat_libc++",
+    ],
+    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/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp b/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
new file mode 100644
index 0000000..c4c960d
--- /dev/null
+++ b/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <errno.h>
+#include <jni.h>
+#include <nativehelper/JNIHelp.h>
+#include <nativehelper/scoped_utf_chars.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#include <time.h>
+#include <unistd.h>
+
+#define MSEC_PER_SEC 1000
+#define NSEC_PER_MSEC 1000000
+
+namespace android {
+
+static jint
+com_android_net_module_util_TimerFdUtils_createTimerFd(JNIEnv *env,
+                                                       jclass clazz) {
+  int tfd;
+  tfd = timerfd_create(CLOCK_BOOTTIME, 0);
+  if (tfd == -1) {
+    jniThrowErrnoException(env, "createTimerFd", tfd);
+  }
+  return tfd;
+}
+
+static void
+com_android_net_module_util_TimerFdUtils_setTime(JNIEnv *env, jclass clazz,
+                                                 jint tfd, jlong milliseconds) {
+  struct itimerspec new_value;
+  new_value.it_value.tv_sec = milliseconds / MSEC_PER_SEC;
+  new_value.it_value.tv_nsec = (milliseconds % MSEC_PER_SEC) * NSEC_PER_MSEC;
+  // Set the interval time to 0 because it's designed for repeated timer expirations after the
+  // initial expiration, which doesn't fit the current usage.
+  new_value.it_interval.tv_sec = 0;
+  new_value.it_interval.tv_nsec = 0;
+
+  int ret = timerfd_settime(tfd, 0, &new_value, NULL);
+  if (ret == -1) {
+    jniThrowErrnoException(env, "setTime", ret);
+  }
+}
+
+/*
+ * JNI registration.
+ */
+static const JNINativeMethod gMethods[] = {
+    /* name, signature, funcPtr */
+    {"createTimerFd", "()I",
+     (void *)com_android_net_module_util_TimerFdUtils_createTimerFd},
+    {"setTime", "(IJ)V",
+     (void *)com_android_net_module_util_TimerFdUtils_setTime},
+};
+
+int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+                                                      char const *class_name) {
+  return jniRegisterNativeMethods(env, class_name, gMethods, NELEM(gMethods));
+}
+
+}; // namespace android
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 2885460..419b338 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -29,6 +29,10 @@
     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
@@ -208,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/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/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/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 13e1dc0..2a26ef8 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -97,13 +97,9 @@
         "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..46e511e
--- /dev/null
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.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 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(
+            uiAutomation.executeShellCommand("printflags")).bufferedReader().use { reader ->
+                return reader.lines().anyMatch {
+                    it.contains(flagEnabledRegex)
+                }
+        }
+    }
+}
\ No newline at end of file
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/DeviceConfigRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
index 68248ca..785e55a 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
@@ -89,6 +89,7 @@
                 } cleanupStep {
                     runAsShell(WRITE_DEVICE_CONFIG) {
                         originalConfig.forEach { (key, value) ->
+                            Log.i(TAG, "Resetting config \"${key.second}\" to \"$value\"")
                             DeviceConfig.setProperty(
                                     key.first, key.second, value, false /* makeDefault */)
                         }
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/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
index 435fdd8..f6168af 100644
--- a/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
+++ b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
@@ -28,6 +28,7 @@
 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"
@@ -84,27 +85,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)) {
@@ -187,6 +189,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 e84ba3e..55ac860 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -178,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,
@@ -224,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:
@@ -304,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/hostdevice/com/android/testutils/MiscAsserts.kt b/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
index 1883387..d1d5649 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/MiscAsserts.kt
@@ -20,11 +20,13 @@
 
 import com.android.testutils.FunctionalUtils.ThrowingRunnable
 import java.lang.reflect.Modifier
+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 +120,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() + timeoutMs * 1000
+    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 920492f..bb1009b 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: [
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 97be91a..0ac9ce1 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -56,7 +56,7 @@
         "mts-tethering",
         "sts",
     ],
-    data: [
+    device_common_data: [
         ":CtsHostsideNetworkTestsApp",
         ":CtsHostsideNetworkTestsApp2",
         ":CtsHostsideNetworkCapTestsAppWithoutProperty",
diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp
index 40aa1e4..949be85 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -37,7 +37,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/net/Android.bp b/tests/cts/net/Android.bp
index a5ad7f2..9e57f69 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
diff --git a/tests/cts/net/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index a65316f..55b6494 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" />
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index b62db04..3a8252a 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;
@@ -2934,12 +2935,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 +3018,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 +3061,7 @@
 
         try {
             final Network cellNetwork = networkCallbackRule.requestCell();
+            ensureCellIsValidated();
             final Network wifiNetwork = prepareValidatedNetwork();
 
             final TestableNetworkCallback defaultCb =
@@ -3156,7 +3157,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 +3174,7 @@
     }
 
     private Network preparePartialConnectivity() throws Exception {
-        ensureCellIsValidatedBeforeMockingValidationUrls();
+        ensureCellIsValidated();
 
         prepareHttpServer();
         // Configure response code for partial connectivity
@@ -3183,7 +3189,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/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..1e2a212 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -72,7 +72,7 @@
 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
@@ -151,7 +151,7 @@
         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 +169,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 +340,7 @@
         }
     }
 
-    private fun isEthernetSupported() : Boolean {
+    private fun isEthernetSupported(): Boolean {
         return context.getSystemService(EthernetManager::class.java) != null
     }
 
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/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 60081d4..815c3a5 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,6 +109,10 @@
 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
@@ -115,6 +123,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Losing
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAddKeepalivePacketFilter
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAutomaticReconnectDisabled
@@ -133,6 +142,7 @@
 import com.android.testutils.assertThrows
 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
@@ -140,10 +150,13 @@
 import java.net.InetSocketAddress
 import java.net.Socket
 import java.security.MessageDigest
+import java.nio.ByteBuffer
 import java.time.Duration
 import java.util.Arrays
+import java.util.Random
 import java.util.UUID
 import java.util.concurrent.Executors
+import kotlin.collections.ArrayList
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
@@ -188,6 +201,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.
@@ -321,6 +339,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 +368,7 @@
 
     private fun createConnectedNetworkAgent(
         context: Context = realContext,
+        lp: LinkProperties? = null,
         specifier: String? = UUID.randomUUID().toString(),
         initialConfig: NetworkAgentConfig? = null,
         expectedInitSignalStrengthThresholds: IntArray = intArrayOf(),
@@ -350,7 +378,8 @@
         // 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 +390,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 +412,9 @@
         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)
     }
 
@@ -1501,15 +1532,75 @@
 
     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
         )
     }
 
+    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 +1610,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 +1641,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 +1664,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 +1680,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 +1730,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)
 
@@ -1674,6 +1767,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
@@ -1706,14 +1827,7 @@
                 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.
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
index 1a48983..10adee0 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 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..fef085d 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;
@@ -68,13 +69,16 @@
 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.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;
@@ -95,12 +99,18 @@
 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}";
@@ -115,14 +125,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 +168,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 +186,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 +208,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 +237,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");
@@ -309,99 +348,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 +401,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 +438,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 +475,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 +552,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 +597,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 +652,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 {
@@ -769,10 +759,11 @@
     @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;
             }
+            // Relatively large tolerance to accommodate for history bucket size.
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -847,9 +838,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();
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/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index c71d925..7fc8863 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -100,7 +100,7 @@
 import com.android.testutils.NsdServiceInfoCallbackRecord.ServiceInfoCallbackEvent.UnregisterCallbackSucceeded
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestDnsPacket
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
@@ -326,6 +326,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) {
@@ -1298,14 +1307,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 +1347,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 +1387,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 +1428,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 +1502,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 +1565,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 +1602,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 +1641,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 +1657,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 +1741,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 +1768,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 +1790,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 +1857,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 +1879,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 +1937,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 +1955,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 +2001,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 +2316,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 +2348,11 @@
             nsdManager.stopServiceDiscovery(discoveryRecord)
 
             discoveryRecord.expectCallback<DiscoveryStopped>()
-        } cleanup {
+        } cleanupStep {
             nsdManager.unregisterService(registrationRecord)
+        } cleanup {
+            packetReader.handler.post { packetReader.stop() }
+            handlerThread.waitForIdle(TIMEOUT_MS)
         }
     }
 
@@ -2410,14 +2367,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 +2389,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 +2420,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 +2454,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 +2531,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,12 +2550,85 @@
             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)
+    }
+
     private fun hasServiceTypeClientsForNetwork(clients: List<String>, network: Network): Boolean {
         return clients.any { client -> client.substring(
                 client.indexOf("network=") + "network=".length,
diff --git a/tests/cts/net/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..b324dc8 100644
--- a/tests/cts/netpermission/updatestatspermission/Android.bp
+++ b/tests/cts/netpermission/updatestatspermission/Android.bp
@@ -36,5 +36,6 @@
         "cts",
         "general-tests",
     ],
+    data: [":ConnectivityTestPreparer"],
     host_required: ["net-tests-utils-host-common"],
 }
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 1165018..d9bc7f7 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",
@@ -94,14 +97,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..a07c9ea 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -32,6 +32,7 @@
 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;
 
@@ -244,24 +245,35 @@
         assertFalse(tr.isExemptFromEntitlementCheck());
         assertTrue(tr.getShouldShowEntitlementUi());
         assertEquals(softApConfiguration, tr.getSoftApConfiguration());
+        assertEquals(INVALID_UID, tr.getUid());
+        assertNull(tr.getPackageName());
 
         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)
+                .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(uid, tr2.getUid());
+        assertEquals(packageName, tr2.getPackageName());
 
         final TetheringRequest tr3 = new TetheringRequest.Builder(TETHERING_USB)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
                 .setShouldShowEntitlementUi(false).build();
+        tr3.setUid(uid);
+        tr3.setPackageName(packageName);
         assertEquals(tr2, tr3);
     }
 
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 6892a42..9edf9bd 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -114,7 +114,7 @@
     visibility: ["//packages/modules/Connectivity/tests:__subpackages__"],
 }
 
-genrule {
+java_genrule {
     name: "frameworks-net-tests-jarjar-rules",
     defaults: ["jarjar-rules-combine-defaults"],
     srcs: [
diff --git a/tests/unit/java/android/net/TrafficStatsTest.kt b/tests/unit/java/android/net/TrafficStatsTest.kt
deleted file mode 100644
index c61541e..0000000
--- a/tests/unit/java/android/net/TrafficStatsTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
-* Copyright (C) 2024 The Android Open Source Project
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*      http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
-
-package android.net
-
-import android.net.TrafficStats.getValueForTypeFromFirstEntry
-import android.net.TrafficStats.TYPE_RX_BYTES
-import android.net.TrafficStats.UNSUPPORTED
-import android.os.Build
-import com.android.testutils.DevSdkIgnoreRule
-import com.android.testutils.DevSdkIgnoreRunner
-import org.junit.Test
-import org.junit.runner.RunWith
-import kotlin.test.assertEquals
-
-private const val TEST_IFACE1 = "test_iface1"
-
-@RunWith(DevSdkIgnoreRunner::class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
-class TrafficStatsTest {
-
-    @Test
-    fun testGetValueForTypeFromFirstEntry() {
-        var stats: NetworkStats = NetworkStats(0, 0)
-        // empty stats
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), UNSUPPORTED.toLong())
-        // invalid type
-        stats.insertEntry(TEST_IFACE1, 1, 2, 3, 4)
-        assertEquals(getValueForTypeFromFirstEntry(stats, 1000), UNSUPPORTED.toLong())
-        // valid type
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), 1)
-    }
-}
\ No newline at end of file
diff --git a/tests/unit/java/com/android/server/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/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/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/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/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/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index ef4c44d..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;
@@ -79,6 +78,7 @@
 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_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;
 
@@ -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,9 @@
         }
 
         @Override
-        public boolean alwaysUseTrafficStatsServiceRateLimitCache(Context ctx) {
-            return mFeatureFlags.getOrDefault(
+        public boolean isTrafficStatsServiceRateLimitCacheEnabled(Context ctx,
+                boolean isClientCacheEnabled) {
+            return !isClientCacheEnabled && mFeatureFlags.getOrDefault(
                     TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
         }
 
@@ -643,6 +647,19 @@
         }
 
         @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
         public boolean isChangeEnabled(long changeId, int uid) {
             return mCompatChanges.getOrDefault(changeId, true);
         }
@@ -2453,11 +2470,49 @@
         assertUidTotal(sTemplateWifi, UID_GREEN, 64L, 3L, 1024L, 8L, 0);
     }
 
+    @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_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
@@ -2475,8 +2530,19 @@
     }
 
     @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 */);
     }
@@ -2515,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..50971e7 100644
--- a/tests/unit/jni/Android.bp
+++ b/tests/unit/jni/Android.bp
@@ -42,6 +42,7 @@
     ],
     static_libs: [
         "libnet_utils_device_common_bpfjni",
+        "libnet_utils_device_common_timerfdjni",
         "libtcutils",
     ],
     shared_libs: [
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/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 57c365b..d074b01 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -24,5 +24,5 @@
     void onPartitionIdChanged(long partitionId);
     void onThreadEnableStateChanged(int enabledState);
     void onEphemeralKeyStateChanged(
-            int ephemeralKeyState, @nullable String ephemeralKey, long expiryMillis);
+            int ephemeralKeyState, @nullable String ephemeralKey, long lifetimeMillis);
 }
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 1222398..73a6bda 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -368,7 +368,7 @@
          *     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 expireor {@code null} if {@code
+         * @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)
@@ -420,17 +420,14 @@
 
         @Override
         public void onEphemeralKeyStateChanged(
-                @EphemeralKeyState int ephemeralKeyState, String ephemeralKey, long expiryMillis) {
-            if (!Flags.epskcEnabled()) {
-                throw new IllegalStateException(
-                        "This should not be called when Ephemeral key API is disabled");
-            }
-
+                @EphemeralKeyState int ephemeralKeyState,
+                String ephemeralKey,
+                long lifetimeMillis) {
             final long identity = Binder.clearCallingIdentity();
             final Instant expiry =
                     ephemeralKeyState == EPHEMERAL_KEY_DISABLED
                             ? null
-                            : Instant.ofEpochMilli(expiryMillis);
+                            : Instant.now().plusMillis(lifetimeMillis);
 
             try {
                 mExecutor.execute(
@@ -748,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,
@@ -907,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/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/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 3d854d7..8747b44 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -19,6 +19,7 @@
 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;
@@ -78,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;
@@ -120,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;
@@ -141,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;
@@ -193,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 =
@@ -214,13 +222,13 @@
     private NetworkRequest mUpstreamNetworkRequest;
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
+    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
@@ -230,6 +238,7 @@
             NetworkProvider networkProvider,
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
+            RoutingCoordinatorManager routingCoordinatorManager,
             TunInterfaceController tunIfController,
             InfraInterfaceController infraIfController,
             ThreadPersistentSettings persistentSettings,
@@ -243,13 +252,13 @@
         mNetworkProvider = networkProvider;
         mOtDaemonSupplier = otDaemonSupplier;
         mConnectivityManager = connectivityManager;
+        mRoutingCoordinatorManager = routingCoordinatorManager;
         mTunIfController = tunIfController;
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
         // TODO: networkToLinkProperties should be shared with NsdPublisher, add a test/assert to
         // verify they are the same.
         mNetworkToLinkProperties = networkToLinkProperties;
-        mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
         mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
         mNsdPublisher = nsdPublisher;
@@ -268,13 +277,19 @@
         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,
@@ -301,14 +316,6 @@
                 .build();
     }
 
-    private LocalNetworkConfig newLocalNetworkConfig() {
-        return new LocalNetworkConfig.Builder()
-                .setUpstreamMulticastRoutingConfig(mUpstreamMulticastRoutingConfig)
-                .setDownstreamMulticastRoutingConfig(mDownstreamMulticastRoutingConfig)
-                .setUpstreamSelector(mUpstreamNetworkRequest)
-                .build();
-    }
-
     private void maybeInitializeOtDaemon() {
         if (!shouldEnableThread()) {
             return;
@@ -346,12 +353,14 @@
         otDaemon.initialize(
                 mTunIfController.getTunFd(),
                 shouldEnableThread(),
+                newOtDaemonConfig(mPersistentSettings.getConfiguration()),
                 mNsdPublisher,
                 getMeshcopTxtAttributes(mResources.get()),
                 mOtDaemonCallbackProxy,
                 mCountryCodeSupplier.get());
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
+        mHandler.post(mNat64CidrController::maybeUpdateNat64Cidr);
         return mOtDaemon;
     }
 
@@ -445,24 +454,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();
-                    registerThreadNetworkCallback();
-                    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();
     }
 
     /**
@@ -556,22 +573,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 {
@@ -581,7 +610,30 @@
                 }
             }
         }
-        // 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 static OtDaemonConfiguration newOtDaemonConfig(
+            @NonNull ThreadConfiguration threadConfig) {
+        return new OtDaemonConfiguration.Builder()
+                .setBorderRouterEnabled(threadConfig.isBorderRouterEnabled())
+                .setNat64Enabled(threadConfig.isNat64Enabled())
+                .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
+                .build();
+    }
+
+    /** Returns {@code true} if this device is operating as a border router. */
+    private boolean isBorderRouterMode() {
+        return mPersistentSettings.getConfiguration().isBorderRouterEnabled();
     }
 
     @Override
@@ -690,7 +742,7 @@
 
     private void requestUpstreamNetwork() {
         if (mUpstreamNetworkCallback != null) {
-            throw new AssertionError("The upstream network request is already there.");
+            return;
         }
         mUpstreamNetworkCallback = new UpstreamNetworkCallback();
         mConnectivityManager.registerNetworkCallback(
@@ -699,7 +751,7 @@
 
     private void cancelRequestUpstreamNetwork() {
         if (mUpstreamNetworkCallback == null) {
-            throw new AssertionError("The upstream network request null.");
+            return;
         }
         mNetworkToLinkProperties.clear();
         mConnectivityManager.unregisterNetworkCallback(mUpstreamNetworkCallback);
@@ -764,33 +816,43 @@
                             + ", localNetworkInfo: "
                             + localNetworkInfo
                             + "}");
-            if (localNetworkInfo.getUpstreamNetwork() == null) {
+            mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
+            if (mUpstreamNetwork == null) {
                 setInfraLinkState(newInfraLinkStateBuilder().build());
                 return;
             }
-            if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
-                mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
-                if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
-                    setInfraLinkState(
-                            newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
-                                    .build());
-                }
-                mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
+            if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
+                setInfraLinkState(
+                        newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
+                                .build());
             }
+            mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
         }
     }
 
     private void registerThreadNetworkCallback() {
-        mConnectivityManager.registerNetworkCallback(
+        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. */
@@ -804,27 +866,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() {
@@ -870,6 +951,12 @@
             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) {
@@ -889,6 +976,12 @@
     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) {
@@ -1202,16 +1295,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);
@@ -1308,19 +1405,19 @@
     }
 
     private void setInfraLinkState(InfraLinkState newInfraLinkState) {
-        if (mInfraLinkState.equals(newInfraLinkState)) {
-            return;
+        if (Objects.equals(mInfraLinkState, newInfraLinkState)) {
+            return ;
         }
         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;
+            return ;
         }
         ParcelFileDescriptor infraIcmp6Socket = null;
         if (newInfraLinkInterfaceName != null) {
@@ -1342,8 +1439,8 @@
     }
 
     private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
-        if (Objects.equals(mInfraLinkState.nat64Prefix, newNat64Prefix)) {
-            return;
+        if (Objects.equals(newNat64Prefix, mInfraLinkState.nat64Prefix)) {
+            return ;
         }
         try {
             getOtDaemon()
@@ -1354,6 +1451,24 @@
         }
     }
 
+    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() {
+        LOG.i("Disabling border routing");
+        setInfraLinkState(newInfraLinkStateBuilder().build());
+    }
+
     private void handleThreadInterfaceStateChanged(boolean isUp) {
         try {
             mTunIfController.setInterfaceUp(isUp);
@@ -1386,9 +1501,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) {
@@ -1398,9 +1511,18 @@
 
         // 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(
@@ -1477,11 +1599,6 @@
         return builder.build();
     }
 
-    private static OtDaemonConfiguration.Builder newOtDaemonConfigBuilder(
-            OtDaemonConfiguration config) {
-        return new OtDaemonConfiguration.Builder();
-    }
-
     private static InfraLinkState.Builder newInfraLinkStateBuilder() {
         return new InfraLinkState.Builder().setInterfaceName("");
     }
@@ -1497,7 +1614,17 @@
         }
         return new InfraLinkState.Builder()
                 .setInterfaceName(linkProperties.getInterfaceName())
-                .setNat64Prefix(nat64Prefix);
+                .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 {
@@ -1525,6 +1652,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;
 
@@ -1647,6 +1793,7 @@
                     // do nothing if the client is dead
                 }
             }
+            mInfraLinkState = newInfraLinkStateBuilder().build();
         }
 
         private void onThreadEnabledChanged(int state, long listenerId) {
@@ -1787,7 +1934,7 @@
                             .onEphemeralKeyStateChanged(
                                     newState.ephemeralKeyState,
                                     passcode,
-                                    newState.ephemeralKeyExpiryMillis);
+                                    newState.ephemeralKeyLifetimeMillis);
                 } catch (RemoteException ignored) {
                     // do nothing if the client is dead
                 }
@@ -1800,7 +1947,7 @@
             if (oldState.ephemeralKeyState != newState.ephemeralKeyState) return true;
             if (oldState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) return false;
             return (!Objects.equals(oldState.ephemeralKeyPasscode, newState.ephemeralKeyPasscode)
-                    || oldState.ephemeralKeyExpiryMillis != newState.ephemeralKeyExpiryMillis);
+                    || oldState.ephemeralKeyLifetimeMillis != newState.ephemeralKeyLifetimeMillis);
         }
 
         private void onActiveOperationalDatasetChanged(
@@ -1851,4 +1998,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 1eddebf..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,12 @@
 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;
@@ -56,6 +58,7 @@
     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";
 
@@ -118,6 +121,8 @@
         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
@@ -132,6 +137,8 @@
                 return setThreadEnabled(true);
             case "disable":
                 return setThreadEnabled(false);
+            case "config":
+                return handleConfigCommand();
             case "join":
                 return join();
             case "leave":
@@ -261,6 +268,69 @@
         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;
@@ -359,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/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 1792bfb..2d487ca 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -182,6 +182,7 @@
     @After
     public void tearDown() throws Exception {
         dropAllPermissions();
+        setEnabledAndWait(mController, true);
         leaveAndWait(mController);
         tearDownTestNetwork();
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
@@ -921,6 +922,27 @@
 
     @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();
@@ -932,6 +954,26 @@
 
     @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<>();
@@ -1042,7 +1084,9 @@
                     listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
 
             assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
-            assertThat(epskc2.getThird()).isEqualTo(epskc1.getThird());
+            // 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();
         }
@@ -1149,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 {
@@ -1268,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
@@ -1293,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);
     }
 
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 4a8462d8..aeeed65 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -19,6 +19,8 @@
 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,9 +28,11 @@
 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;
 
@@ -46,7 +50,6 @@
 import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
-import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
@@ -55,6 +58,7 @@
 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;
@@ -68,18 +72,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;
@@ -101,7 +110,6 @@
             (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 =
@@ -113,28 +121,32 @@
     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);
@@ -149,8 +161,7 @@
     @After
     public void tearDown() throws Exception {
         mController.setTestNetworkAsUpstreamAndWait(null);
-        mController.leaveAndWait();
-        tearDownInfraNetwork();
+        TestTunNetworkUtils.tearDownAllInfraNetworks();
 
         mHandlerThread.quitSafely();
         mHandlerThread.join();
@@ -217,19 +228,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
@@ -274,6 +279,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 {
@@ -584,8 +611,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();
@@ -611,8 +636,6 @@
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
         Inet6Address ftdOmr = ftd.getOmrAddress();
 
-        // Destroy infra link and re-create
-        tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         startInfraDeviceAndWaitForOnLinkAddr();
@@ -625,23 +648,34 @@
     }
 
     @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);
+        mController.setNat64EnabledAndWait(true);
         waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
+        Thread echoReplyThread = new Thread(() -> respondToEchoRequestOnce(IPV4_SERVER_ADDR));
+        echoReplyThread.start();
 
-        ftd.ping(IPV4_SERVER_ADDR);
+        assertThat(ftd.ping(IPV4_SERVER_ADDR, 1 /* count */)).isEqualTo(1);
 
-        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMP_ECHO, null, IPV4_SERVER_ADDR));
+        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 {
-        tearDownInfraNetwork();
+        TestTunNetworkUtils.tearDownInfraNetwork(mInfraNetworkTracker);
         LinkProperties lp = new LinkProperties();
         // NAT64 feature requires the infra network to have an IPv4 default route.
         lp.addRoute(
@@ -659,12 +693,11 @@
                         RouteInfo.RTN_UNICAST,
                         1500 /* mtu */));
         lp.setNat64Prefix(AIL_NAT64_PREFIX);
-        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController, lp);
+        mInfraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(mContext, mController, lp);
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
-        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
-        mOtCtl.setNat64Enabled(true);
+        mController.setNat64EnabledAndWait(true);
         mOtCtl.addPrefixInNetworkData(DHCP6_PD_PREFIX, "paros", "med");
         waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
@@ -676,16 +709,12 @@
     }
 
     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/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 87219d3..2f0ab34 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,6 +19,7 @@
 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;
@@ -79,6 +80,7 @@
     public void tearDown() throws Exception {
         mFtd.destroy();
         ensureThreadEnabled();
+        mController.setConfigurationAndWait(DEFAULT_CONFIG);
     }
 
     private static void ensureThreadEnabled() {
@@ -179,6 +181,27 @@
         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 3df74b0..07d0390 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,21 @@
 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 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 +54,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
@@ -108,6 +115,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 +146,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 +201,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 +209,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 +313,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> {
@@ -513,6 +590,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,54 +649,11 @@
         )
     }
 
-    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
-    }
-
+    /**
+     * 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
-    @JvmOverloads
-    @Throws(java.lang.Exception::class)
-    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)
-
-        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 046d9bf..afb0fc7 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()
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/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index 62801bf..e3c83f1 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -147,6 +147,14 @@
         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);
@@ -537,4 +545,68 @@
         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/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index b97e2b7..e188491 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -44,6 +44,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;
@@ -64,6 +66,7 @@
 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;
@@ -91,9 +94,12 @@
 
 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.Before;
@@ -164,8 +170,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;
@@ -208,7 +216,10 @@
         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);
@@ -235,6 +246,7 @@
                         networkProvider,
                         () -> mFakeOtDaemon,
                         mMockConnectivityManager,
+                        mMockRoutingCoordinatorManager,
                         mMockTunIfController,
                         mMockInfraIfController,
                         mPersistentSettings,
@@ -281,6 +293,37 @@
     }
 
     @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 getMeshcopTxtAttributes_emptyVendorName_accepted() {
         when(mResources.getString(eq(R.string.config_thread_vendor_name))).thenReturn("");
 
@@ -741,10 +784,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
 
@@ -761,6 +801,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();
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 af5c9aa..640b0f1 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -38,8 +38,11 @@
 
 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;
 
@@ -320,4 +323,108 @@
         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",