Merge "Clarify advertising logs" 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..2a6f6d5 100644
--- a/Tethering/AndroidManifest.xml
+++ b/Tethering/AndroidManifest.xml
@@ -32,8 +32,10 @@
     <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" />
     <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
+    <uses-permission android:name="android.permission.QUERY_USERS"/>
     <uses-permission android:name="android.permission.READ_DEVICE_CONFIG" />
     <uses-permission android:name="android.permission.READ_NETWORK_USAGE_HISTORY" />
     <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
diff --git a/Tethering/apex/permissions/permissions.xml b/Tethering/apex/permissions/permissions.xml
index f26a961..fcb287e 100644
--- a/Tethering/apex/permissions/permissions.xml
+++ b/Tethering/apex/permissions/permissions.xml
@@ -18,8 +18,10 @@
 <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.MODIFY_PHONE_STATE"/>
+        <permission name="android.permission.QUERY_USERS"/>
         <permission name="android.permission.READ_NETWORK_USAGE_HISTORY"/>
         <permission name="android.permission.TETHER_PRIVILEGED"/>
         <permission name="android.permission.UPDATE_APP_OPS_STATS"/>
diff --git a/Tethering/common/TetheringLib/api/module-lib-current.txt b/Tethering/common/TetheringLib/api/module-lib-current.txt
index 460c216..a680590 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_request_with_soft_ap_config") @Nullable public String getPackageName();
+    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public int getUid();
+  }
+
 }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 411971d..7c7a4e0 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;
@@ -746,6 +747,7 @@
                 mBuilderParcel.exemptFromEntitlementCheck = false;
                 mBuilderParcel.showProvisioningUi = true;
                 mBuilderParcel.connectivityScope = getDefaultConnectivityScope(type);
+                mBuilderParcel.uid = Process.INVALID_UID;
                 mBuilderParcel.softApConfig = null;
             }
 
@@ -920,6 +922,47 @@
         }
 
         /**
+         * 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_REQUEST_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_REQUEST_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 f60d7a1..a0604f2 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -725,8 +725,12 @@
 
         if (shouldUseWifiP2pDedicatedIp()) return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
 
-        return mRoutingCoordinator.requestDownstreamAddress(mInterfaceType, scope, useLastAddress,
-                mIpv4PrefixRequest);
+        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/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index 454cbf1..cea7e82 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -55,6 +55,7 @@
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.PermissionUtils;
 import com.android.networkstack.apishim.SettingsShimImpl;
 import com.android.networkstack.apishim.common.SettingsShim;
 
@@ -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,28 @@
     }
 
     /**
+     * Check if the package name matches the uid.
+     */
+    @VisibleForTesting
+    boolean checkPackageNameMatchesUid(@NonNull Context context, int uid,
+            @NonNull String callingPackage) {
+        try {
+            PermissionUtils.enforcePackageNameMatchesUid(context, 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 a744953..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;
@@ -478,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/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 8f5e6c4..680e81d 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -258,8 +258,8 @@
                     mIpServer, interfaceParams.index, upstreamPrefixes);
         }
         reset(mNetd, mBpfCoordinator, mCallback, mRoutingCoordinatorManager);
-        when(mRoutingCoordinatorManager.requestDownstreamAddress(anyInt(), anyInt(),
-                anyBoolean(), any())).thenReturn(mTestAddress);
+        when(mRoutingCoordinatorManager.requestStickyDownstreamAddress(anyInt(), anyInt(),
+                any())).thenReturn(mTestAddress);
     }
 
     @SuppressWarnings("DoNotCall") // Ignore warning for synchronous to call to Thread.run()
@@ -280,8 +280,9 @@
     @Before public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         when(mSharedLog.forSubComponent(anyString())).thenReturn(mSharedLog);
-        when(mRoutingCoordinatorManager.requestDownstreamAddress(anyInt(), anyInt(),
-                anyBoolean(), any())).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 */);
 
@@ -348,11 +349,11 @@
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         if (isAtLeastT()) {
             inOrder.verify(mRoutingCoordinatorManager)
-                    .requestDownstreamAddress(
+                    .requestStickyDownstreamAddress(
                             eq(TETHERING_BLUETOOTH),
                             eq(CONNECTIVITY_SCOPE_GLOBAL),
-                            eq(true),
                             any());
+            inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
             inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                     IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         }
@@ -401,8 +402,9 @@
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
-        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(anyInt(),
-                eq(CONNECTIVITY_SCOPE_GLOBAL), eq(true), any());
+        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);
@@ -423,8 +425,9 @@
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
         InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
-        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(anyInt(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true), any());
+        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);
@@ -449,7 +452,8 @@
         // When using WiFi P2p dedicated IP, the IpServer just picks the IP address without
         // requesting for it at RoutingCoordinatorManager.
         inOrder.verify(mRoutingCoordinatorManager, never())
-                .requestDownstreamAddress(anyInt(), anyInt(), anyBoolean(), any());
+                .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);
@@ -731,8 +735,9 @@
         final ArgumentCaptor<LinkProperties> lpCaptor =
                 ArgumentCaptor.forClass(LinkProperties.class);
         InOrder inOrder = inOrder(mNetd, mCallback, mRoutingCoordinatorManager);
-        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(anyInt(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true), any());
+        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),
@@ -745,13 +750,13 @@
         // Simulate the DHCP server receives DHCPDECLINE on MirrorLink and then signals
         // onNewPrefixRequest callback.
         final LinkAddress newAddress = new LinkAddress("192.168.100.125/24");
-        when(mRoutingCoordinatorManager.requestDownstreamAddress(anyInt(), anyInt(),
-                anyBoolean(), any())).thenReturn(newAddress);
+        when(mRoutingCoordinatorManager.requestDownstreamAddress(any())).thenReturn(newAddress);
         eventCallbacks.onNewPrefixRequest(new IpPrefix("192.168.42.0/24"));
         mLooper.dispatchAll();
 
-        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(anyInt(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(false), any());
+        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 1ab5766..1608e1a 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -134,13 +134,18 @@
                 spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
     }
 
-    private LinkAddress requestDownstreamAddress(
-            final IpServer ipServer, int scope, boolean useLastAddress) throws Exception {
+    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.interfaceType(),
-                        scope,
-                        useLastAddress,
                         ipServer.getIpv4PrefixRequest());
         when(ipServer.getAddress()).thenReturn(address);
         return address;
@@ -158,19 +163,16 @@
     @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);
@@ -184,8 +186,7 @@
         // - 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);
         releaseDownstream(mHotspotIpServer);
@@ -193,8 +194,7 @@
         // - 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);
@@ -203,8 +203,7 @@
         // - 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);
@@ -214,20 +213,20 @@
 
     @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);
 
         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,
@@ -299,8 +298,8 @@
     }
 
     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);
@@ -309,12 +308,12 @@
 
     @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);
@@ -353,8 +352,7 @@
         mPrivateAddressCoordinator =
                 spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomIntForPrefixBase);
-        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix prefixBase = new IpPrefix(expected);
         assertTrue(address + " is not part of " + prefixBase,
                 prefixBase.containsPrefix(asIpPrefix(address)));
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
index c0d7ad4..1988311 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
@@ -34,6 +34,7 @@
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
@@ -79,6 +80,7 @@
 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;
     @Mock private ITetheringEventCallback mITetheringEventCallback;
     @Rule public ServiceTestRule mServiceTestRule;
@@ -128,6 +130,8 @@
         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);
     }
 
     @After
@@ -330,6 +334,16 @@
         });
 
         runAsTetherPrivileged((result) -> {
+            String wrongPackage = "wrong.package";
+            mTetheringConnector.startTethering(request, wrongPackage,
+                    TEST_ATTRIBUTION_TAG, result);
+            verify(mTethering, never()).startTethering(
+                    eq(new TetheringRequest(request)), eq(wrongPackage), eq(result));
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractionsForTethering();
+        });
+
+        runAsTetherPrivileged((result) -> {
             runStartTethering(result, request);
             verifyNoMoreInteractionsForTethering();
         });
@@ -445,6 +459,13 @@
             verifyNoMoreInteractionsForTethering();
         });
 
+        runAsTetherPrivileged((none) -> {
+            mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
+                    true /* showEntitlementUi */, "wrong.package", TEST_ATTRIBUTION_TAG);
+            result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
+            verifyNoMoreInteractions(mTethering);
+        });
+
         runAsWriteSettings((none) -> {
             runRequestLatestTetheringEntitlementResult();
             verify(mTethering).isTetherProvisioningRequired();
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 08129eb..5f8f0e3 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -500,7 +500,6 @@
 
   @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;
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 fd73b29..16f32c4 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -73,7 +73,9 @@
                 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);
@@ -185,7 +187,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;
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 914af06..0ae982d 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -43,7 +43,7 @@
 
     void initialize() {
         mDataStore.load();
-        mCertificateTransparencyDownloader.registerReceiver();
+        mCertificateTransparencyDownloader.initialize();
         DeviceConfig.addOnPropertiesChangedListener(
                 Config.NAMESPACE_NETWORK_SECURITY, Executors.newSingleThreadExecutor(), this);
         if (Config.DEBUG) {
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 611a5c7..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__";
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..e3b4124
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
@@ -0,0 +1,69 @@
+/*
+ * 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);
+    }
+
+    // CT files and directories are readable by all apps.
+    @SuppressLint("SetWorldReadable")
+    static void setWorldReadable(File file) throws IOException {
+        if (!file.setReadable(true, false)) {
+            throw new IOException("Failed to set " + file.getCanonicalPath() + " readable");
+        }
+    }
+
+    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/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index 1aad028..df02446 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
@@ -159,7 +159,9 @@
                 Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
 
         setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
+        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();
@@ -168,7 +170,8 @@
         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());
@@ -185,7 +188,9 @@
         Uri metadataUri = Uri.fromFile(metadataFile);
 
         setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
-        when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(false);
+        when(mCertificateTransparencyInstaller.install(
+                        eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
+                .thenReturn(false);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -208,7 +213,8 @@
         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();
@@ -230,7 +236,8 @@
         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();
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/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/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 4f01599..a43486e 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -59,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;
@@ -184,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:
@@ -369,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(
@@ -492,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)) {
@@ -503,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);
             }
         }
     }
@@ -757,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/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/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
index a04ebdd..e503312 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -6003,12 +6003,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);
                 }
@@ -9122,11 +9120,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
@@ -10271,8 +10265,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()) {
@@ -10282,7 +10275,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);
@@ -10290,6 +10283,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
@@ -10297,24 +10312,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() {
@@ -10428,16 +10436,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 ?
@@ -10792,16 +10796,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);
@@ -13051,10 +13051,7 @@
         }
 
         private void ensureRunningOnConnectivityServiceThread() {
-            if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-                throw new IllegalStateException("Not running on ConnectivityService thread: "
-                                + Thread.currentThread().getName());
-            }
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         }
 
         /**
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/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 66e1dad..a825b87 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -626,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.
diff --git a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
index 097824f..7688e6a 100644
--- a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
+++ b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
@@ -115,20 +115,27 @@
     void maybeRemoveDeprecatedUpstreams();
 
    /**
-    * Request an IPv4 address for the downstream.
+    * 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 useLastAddress whether to use the last address
     * @param request a {@link IIpv4PrefixRequest} to report conflicts
     * @return an IPv4 address allocated for the downstream, could be null
     */
     @nullable
-    LinkAddress requestDownstreamAddress(
+    LinkAddress requestStickyDownstreamAddress(
             in int interfaceType,
             in int scope,
-            in boolean useLastAddress,
             in IIpv4PrefixRequest request);
+   /**
+    * Request an IPv4 address for the downstream.
+    *
+    * @param request a {@link IIpv4PrefixRequest} to report conflicts
+    * @return an IPv4 address allocated for the downstream, could be null
+    */
+    @nullable
+    LinkAddress requestDownstreamAddress(in IIpv4PrefixRequest request);
 
     /** Release the IPv4 address allocated for the downstream. */
     void releaseDownstream(in IIpv4PrefixRequest request);
diff --git a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index 990358d..7fcbd4e 100644
--- a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -78,7 +78,7 @@
     private final ArrayMap<Network, List<IpPrefix>> mUpstreamPrefixMap;
     // The downstreams are indexed by Ipv4PrefixRequest, which is a wrapper of the Binder object of
     // IIpv4PrefixRequest.
-    private final ArrayMap<Ipv4PrefixRequest, Downstream> mDownstreams;
+    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;
@@ -168,10 +168,10 @@
     }
 
     private void handleMaybePrefixConflict(final List<IpPrefix> prefixes) {
-        for (Map.Entry<Ipv4PrefixRequest, Downstream> entry : mDownstreams.entrySet()) {
+        for (Map.Entry<Ipv4PrefixRequest, LinkAddress> entry : mDownstreams.entrySet()) {
             final Ipv4PrefixRequest request = entry.getKey();
-            final Downstream downstream = entry.getValue();
-            final IpPrefix target = asIpPrefix(downstream.getAddress());
+            final LinkAddress downstream = entry.getValue();
+            final IpPrefix target = asIpPrefix(downstream);
 
             for (IpPrefix source : prefixes) {
                 if (isConflictPrefix(source, target)) {
@@ -209,12 +209,14 @@
     // 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(int interfaceType, final int scope,
-            boolean useLastAddress,
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, final int scope,
             IIpv4PrefixRequest request) {
         final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
         final AddressKey addrKey = new AddressKey(interfaceType, scope);
@@ -222,20 +224,32 @@
         // 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.put(wrappedRequest, new Downstream(interfaceType, cachedAddress));
+        if (cachedAddress != null && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
+            mDownstreams.put(wrappedRequest, cachedAddress);
             return cachedAddress;
         }
 
+        final LinkAddress newAddress = requestDownstreamAddress(request);
+        if (newAddress != null) {
+            mCachedAddresses.put(addrKey, newAddress);
+        }
+        return newAddress;
+    }
+
+    /**
+     * Pick a random available address and mark its prefix as in use for the provided
+     * IIpv4PrefixRequest. Return null if there is no available address.
+     */
+    @Nullable
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
         final int prefixIndex = getRandomPrefixIndex();
         for (int i = 0; i < mTetheringPrefixes.size(); i++) {
             final IpPrefix prefixRange = mTetheringPrefixes.get(
                     (prefixIndex + i) % mTetheringPrefixes.size());
             final LinkAddress newAddress = chooseDownstreamAddress(prefixRange);
             if (newAddress != null) {
-                mDownstreams.put(wrappedRequest, new Downstream(interfaceType, newAddress));
-                mCachedAddresses.put(addrKey, newAddress);
+                mDownstreams.put(wrappedRequest, newAddress);
                 return newAddress;
             }
         }
@@ -379,8 +393,8 @@
 
         // IpServer may use manually-defined address (mStaticIpv4ServerAddr) which does not include
         // in mCachedAddresses.
-        for (Downstream downstream : mDownstreams.values()) {
-            final IpPrefix target = asIpPrefix(downstream.getAddress());
+        for (LinkAddress downstream : mDownstreams.values()) {
+            final IpPrefix target = asIpPrefix(downstream);
 
             if (isConflictPrefix(prefix, target)) return target;
         }
@@ -417,24 +431,6 @@
         }
     }
 
-    private static final class Downstream {
-        private final int mInterfaceType;
-        private final LinkAddress mAddress;
-
-        private Downstream(int interfaceType, LinkAddress address) {
-            mInterfaceType = interfaceType;
-            mAddress = address;
-        }
-
-        public int getInterfaceType() {
-            return mInterfaceType;
-        }
-
-        public LinkAddress getAddress() {
-            return mAddress;
-        }
-    }
-
     private static class AddressKey {
         private final int mTetheringType;
         private final int mScope;
@@ -481,8 +477,8 @@
 
         pw.println("mDownstreams:");
         pw.increaseIndent();
-        for (Downstream downstream : mDownstreams.values()) {
-            pw.println(downstream.getInterfaceType() + " - " + downstream.getAddress());
+        for (LinkAddress downstream : mDownstreams.values()) {
+            pw.println(downstream);
         }
         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 9ea0947..f5af30c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
@@ -195,23 +195,35 @@
     }
 
     /**
-     * Request an IPv4 address for the downstream.
+     * 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 useLastAddress whether to use the last address
      * @param request a {@link IIpv4PrefixRequest} to report conflicts
      * @return an IPv4 address allocated for the downstream, could be null
      */
     @Nullable
-    public LinkAddress requestDownstreamAddress(
+    public LinkAddress requestStickyDownstreamAddress(
             int interfaceType,
             int scope,
-            boolean useLastAddress,
             IIpv4PrefixRequest request) {
         try {
-            return mService.requestDownstreamAddress(
-                    interfaceType, scope, useLastAddress, request);
+            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();
         }
diff --git a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
index d16c234..51eb47c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
@@ -284,23 +284,40 @@
     }
 
     /**
-     * Request an IPv4 address for the downstream.
+     * 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 useLastAddress whether to use the last address
      * @param request a {@link IIpv4PrefixRequest} to report conflicts
      * @return an IPv4 address allocated for the downstream, could be null
      */
     @Override
-    public LinkAddress requestDownstreamAddress(int interfaceType, int scope,
-            boolean useLastAddress, IIpv4PrefixRequest request) {
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, int scope,
+            IIpv4PrefixRequest request) {
         Objects.requireNonNull(request);
         return BinderUtils.withCleanCallingIdentity(
                 () -> {
                     synchronized (mPrivateAddressCoordinatorLock) {
-                        return mPrivateAddressCoordinator.requestDownstreamAddress(
-                                interfaceType, scope, useLastAddress, request);
+                        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);
                     }
                 });
     }
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/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/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 13e1dc0..f4ed9e4 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -103,7 +103,7 @@
         "mcts-wifi",
         "mcts-dnsresolver",
     ],
-    data: [":ConnectivityTestPreparer"],
+    device_common_data: [":ConnectivityTestPreparer"],
 }
 
 python_library_host {
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
index 93422ad..be6947f 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
@@ -98,10 +98,10 @@
         cellRequestCb = null
     }
 
-    private fun addCallback(
-        cb: TestableNetworkCallback,
-        registrar: (TestableNetworkCallback) -> Unit
-    ): TestableNetworkCallback {
+    private fun <T> addCallback(
+        cb: T,
+        registrar: (NetworkCallback) -> Unit
+    ): T where T : NetworkCallback {
         registrar(cb)
         cbToCleanup.add(cb)
         return cb
@@ -142,17 +142,24 @@
     /**
      * File a callback for a NetworkRequest.
      *
-     * This will fail tests (throw) if the cell network cannot be obtained, or if it was already
-     * requested.
-     *
      * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
      * otherwise it will be automatically unrequested after the test.
      */
     @JvmOverloads
     fun registerNetworkCallback(
+        request: NetworkRequest
+    ): TestableNetworkCallback = registerNetworkCallback(request, TestableNetworkCallback())
+
+    /**
+     * File a callback for a NetworkRequest.
+     *
+     * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
+     * otherwise it will be automatically unrequested after the test.
+     */
+    fun <T> registerNetworkCallback(
         request: NetworkRequest,
-        cb: TestableNetworkCallback = TestableNetworkCallback()
-    ) = addCallback(cb) { cm.registerNetworkCallback(request, it) }
+        cb: T
+    ) where T : NetworkCallback = addCallback(cb) { cm.registerNetworkCallback(request, it) }
 
     /**
      * @see ConnectivityManager.registerDefaultNetworkCallback
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
index f5a5b4d..ea86281 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -16,19 +16,46 @@
 
 package com.android.testutils
 
+import android.Manifest.permission.NETWORK_SETTINGS
+import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
+import android.content.pm.PackageManager.FEATURE_TELEPHONY
+import android.content.pm.PackageManager.FEATURE_WIFI
 import android.device.collectors.BaseMetricListener
 import android.device.collectors.DataRecord
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
 import android.os.Build
 import android.os.ParcelFileDescriptor
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN
 import android.util.Log
+import androidx.annotation.RequiresApi
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import java.io.ByteArrayOutputStream
 import java.io.File
+import java.io.FileOutputStream
 import java.io.PrintWriter
 import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
 import kotlin.test.assertNull
+import org.json.JSONObject
 import org.junit.AssumptionViolatedException
 import org.junit.runner.Description
+import org.junit.runner.Result
 import org.junit.runner.notification.Failure
 
 /**
@@ -52,11 +79,35 @@
         var instance: ConnectivityDiagnosticsCollector? = null
     }
 
+    private var failureHeader: String? = null
     private val buffer = ByteArrayOutputStream()
     private val collectorDir: File by lazy {
         createAndEmptyDirectory(COLLECTOR_DIR)
     }
     private val outputFiles = mutableSetOf<String>()
+    private val cbHelper = NetworkCallbackHelper()
+    private val networkCallback = MonitoringNetworkCallback()
+
+    inner class MonitoringNetworkCallback : NetworkCallback() {
+        val currentMobileDataNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentVpnNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentWifiNetworks = mutableMapOf<Network, NetworkCapabilities>()
+
+        override fun onLost(network: Network) {
+            currentWifiNetworks.remove(network)
+            currentMobileDataNetworks.remove(network)
+        }
+
+        override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
+            if (nc.hasTransport(TRANSPORT_VPN)) {
+                currentVpnNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_WIFI)) {
+                currentWifiNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_CELLULAR)) {
+                currentMobileDataNetworks[network] = nc
+            }
+        }
+    }
 
     override fun onSetUp() {
         assertNull(instance, "ConnectivityDiagnosticsCollectors were set up multiple times")
@@ -72,6 +123,24 @@
         instance = null
     }
 
+    override fun onTestRunStart(runData: DataRecord?, description: Description?) {
+        runAsShell(NETWORK_SETTINGS) {
+            cbHelper.registerNetworkCallback(
+                NetworkRequest.Builder()
+                    .addCapability(NET_CAPABILITY_INTERNET)
+                    .addTransportType(TRANSPORT_WIFI)
+                    .addTransportType(TRANSPORT_CELLULAR)
+                    .build(), networkCallback
+            )
+        }
+    }
+
+    override fun onTestRunEnd(runData: DataRecord?, result: Result?) {
+        // onTestRunEnd is called regardless of success/failure, and the Result contains summary of
+        // run/failed/ignored... tests.
+        cbHelper.unregisterAll()
+    }
+
     override fun onTestFail(testData: DataRecord, description: Description, failure: Failure) {
         // TODO: find a way to disable this behavior only on local runs, to avoid slowing them down
         // when iterating on failing tests.
@@ -119,12 +188,116 @@
         }
         val outFile = File(collectorDir, filename + FILENAME_SUFFIX)
         outputFiles.add(filename)
-        outFile.writeBytes(buffer.toByteArray())
+        FileOutputStream(outFile).use { fos ->
+            failureHeader?.let {
+                fos.write(it.toByteArray())
+                fos.write("\n".toByteArray())
+            }
+            fos.write(buffer.toByteArray())
+        }
+        failureHeader = null
         buffer.reset()
         val fileKey = "${ConnectivityDiagnosticsCollector::class.qualifiedName}_$filename"
         testData.addFileMetric(fileKey, outFile)
     }
 
+    private fun maybeCollectFailureHeader() {
+        if (failureHeader != null) {
+            Log.i(TAG, "Connectivity diagnostics failure header already collected, skipping")
+            return
+        }
+
+        val instr = InstrumentationRegistry.getInstrumentation()
+        val ctx = instr.context
+        val pm = ctx.packageManager
+        val hasWifi = pm.hasSystemFeature(FEATURE_WIFI)
+        val hasMobileData = pm.hasSystemFeature(FEATURE_TELEPHONY)
+        val tm = if (hasMobileData) ctx.getSystemService(TelephonyManager::class.java) else null
+        // getAdoptedShellPermissions is S+. Optimistically assume that tests are not holding on
+        // shell permissions during failure/cleanup on R.
+        val canUseShell = !isAtLeastS() ||
+                instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty()
+        val headerObj = JSONObject()
+        if (canUseShell) {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) {
+                headerObj.apply {
+                    put("deviceSerial", Build.getSerial())
+                    // The network callback filed on start cannot get the WifiInfo as it would need
+                    // to keep NETWORK_SETTINGS permission throughout the test run. Try to
+                    // obtain it while holding the permission at the end of the test.
+                    val wifiInfo = networkCallback.currentWifiNetworks.keys.firstOrNull()?.let {
+                        getWifiInfo(it)
+                    }
+                    put("ssid", wifiInfo?.ssid)
+                    put("bssid", wifiInfo?.bssid)
+                    put("simState", tm?.simState ?: SIM_STATE_UNKNOWN)
+                    put("mccMnc", tm?.simOperator)
+                }
+            }
+        } else {
+            Log.w(TAG, "The test is still holding shell permissions, cannot collect privileged " +
+                    "device info")
+            headerObj.put("shellPermissionsUnavailable", true)
+        }
+        failureHeader = headerObj.apply {
+            put("time", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now()))
+            put(
+                "wifiEnabled",
+                hasWifi && ctx.getSystemService(WifiManager::class.java).isWifiEnabled
+            )
+            put("connectedWifiCount", networkCallback.currentWifiNetworks.size)
+            put("validatedWifiCount", networkCallback.currentWifiNetworks.filterValues {
+                it.hasCapability(NET_CAPABILITY_VALIDATED)
+            }.size)
+            put("mobileDataConnectivityPossible", tm?.isDataConnectivityPossible ?: false)
+            put("connectedMobileDataCount", networkCallback.currentMobileDataNetworks.size)
+            put("validatedMobileDataCount",
+                networkCallback.currentMobileDataNetworks.filterValues {
+                    it.hasCapability(NET_CAPABILITY_VALIDATED)
+                }.size
+            )
+        }.toString()
+    }
+
+    private class WifiInfoCallback : NetworkCallback {
+        private val network: Network
+        val wifiInfoFuture = CompletableFuture<WifiInfo?>()
+        constructor(network: Network) : super() {
+            this.network = network
+        }
+        @RequiresApi(Build.VERSION_CODES.S)
+        constructor(network: Network, flags: Int) : super(flags) {
+            this.network = network
+        }
+        override fun onCapabilitiesChanged(net: Network, nc: NetworkCapabilities) {
+            if (network == net) {
+                wifiInfoFuture.complete(nc.transportInfo as? WifiInfo)
+            }
+        }
+    }
+
+    private fun getWifiInfo(network: Network): WifiInfo? {
+        // Get the SSID via network callbacks, as the Networks are obtained via callbacks, and
+        // synchronous calls (CM#getNetworkCapabilities) and callbacks should not be mixed.
+        // A new callback needs to be filed and received while holding NETWORK_SETTINGS permission.
+        val cb = if (isAtLeastS()) {
+            WifiInfoCallback(network, FLAG_INCLUDE_LOCATION_INFO)
+        } else {
+            WifiInfoCallback(network)
+        }
+        cbHelper.registerNetworkCallback(
+            NetworkRequest.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
+        return try {
+            cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS)
+        } catch (e: TimeoutException) {
+            null
+        } finally {
+            cbHelper.unregisterNetworkCallback(cb)
+        }
+    }
+
     /**
      * Add connectivity diagnostics to the test data dump.
      *
@@ -134,6 +307,7 @@
      * @param exceptionContext An exception to write a stacktrace to the dump for context.
      */
     fun collectTestFailureDiagnostics(exceptionContext: Throwable? = null) {
+        maybeCollectFailureHeader()
         collectDumpsysConnectivity(exceptionContext)
     }
 
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/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index 7fe60bd..55ac860 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -182,22 +182,23 @@
         ad: android_device.AndroidDevice,
 ) -> bool:
 
-  # Invoke the shell command with empty argument and see how NetworkStack respond.
-  # If supported, an IllegalArgumentException with help page will be printed.
-  functions_with_args = (
-    # list all functions and args with (func, *args) tuple
-    (start_capture_packets, (ad, "")),
-    (stop_capture_packets, (ad, "")),
-    (get_matched_packet_counts, (ad, "", ""))
-  )
-
-  for func, args in functions_with_args:
-    try:
-      func(*args)
-    except UnsupportedOperationException:
-      return False
-    except Exception:
-      continue
+  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
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/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index a65316f..7590a2b 100644
--- a/tests/cts/net/AndroidTestTemplate.xml
+++ b/tests/cts/net/AndroidTestTemplate.xml
@@ -60,7 +60,8 @@
     </test>
     <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
         <!-- Pattern matching the fileKey used by ConnectivityDiagnosticsCollector when calling addFileMetric -->
-        <option name="pull-pattern-keys" value="com.android.testutils.ConnectivityDiagnosticsCollector.*"/>
+        <option name="pull-pattern-keys" value="com.android.testutils.ConnectivityDiagnosticsCollector.*" />
+        <option name="log-data-type" value="CONNDIAG" />
         <option name="collect-on-run-ended-only" value="true" />
     </metrics_collector>
     <!-- When this test is run in a Mainline context (e.g. with `mts-tradefed`), only enable it if
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index 0e9ea0c..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,7 +3061,7 @@
 
         try {
             final Network cellNetwork = networkCallbackRule.requestCell();
-            ensureCellIsValidatedBeforeMockingValidationUrls();
+            ensureCellIsValidated();
             final Network wifiNetwork = prepareValidatedNetwork();
 
             final TestableNetworkCallback defaultCb =
@@ -3157,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);
@@ -3169,7 +3174,7 @@
     }
 
     private Network preparePartialConnectivity() throws Exception {
-        ensureCellIsValidatedBeforeMockingValidationUrls();
+        ensureCellIsValidated();
 
         prepareHttpServer();
         // Configure response code for partial connectivity
@@ -3184,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/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/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 11fc6df..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}";
@@ -119,12 +129,19 @@
     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;
@@ -151,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 =
@@ -163,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
@@ -184,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.";
-                        }
                     }
             };
 
@@ -215,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");
@@ -311,97 +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());
     }
 
+    @NonNull
+    private Network requestNetworkAndSetAttributes(
+            @NonNull NetworkInterfaceToTest networkInterface) {
+        final Network network = networkInterface.requestNetwork();
+
+        // 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 NetworkCallback callback = new NetworkCallback(tolerance,
-                new URL(CHECK_CONNECTIVITY_URL));
-        mCm.requestNetwork(new NetworkRequest.Builder()
-                .addTransportType(networkInterface.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) {
-            fail(networkInterface.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.4)
-                    + "ms)." + networkInterface.getErrorMessage());
-        }
+        final Network network = requestNetworkAndSetAttributes(networkInterface);
 
-        networkInterface.setMetered(callback.metered);
-        networkInterface.setRoaming(callback.roaming);
-        networkInterface.setIsDefault(callback.isDefault);
+        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) {
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index ad6fe63..7fc8863 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(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 = PollPacketReader(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 = PollPacketReader(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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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 = PollPacketReader(
-                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/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 83818be..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",
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/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/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 2659d24..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,6 +46,8 @@
 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;
@@ -63,6 +66,7 @@
     private TextView mTextNetworkInfo;
     private TextView mMigrateNetworkState;
     private TextView mEphemeralKeyStateText;
+    private SwitchMaterial mNat64Switch;
     private Executor mMainExecutor;
 
     private int mDeviceRole;
@@ -72,6 +76,7 @@
     private String mEphemeralKey;
     private Instant mEphemeralKeyExpiry;
     private Timer mEphemeralKeyLifetimeTimer;
+    private ThreadConfiguration mThreadConfiguration;
 
     private static final byte[] DEFAULT_ACTIVE_DATASET_TLVS =
             base16().lowerCase()
@@ -110,6 +115,10 @@
         }
     }
 
+    private static String booleanToEnabledOrDisabled(boolean enabled) {
+        return enabled ? "Enabled" : "Disabled";
+    }
+
     @Override
     public View onCreateView(
             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -181,11 +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!");
@@ -303,6 +317,34 @@
                 });
     }
 
+    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,
@@ -368,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 84d984b..47ce62a 100644
--- a/thread/demoapp/res/layout/thread_network_settings_fragment.xml
+++ b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
@@ -19,11 +19,10 @@
   android:layout_width="match_parent"
   android:layout_height="match_parent">
     <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:layout_width="wrap_content"
+        android:layout_height="wrap_content"
         android:padding="8dp"
         android:orientation="vertical"
         tools:context=".ThreadNetworkSettingsFragment" >
@@ -40,28 +39,28 @@
         <TextView
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:textSize="16dp"
+            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="12dp"
+            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: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="12dp" />
+            android:textSize="12sp" />
 
         <Button android:id="@+id/button_migrate_network"
             android:layout_width="wrap_content"
@@ -71,7 +70,7 @@
             android:id="@+id/text_migrate_network_state"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:textSize="12dp" />
+            android:textSize="12sp" />
 
         <Button android:id="@+id/button_activate_ephemeral_key_mode"
             android:layout_width="wrap_content"
@@ -86,14 +85,28 @@
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_marginTop="10dp"
-            android:textSize="16dp"
+            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:layout_marginBottom="50dp"
-            android:textSize="12dp" />
+            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/ThreadConfiguration.java b/thread/framework/java/android/net/thread/ThreadConfiguration.java
index e6fa1ef..edb5021 100644
--- a/thread/framework/java/android/net/thread/ThreadConfiguration.java
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.java
@@ -61,7 +61,11 @@
         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;
     }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 4e812fb..ba4eeaf 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -78,6 +78,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 +122,8 @@
 
 import com.android.connectivity.resources.R;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.RoutingCoordinatorManager;
+import com.android.net.module.util.IIpv4PrefixRequest;
 import com.android.net.module.util.SharedLog;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
@@ -141,11 +145,13 @@
 
 import java.io.IOException;
 import java.net.Inet6Address;
+import java.net.InetAddress;
 import java.security.SecureRandom;
 import java.time.Clock;
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -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 =
@@ -229,6 +237,7 @@
             NetworkProvider networkProvider,
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
+            RoutingCoordinatorManager routingCoordinatorManager,
             TunInterfaceController tunIfController,
             InfraInterfaceController infraIfController,
             ThreadPersistentSettings persistentSettings,
@@ -242,6 +251,7 @@
         mNetworkProvider = networkProvider;
         mOtDaemonSupplier = otDaemonSupplier;
         mConnectivityManager = connectivityManager;
+        mRoutingCoordinatorManager = routingCoordinatorManager;
         mTunIfController = tunIfController;
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
@@ -266,13 +276,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,
@@ -351,6 +367,7 @@
                 mCountryCodeSupplier.get());
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
+        mHandler.post(mNat64CidrController::maybeUpdateNat64Cidr);
         return mOtDaemon;
     }
 
@@ -444,24 +461,26 @@
     }
 
     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);
+        requestUpstreamNetwork();
+        registerThreadNetworkCallback();
+        mUserRestricted = isThreadUserRestricted();
+        registerUserRestrictionsReceiver();
+        maybeInitializeOtDaemon();
     }
 
     /**
@@ -587,6 +606,7 @@
         } catch (RemoteException | ThreadNetworkException e) {
             LOG.e("otDaemon.setConfiguration failed. Config: " + configuration, e);
         }
+        mNat64CidrController.maybeUpdateNat64Cidr();
     }
 
     private static OtDaemonConfiguration newOtDaemonConfig(
@@ -831,7 +851,7 @@
                 mHandler.getLooper(),
                 LOG.getTag(),
                 netCaps,
-                mTunIfController.getLinkProperties(),
+                getTunIfLinkProperties(),
                 newLocalNetworkConfig(),
                 score,
                 new NetworkAgentConfig.Builder().build(),
@@ -1324,6 +1344,7 @@
         }
         setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
         setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
+        setInfraLinkDnsServers(newInfraLinkState.dnsServers);
         mInfraLinkState = newInfraLinkState;
     }
 
@@ -1357,6 +1378,16 @@
         }
     }
 
+    private void setInfraLinkDnsServers(List<String> newDnsServers) {
+        try {
+            getOtDaemon()
+                    .setInfraLinkDnsServers(
+                            newDnsServers, new LoggingOtStatusReceiver("setInfraLinkDnsServers"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("Failed to set infra link DNS servers " + newDnsServers, e);
+        }
+    }
+
     private void handleThreadInterfaceStateChanged(boolean isUp) {
         try {
             mTunIfController.setInterfaceUp(isUp);
@@ -1389,9 +1420,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) {
@@ -1401,9 +1430,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(
@@ -1495,7 +1533,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 {
@@ -1849,4 +1897,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..5d869df 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
@@ -144,6 +149,8 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
+            case "config":
+                return handleConfigCommand();
             case "ot-ctl":
                 return handleOtCtlCommand();
             default:
@@ -261,6 +268,68 @@
         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 "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 +428,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/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/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index d9ce9e1..92227b4 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -1265,7 +1265,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
@@ -1290,7 +1293,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 cf7a4f7..f6dd6b9 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -19,6 +19,7 @@
 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.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv4Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
@@ -77,9 +78,11 @@
 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 +104,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 =
@@ -647,17 +649,27 @@
     }
 
     @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) {
+        }
     }
 
     @Test
@@ -685,8 +697,7 @@
         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);
 
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 87219d3..32e3b95 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/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index 116fb72..dc2a9c9 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
@@ -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.
      *
@@ -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> {
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 4a30c45..4354702 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -200,7 +200,7 @@
         runAsShell(
                 PERMISSION_THREAD_NETWORK_PRIVILEGED,
                 () -> mController.registerConfigurationCallback(directExecutor(), callback));
-        future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS);
         runAsShell(
                 PERMISSION_THREAD_NETWORK_PRIVILEGED,
                 () -> mController.unregisterConfigurationCallback(callback));
@@ -214,7 +214,14 @@
                 () ->
                         mController.setConfiguration(
                                 config, directExecutor(), newOutcomeReceiver(future)));
-        future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
+        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(
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 7ac404f..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("");
 
@@ -758,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..c0e99d7 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/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",