Merge "Use unique_fd for usableProgram" into main
diff --git a/Tethering/AndroidManifest.xml b/Tethering/AndroidManifest.xml
index 2a6f6d5..32442f5 100644
--- a/Tethering/AndroidManifest.xml
+++ b/Tethering/AndroidManifest.xml
@@ -34,8 +34,10 @@
     <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
     <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"/>
     <uses-permission android:name="android.permission.MANAGE_USB" />
+    <!-- MANAGE_USERS is for accessing multi-user APIs, note that QUERY_USERS should
+         not be used since it is not a privileged permission until U. -->
+    <uses-permission android:name="android.permission.MANAGE_USERS"/>
     <uses-permission android:name="android.permission.MODIFY_PHONE_STATE" />
-    <uses-permission android:name="android.permission.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 fcb287e..4051877 100644
--- a/Tethering/apex/permissions/permissions.xml
+++ b/Tethering/apex/permissions/permissions.xml
@@ -20,8 +20,8 @@
         <permission name="android.permission.BLUETOOTH_PRIVILEGED" />
         <permission name="android.permission.INTERACT_ACROSS_USERS"/>
         <permission name="android.permission.MANAGE_USB"/>
+        <permission name="android.permission.MANAGE_USERS"/>
         <permission name="android.permission.MODIFY_PHONE_STATE"/>
-        <permission name="android.permission.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 a680590..e893894 100644
--- a/Tethering/common/TetheringLib/api/module-lib-current.txt
+++ b/Tethering/common/TetheringLib/api/module-lib-current.txt
@@ -47,8 +47,8 @@
   }
 
   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();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable public String getPackageName();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public int getUid();
   }
 
 }
diff --git a/Tethering/common/TetheringLib/api/system-current.txt b/Tethering/common/TetheringLib/api/system-current.txt
index 3efaac2..0e85956 100644
--- a/Tethering/common/TetheringLib/api/system-current.txt
+++ b/Tethering/common/TetheringLib/api/system-current.txt
@@ -21,8 +21,10 @@
 
   public final class TetheringInterface implements android.os.Parcelable {
     ctor public TetheringInterface(int, @NonNull String);
+    ctor @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public TetheringInterface(int, @NonNull String, @Nullable android.net.wifi.SoftApConfiguration);
     method public int describeContents();
     method @NonNull public String getInterface();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable @RequiresPermission(value=android.Manifest.permission.NETWORK_SETTINGS, conditional=true) public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
     method public int getType();
     method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringInterface> CREATOR;
@@ -97,16 +99,16 @@
   }
 
   public static final class TetheringManager.TetheringRequest implements android.os.Parcelable {
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public int describeContents();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public int describeContents();
     method @Nullable public android.net.LinkAddress getClientStaticIpv4Address();
     method public int getConnectivityScope();
     method @Nullable public android.net.LinkAddress getLocalIpv4Address();
     method public boolean getShouldShowEntitlementUi();
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @Nullable public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @Nullable public android.net.wifi.SoftApConfiguration getSoftApConfiguration();
     method public int getTetheringType();
     method public boolean isExemptFromEntitlementCheck();
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") public void writeToParcel(@NonNull android.os.Parcel, int);
-    field @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringManager.TetheringRequest> CREATOR;
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @NonNull public static final android.os.Parcelable.Creator<android.net.TetheringManager.TetheringRequest> CREATOR;
   }
 
   public static class TetheringManager.TetheringRequest.Builder {
@@ -115,7 +117,7 @@
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setConnectivityScope(int);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setExemptFromEntitlementCheck(boolean);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setShouldShowEntitlementUi(boolean);
-    method @FlaggedApi("com.android.net.flags.tethering_request_with_soft_ap_config") @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setSoftApConfiguration(@Nullable android.net.wifi.SoftApConfiguration);
+    method @FlaggedApi("com.android.net.flags.tethering_with_soft_ap_config") @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setSoftApConfiguration(@Nullable android.net.wifi.SoftApConfiguration);
     method @NonNull @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED) public android.net.TetheringManager.TetheringRequest.Builder setStaticIpv4Addresses(@NonNull android.net.LinkAddress, @NonNull android.net.LinkAddress);
   }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java b/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
index 84cdef1..0464fe0 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringInterface.java
@@ -16,13 +16,19 @@
 
 package android.net;
 
+import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.net.TetheringManager.TetheringType;
+import android.net.wifi.SoftApConfiguration;
 import android.os.Parcel;
 import android.os.Parcelable;
 
+import com.android.net.flags.Flags;
+
 import java.util.Objects;
 
 /**
@@ -33,15 +39,21 @@
 public final class TetheringInterface implements Parcelable {
     private final int mType;
     private final String mInterface;
+    @Nullable
+    private final SoftApConfiguration mSoftApConfig;
 
+    @SuppressLint("UnflaggedApi")
     public TetheringInterface(@TetheringType int type, @NonNull String iface) {
+        this(type, iface, null);
+    }
+
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    public TetheringInterface(@TetheringType int type, @NonNull String iface,
+            @Nullable SoftApConfiguration softApConfig) {
         Objects.requireNonNull(iface);
         mType = type;
         mInterface = iface;
-    }
-
-    private TetheringInterface(@NonNull Parcel in) {
-        this(in.readInt(), in.readString());
+        mSoftApConfig = softApConfig;
     }
 
     /** Get tethering type. */
@@ -55,22 +67,36 @@
         return mInterface;
     }
 
+    /**
+     * Get the SoftApConfiguration provided for this interface, if any. This will only be populated
+     * for apps with the same uid that specified the configuration, or apps with permission
+     * {@link android.Manifest.permission.NETWORK_SETTINGS}.
+     */
+    @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
+    @RequiresPermission(value = android.Manifest.permission.NETWORK_SETTINGS, conditional = true)
+    @Nullable
+    public SoftApConfiguration getSoftApConfiguration() {
+        return mSoftApConfig;
+    }
+
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
         dest.writeInt(mType);
         dest.writeString(mInterface);
+        dest.writeParcelable(mSoftApConfig, flags);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(mType, mInterface);
+        return Objects.hash(mType, mInterface, mSoftApConfig);
     }
 
     @Override
     public boolean equals(@Nullable Object obj) {
         if (!(obj instanceof TetheringInterface)) return false;
         final TetheringInterface other = (TetheringInterface) obj;
-        return mType == other.mType && mInterface.equals(other.mInterface);
+        return mType == other.mType && mInterface.equals(other.mInterface)
+                && Objects.equals(mSoftApConfig, other.mSoftApConfig);
     }
 
     @Override
@@ -82,8 +108,10 @@
     public static final Creator<TetheringInterface> CREATOR = new Creator<TetheringInterface>() {
         @NonNull
         @Override
+        @SuppressLint("UnflaggedApi")
         public TetheringInterface createFromParcel(@NonNull Parcel in) {
-            return new TetheringInterface(in);
+            return new TetheringInterface(in.readInt(), in.readString(),
+                    in.readParcelable(SoftApConfiguration.class.getClassLoader()));
         }
 
         @NonNull
@@ -97,6 +125,8 @@
     @Override
     public String toString() {
         return "TetheringInterface {mType=" + mType
-                + ", mInterface=" + mInterface + "}";
+                + ", mInterface=" + mInterface
+                + ((mSoftApConfig == null) ? "" : ", mSoftApConfig=" + mSoftApConfig)
+                + "}";
     }
 }
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 7c7a4e0..bc771da 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -54,6 +54,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.StringJoiner;
 import java.util.concurrent.Executor;
 import java.util.function.Supplier;
 
@@ -208,6 +209,20 @@
      */
     public static final int MAX_TETHERING_TYPE = TETHERING_VIRTUAL;
 
+    private static String typeToString(@TetheringType int type) {
+        switch (type) {
+            case TETHERING_INVALID: return "TETHERING_INVALID";
+            case TETHERING_WIFI: return "TETHERING_WIFI";
+            case TETHERING_USB: return "TETHERING_USB";
+            case TETHERING_BLUETOOTH: return "TETHERING_BLUETOOTH";
+            case TETHERING_WIFI_P2P: return "TETHERING_WIFI_P2P";
+            case TETHERING_NCM: return "TETHERING_NCM";
+            case TETHERING_ETHERNET: return "TETHERING_ETHERNET";
+            default:
+                return "TETHERING_UNKNOWN(" + type + ")";
+        }
+    }
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(value = {
@@ -689,6 +704,17 @@
     })
     public @interface ConnectivityScope {}
 
+    private static String connectivityScopeToString(@ConnectivityScope int scope) {
+        switch (scope) {
+            case CONNECTIVITY_SCOPE_GLOBAL:
+                return "CONNECTIVITY_SCOPE_GLOBAL";
+            case CONNECTIVITY_SCOPE_LOCAL:
+                return "CONNECTIVITY_SCOPE_LOCAL";
+            default:
+                return "CONNECTIVITY_SCOPE_UNKNOWN(" + scope + ")";
+        }
+    }
+
     /**
      *  Use with {@link #startTethering} to specify additional parameters when starting tethering.
      */
@@ -699,7 +725,7 @@
         /**
          * @hide
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         public TetheringRequest(@NonNull final TetheringRequestParcel request) {
             mRequestParcel = request;
         }
@@ -708,7 +734,7 @@
             mRequestParcel = in.readParcelable(TetheringRequestParcel.class.getClassLoader());
         }
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @NonNull
         public static final Creator<TetheringRequest> CREATOR = new Creator<>() {
             @Override
@@ -722,13 +748,13 @@
             }
         };
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Override
         public int describeContents() {
             return 0;
         }
 
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Override
         public void writeToParcel(@NonNull Parcel dest, int flags) {
             dest.writeParcelable(mRequestParcel, flags);
@@ -820,7 +846,7 @@
              * @param softApConfig SoftApConfiguration to use.
              * @throws IllegalArgumentException if the tethering type isn't TETHERING_WIFI.
              */
-            @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+            @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
             @RequiresPermission(android.Manifest.permission.TETHER_PRIVILEGED)
             @NonNull
             public Builder setSoftApConfiguration(@Nullable SoftApConfiguration softApConfig) {
@@ -915,7 +941,7 @@
         /**
          * Get the desired SoftApConfiguration of the request, if one was specified.
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @Nullable
         public SoftApConfiguration getSoftApConfiguration() {
             return mRequestParcel.softApConfig;
@@ -944,7 +970,7 @@
          * {@link Process#INVALID_UID} if unset.
          * @hide
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @SystemApi(client = MODULE_LIBRARIES)
         public int getUid() {
             return mRequestParcel.uid;
@@ -955,7 +981,7 @@
          * unset.
          * @hide
          */
-        @FlaggedApi(Flags.FLAG_TETHERING_REQUEST_WITH_SOFT_AP_CONFIG)
+        @FlaggedApi(Flags.FLAG_TETHERING_WITH_SOFT_AP_CONFIG)
         @SystemApi(client = MODULE_LIBRARIES)
         @Nullable
         public String getPackageName() {
@@ -972,15 +998,31 @@
 
         /** String of TetheringRequest detail. */
         public String toString() {
-            return "TetheringRequest [ type= " + mRequestParcel.tetheringType
-                    + ", localIPv4Address= " + mRequestParcel.localIPv4Address
-                    + ", staticClientAddress= " + mRequestParcel.staticClientAddress
-                    + ", exemptFromEntitlementCheck= " + mRequestParcel.exemptFromEntitlementCheck
-                    + ", showProvisioningUi= " + mRequestParcel.showProvisioningUi
-                    + ", softApConfig= " + mRequestParcel.softApConfig
-                    + ", uid= " + mRequestParcel.uid
-                    + ", packageName= " + mRequestParcel.packageName
-                    + " ]";
+            StringJoiner sj = new StringJoiner(", ", "TetheringRequest[ ", " ]");
+            sj.add(typeToString(mRequestParcel.tetheringType));
+            if (mRequestParcel.localIPv4Address != null) {
+                sj.add("localIpv4Address=" + mRequestParcel.localIPv4Address);
+            }
+            if (mRequestParcel.staticClientAddress != null) {
+                sj.add("staticClientAddress=" + mRequestParcel.staticClientAddress);
+            }
+            if (mRequestParcel.exemptFromEntitlementCheck) {
+                sj.add("exemptFromEntitlementCheck");
+            }
+            if (mRequestParcel.showProvisioningUi) {
+                sj.add("showProvisioningUi");
+            }
+            sj.add(connectivityScopeToString(mRequestParcel.connectivityScope));
+            if (mRequestParcel.softApConfig != null) {
+                sj.add("softApConfig=" + mRequestParcel.softApConfig);
+            }
+            if (mRequestParcel.uid != Process.INVALID_UID) {
+                sj.add("uid=" + mRequestParcel.uid);
+            }
+            if (mRequestParcel.packageName != null) {
+                sj.add("packageName=" + mRequestParcel.packageName);
+            }
+            return sj.toString();
         }
 
         @Override
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index a0604f2..ebc9e4e 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -69,8 +69,8 @@
 import com.android.internal.util.MessageUtils;
 import com.android.internal.util.State;
 import com.android.modules.utils.build.SdkLevel;
-import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.IIpv4PrefixRequest;
+import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.SharedLog;
@@ -168,10 +168,10 @@
         /**
          * Request Tethering change.
          *
-         * @param tetheringType the downstream type of this IpServer.
+         * @param request the TetheringRequest this IpServer was enabled with.
          * @param enabled enable or disable tethering.
          */
-        public void requestEnableTethering(int tetheringType, boolean enabled) { }
+        public void requestEnableTethering(TetheringRequest request, boolean enabled) { }
     }
 
     /** Capture IpServer dependencies, for injection. */
@@ -293,6 +293,9 @@
 
     private LinkAddress mIpv4Address;
 
+    @Nullable
+    private TetheringRequest mTetheringRequest;
+
     private final TetheringMetrics mTetheringMetrics;
     private final Handler mHandler;
 
@@ -406,6 +409,12 @@
         return mIpv4PrefixRequest;
     }
 
+    /** The TetheringRequest the IpServer started with. */
+    @Nullable
+    public TetheringRequest getTetheringRequest() {
+        return mTetheringRequest;
+    }
+
     /**
      * Get the latest list of DHCP leases that was reported. Must be called on the IpServer looper
      * thread.
@@ -1033,6 +1042,7 @@
             switch (message.what) {
                 case CMD_TETHER_REQUESTED:
                     mLastError = TETHER_ERROR_NO_ERROR;
+                    mTetheringRequest = (TetheringRequest) message.obj;
                     switch (message.arg1) {
                         case STATE_LOCAL_ONLY:
                             maybeConfigureStaticIp((TetheringRequest) message.obj);
@@ -1168,8 +1178,8 @@
                     handleNewPrefixRequest((IpPrefix) message.obj);
                     break;
                 case CMD_NOTIFY_PREFIX_CONFLICT:
-                    mLog.i("restart tethering: " + mInterfaceType);
-                    mCallback.requestEnableTethering(mInterfaceType, false /* enabled */);
+                    mLog.i("restart tethering: " + mIfaceName);
+                    mCallback.requestEnableTethering(mTetheringRequest, false /* enabled */);
                     transitionTo(mWaitingForRestartState);
                     break;
                 case CMD_SERVICE_FAILED_TO_START:
@@ -1453,12 +1463,12 @@
                 case CMD_TETHER_UNREQUESTED:
                     transitionTo(mInitialState);
                     mLog.i("Untethered (unrequested) and restarting " + mIfaceName);
-                    mCallback.requestEnableTethering(mInterfaceType, true /* enabled */);
+                    mCallback.requestEnableTethering(mTetheringRequest, true /* enabled */);
                     break;
                 case CMD_INTERFACE_DOWN:
                     transitionTo(mUnavailableState);
                     mLog.i("Untethered (interface down) and restarting " + mIfaceName);
-                    mCallback.requestEnableTethering(mInterfaceType, true /* enabled */);
+                    mCallback.requestEnableTethering(mTetheringRequest, true /* enabled */);
                     break;
                 default:
                     return false;
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index df255f3..f33ef37 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -109,6 +109,7 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteCallbackList;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
@@ -216,10 +217,12 @@
      * Cookie added when registering {@link android.net.TetheringManager.TetheringEventCallback}.
      */
     private static class CallbackCookie {
-        public final boolean hasListClientsPermission;
+        public final int uid;
+        public final boolean hasSystemPrivilege;
 
-        private CallbackCookie(boolean hasListClientsPermission) {
-            this.hasListClientsPermission = hasListClientsPermission;
+        private CallbackCookie(int uid, boolean hasSystemPrivilege) {
+            this.uid = uid;
+            this.hasSystemPrivilege = hasSystemPrivilege;
         }
     }
 
@@ -263,7 +266,6 @@
     private boolean mRndisEnabled;       // track the RNDIS function enabled state
     private boolean mNcmEnabled;         // track the NCM function enabled state
     private Network mTetherUpstream;
-    private TetherStatesParcel mTetherStatesParcel;
     private boolean mDataSaverEnabled = false;
     private String mWifiP2pTetherInterface = null;
     private int mOffloadStatus = TETHER_HARDWARE_OFFLOAD_STOPPED;
@@ -1091,21 +1093,55 @@
     }
 
     // TODO: Figure out how to update for local hotspot mode interfaces.
-    private void sendTetherStateChangedBroadcast() {
+    private void notifyTetherStatesChanged() {
         if (!isTetheringSupported()) return;
 
+        sendTetherStatesChangedCallback();
+        sendTetherStatesChangedBroadcast();
+
+        int downstreamTypesMask = DOWNSTREAM_NONE;
+        for (int i = 0; i < mTetherStates.size(); i++) {
+            final TetherState tetherState = mTetherStates.valueAt(i);
+            final int type = tetherState.ipServer.interfaceType();
+            if (tetherState.lastState != IpServer.STATE_TETHERED) continue;
+            switch (type) {
+                case TETHERING_USB:
+                case TETHERING_WIFI:
+                case TETHERING_BLUETOOTH:
+                    downstreamTypesMask |= (1 << type);
+                    break;
+                default:
+                    // Do nothing.
+                    break;
+            }
+        }
+        mNotificationUpdater.onDownstreamChanged(downstreamTypesMask);
+    }
+
+    /**
+     * Builds a TetherStatesParcel for the specified CallbackCookie. SoftApConfiguration will only
+     * be included if the cookie has the same uid as the app that specified the configuration, or
+     * if the cookie has system privilege.
+     *
+     * @param cookie CallbackCookie of the receiving app.
+     * @return TetherStatesParcel with information redacted for the specified cookie.
+     */
+    private TetherStatesParcel buildTetherStatesParcel(CallbackCookie cookie) {
         final ArrayList<TetheringInterface> available = new ArrayList<>();
         final ArrayList<TetheringInterface> tethered = new ArrayList<>();
         final ArrayList<TetheringInterface> localOnly = new ArrayList<>();
         final ArrayList<TetheringInterface> errored = new ArrayList<>();
         final ArrayList<Integer> lastErrors = new ArrayList<>();
 
-        int downstreamTypesMask = DOWNSTREAM_NONE;
         for (int i = 0; i < mTetherStates.size(); i++) {
             final TetherState tetherState = mTetherStates.valueAt(i);
             final int type = tetherState.ipServer.interfaceType();
             final String iface = mTetherStates.keyAt(i);
-            final TetheringInterface tetheringIface = new TetheringInterface(type, iface);
+            final TetheringRequest request = tetherState.ipServer.getTetheringRequest();
+            final boolean includeSoftApConfig = request != null && cookie != null
+                    && (cookie.uid == request.getUid() || cookie.hasSystemPrivilege);
+            final TetheringInterface tetheringIface = new TetheringInterface(type, iface,
+                    includeSoftApConfig ? request.getSoftApConfiguration() : null);
             if (tetherState.lastError != TETHER_ERROR_NO_ERROR) {
                 errored.add(tetheringIface);
                 lastErrors.add(tetherState.lastError);
@@ -1118,41 +1154,16 @@
                     case TETHERING_USB:
                     case TETHERING_WIFI:
                     case TETHERING_BLUETOOTH:
-                        downstreamTypesMask |= (1 << type);
                         break;
                     default:
                         // Do nothing.
+                        break;
                 }
                 tethered.add(tetheringIface);
             }
         }
 
-        mTetherStatesParcel = buildTetherStatesParcel(available, localOnly, tethered, errored,
-                lastErrors);
-        reportTetherStateChanged(mTetherStatesParcel);
-
-        mContext.sendStickyBroadcastAsUser(buildStateChangeIntent(available, localOnly, tethered,
-                errored), UserHandle.ALL);
-        if (DBG) {
-            Log.d(TAG, String.format(
-                    "reportTetherStateChanged %s=[%s] %s=[%s] %s=[%s] %s=[%s]",
-                    "avail", TextUtils.join(",", available),
-                    "local_only", TextUtils.join(",", localOnly),
-                    "tether", TextUtils.join(",", tethered),
-                    "error", TextUtils.join(",", errored)));
-        }
-
-        mNotificationUpdater.onDownstreamChanged(downstreamTypesMask);
-    }
-
-    private TetherStatesParcel buildTetherStatesParcel(
-            final ArrayList<TetheringInterface> available,
-            final ArrayList<TetheringInterface> localOnly,
-            final ArrayList<TetheringInterface> tethered,
-            final ArrayList<TetheringInterface> errored,
-            final ArrayList<Integer> lastErrors) {
         final TetherStatesParcel parcel = new TetherStatesParcel();
-
         parcel.availableList = available.toArray(new TetheringInterface[0]);
         parcel.tetheredList = tethered.toArray(new TetheringInterface[0]);
         parcel.localOnlyList = localOnly.toArray(new TetheringInterface[0]);
@@ -1161,23 +1172,23 @@
         for (int i = 0; i < lastErrors.size(); i++) {
             parcel.lastErrorList[i] = lastErrors.get(i);
         }
-
         return parcel;
     }
 
-    private Intent buildStateChangeIntent(final ArrayList<TetheringInterface> available,
-            final ArrayList<TetheringInterface> localOnly,
-            final ArrayList<TetheringInterface> tethered,
-            final ArrayList<TetheringInterface> errored) {
+    private void sendTetherStatesChangedBroadcast() {
         final Intent bcast = new Intent(ACTION_TETHER_STATE_CHANGED);
         bcast.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING);
 
-        bcast.putStringArrayListExtra(EXTRA_AVAILABLE_TETHER, toIfaces(available));
-        bcast.putStringArrayListExtra(EXTRA_ACTIVE_LOCAL_ONLY, toIfaces(localOnly));
-        bcast.putStringArrayListExtra(EXTRA_ACTIVE_TETHER, toIfaces(tethered));
-        bcast.putStringArrayListExtra(EXTRA_ERRORED_TETHER, toIfaces(errored));
-
-        return bcast;
+        TetherStatesParcel parcel = buildTetherStatesParcel(null /* cookie */);
+        bcast.putStringArrayListExtra(
+                EXTRA_AVAILABLE_TETHER, toIfaces(Arrays.asList(parcel.availableList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ACTIVE_LOCAL_ONLY, toIfaces(Arrays.asList(parcel.localOnlyList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ACTIVE_TETHER, toIfaces(Arrays.asList(parcel.tetheredList)));
+        bcast.putStringArrayListExtra(
+                EXTRA_ERRORED_TETHER, toIfaces(Arrays.asList(parcel.erroredIfaceList)));
+        mContext.sendStickyBroadcastAsUser(bcast, UserHandle.ALL);
     }
 
     private class StateReceiver extends BroadcastReceiver {
@@ -2192,9 +2203,9 @@
                         break;
                     }
                     case EVENT_REQUEST_CHANGE_DOWNSTREAM: {
-                        final int tetheringType = message.arg1;
-                        final Boolean enabled = (Boolean) message.obj;
-                        enableTetheringInternal(tetheringType, enabled, null);
+                        final boolean enabled = message.arg1 == 1;
+                        final TetheringRequest request = (TetheringRequest) message.obj;
+                        enableTetheringInternal(request.getTetheringType(), enabled, null);
                         break;
                     }
                     default:
@@ -2391,19 +2402,19 @@
 
     /** Register tethering event callback */
     void registerTetheringEventCallback(ITetheringEventCallback callback) {
-        final boolean hasListPermission =
-                hasCallingPermission(NETWORK_SETTINGS)
-                        || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
-                        || hasCallingPermission(NETWORK_STACK);
+        final int uid = mDeps.getBinderCallingUid();
+        final boolean hasSystemPrivilege = hasCallingPermission(NETWORK_SETTINGS)
+                || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
+                || hasCallingPermission(NETWORK_STACK);
         mHandler.post(() -> {
-            mTetheringEventCallbacks.register(callback, new CallbackCookie(hasListPermission));
+            CallbackCookie cookie = new CallbackCookie(uid, hasSystemPrivilege);
+            mTetheringEventCallbacks.register(callback, cookie);
             final TetheringCallbackStartedParcel parcel = new TetheringCallbackStartedParcel();
             parcel.supportedTypes = mSupportedTypeBitmap;
             parcel.upstreamNetwork = mTetherUpstream;
             parcel.config = mConfig.toStableParcelable();
-            parcel.states =
-                    mTetherStatesParcel != null ? mTetherStatesParcel : emptyTetherStatesParcel();
-            parcel.tetheredClients = hasListPermission
+            parcel.states = buildTetherStatesParcel(cookie);
+            parcel.tetheredClients = hasSystemPrivilege
                     ? mConnectedClientsTracker.getLastTetheredClients()
                     : Collections.emptyList();
             parcel.offloadStatus = mOffloadStatus;
@@ -2415,17 +2426,6 @@
         });
     }
 
-    private TetherStatesParcel emptyTetherStatesParcel() {
-        final TetherStatesParcel parcel = new TetherStatesParcel();
-        parcel.availableList = new TetheringInterface[0];
-        parcel.tetheredList = new TetheringInterface[0];
-        parcel.localOnlyList = new TetheringInterface[0];
-        parcel.erroredIfaceList = new TetheringInterface[0];
-        parcel.lastErrorList = new int[0];
-
-        return parcel;
-    }
-
     private boolean hasCallingPermission(@NonNull String permission) {
         return mContext.checkCallingOrSelfPermission(permission) == PERMISSION_GRANTED;
     }
@@ -2484,12 +2484,14 @@
         }
     }
 
-    private void reportTetherStateChanged(TetherStatesParcel states) {
+    private void sendTetherStatesChangedCallback() {
         final int length = mTetheringEventCallbacks.beginBroadcast();
         try {
             for (int i = 0; i < length; i++) {
                 try {
-                    mTetheringEventCallbacks.getBroadcastItem(i).onTetherStatesChanged(states);
+                    TetherStatesParcel parcel = buildTetherStatesParcel(
+                            (CallbackCookie) mTetheringEventCallbacks.getBroadcastCookie(i));
+                    mTetheringEventCallbacks.getBroadcastItem(i).onTetherStatesChanged(parcel);
                 } catch (RemoteException e) {
                     // Not really very much to do here.
                 }
@@ -2497,6 +2499,18 @@
         } finally {
             mTetheringEventCallbacks.finishBroadcast();
         }
+
+        if (DBG) {
+            // Use a CallbackCookie with system privilege so nothing is redacted.
+            TetherStatesParcel parcel = buildTetherStatesParcel(
+                    new CallbackCookie(Process.SYSTEM_UID, true /* hasSystemPrivilege */));
+            Log.d(TAG, String.format(
+                    "sendTetherStatesChangedCallback %s=[%s] %s=[%s] %s=[%s] %s=[%s]",
+                    "avail", TextUtils.join(",", Arrays.asList(parcel.availableList)),
+                    "local_only", TextUtils.join(",", Arrays.asList(parcel.localOnlyList)),
+                    "tether", TextUtils.join(",", Arrays.asList(parcel.tetheredList)),
+                    "error", TextUtils.join(",", Arrays.asList(parcel.erroredIfaceList))));
+        }
     }
 
     private void reportTetherClientsChanged(List<TetheredClient> clients) {
@@ -2506,7 +2520,7 @@
                 try {
                     final CallbackCookie cookie =
                             (CallbackCookie) mTetheringEventCallbacks.getBroadcastCookie(i);
-                    if (!cookie.hasListClientsPermission) continue;
+                    if (!cookie.hasSystemPrivilege) continue;
                     mTetheringEventCallbacks.getBroadcastItem(i).onTetherClientsChanged(clients);
                 } catch (RemoteException e) {
                     // Not really very much to do here.
@@ -2741,7 +2755,7 @@
                     return;
             }
             mTetherMainSM.sendMessage(which, state, 0, who);
-            sendTetherStateChangedBroadcast();
+            notifyTetherStatesChanged();
         }
 
         @Override
@@ -2769,9 +2783,9 @@
         }
 
         @Override
-        public void requestEnableTethering(int tetheringType, boolean enabled) {
+        public void requestEnableTethering(TetheringRequest request, boolean enabled) {
             mTetherMainSM.sendMessage(TetherMainSM.EVENT_REQUEST_CHANGE_DOWNSTREAM,
-                    tetheringType, 0, enabled ? Boolean.TRUE : Boolean.FALSE);
+                    enabled ? 1 : 0, 0, request);
         }
     }
 
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index a4823ca..d89bf4d 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -25,6 +25,7 @@
 import android.net.INetd;
 import android.net.connectivity.ConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
+import android.os.Binder;
 import android.os.Build;
 import android.os.Handler;
 import android.os.IBinder;
@@ -36,7 +37,6 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.modules.utils.build.SdkLevel;
-import com.android.net.module.util.PrivateAddressCoordinator;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
@@ -201,4 +201,11 @@
     public WearableConnectionManager makeWearableConnectionManager(Context ctx) {
         return new WearableConnectionManager(ctx);
     }
+
+    /**
+     * Wrapper to get the binder calling uid for unit testing.
+     */
+    public int getBinderCallingUid() {
+        return Binder.getCallingUid();
+    }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java b/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
index a0198cc..a29f0c2 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringNotificationUpdater.java
@@ -85,7 +85,6 @@
     static final int ROAMING_NOTIFICATION_ID = 1003;
     @VisibleForTesting
     static final int NO_ICON_ID = 0;
-    @VisibleForTesting
     static final int DOWNSTREAM_NONE = 0;
     // Refer to TelephonyManager#getSimCarrierId for more details about carrier id.
     @VisibleForTesting
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index cea7e82..3cb5f99 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -28,6 +28,7 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNSUPPORTED;
 import static android.net.dhcp.IDhcpServer.STATUS_UNKNOWN_ERROR;
 
+import android.app.AppOpsManager;
 import android.app.Service;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothManager;
@@ -55,7 +56,6 @@
 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;
 
@@ -351,7 +351,11 @@
     boolean checkPackageNameMatchesUid(@NonNull Context context, int uid,
             @NonNull String callingPackage) {
         try {
-            PermissionUtils.enforcePackageNameMatchesUid(context, uid, callingPackage);
+            final AppOpsManager mAppOps = context.getSystemService(AppOpsManager.class);
+            if (mAppOps == null) {
+                return false;
+            }
+            mAppOps.checkPackage(uid, callingPackage);
         } catch (SecurityException e) {
             return false;
         }
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 1988311..0dbf772 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringServiceTest.java
@@ -33,13 +33,16 @@
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import android.app.AppOpsManager;
 import android.app.UiAutomation;
 import android.content.Intent;
 import android.net.IIntResultListener;
@@ -82,6 +85,7 @@
     private static final String TEST_CALLER_PKG = "com.android.shell";
     private static final int TEST_CALLER_UID = 1234;
     private static final String TEST_ATTRIBUTION_TAG = null;
+    private static final String TEST_WRONG_PACKAGE = "wrong.package";
     @Mock private ITetheringEventCallback mITetheringEventCallback;
     @Rule public ServiceTestRule mServiceTestRule;
     private Tethering mTethering;
@@ -89,6 +93,7 @@
     private MockTetheringConnector mMockConnector;
     private ITetheringConnector mTetheringConnector;
     private UiAutomation mUiAutomation;
+    @Mock private AppOpsManager mAppOps;
 
     private class TestTetheringResult extends IIntResultListener.Stub {
         private int mResult = -1; // Default value that does not match any result code.
@@ -132,6 +137,8 @@
         mTethering = service.getTethering();
         mMockConnector.setCallingUid(TEST_CALLER_UID);
         mMockConnector.setPackageNameUid(TEST_CALLER_PKG, TEST_CALLER_UID);
+        doThrow(new SecurityException()).when(mAppOps).checkPackage(anyInt(),
+                eq(TEST_WRONG_PACKAGE));
     }
 
     @After
@@ -334,11 +341,10 @@
         });
 
         runAsTetherPrivileged((result) -> {
-            String wrongPackage = "wrong.package";
-            mTetheringConnector.startTethering(request, wrongPackage,
+            mTetheringConnector.startTethering(request, TEST_WRONG_PACKAGE,
                     TEST_ATTRIBUTION_TAG, result);
             verify(mTethering, never()).startTethering(
-                    eq(new TetheringRequest(request)), eq(wrongPackage), eq(result));
+                    eq(new TetheringRequest(request)), eq(TEST_WRONG_PACKAGE), eq(result));
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractionsForTethering();
         });
@@ -461,7 +467,7 @@
 
         runAsTetherPrivileged((none) -> {
             mTetheringConnector.requestLatestTetheringEntitlementResult(TETHERING_WIFI, result,
-                    true /* showEntitlementUi */, "wrong.package", TEST_ATTRIBUTION_TAG);
+                    true /* showEntitlementUi */, TEST_WRONG_PACKAGE, TEST_ATTRIBUTION_TAG);
             result.assertResult(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
             verifyNoMoreInteractions(mTethering);
         });
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index d0c036f..17f5081 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -17,7 +17,10 @@
 package com.android.networkstack.tethering;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.Manifest.permission.NETWORK_STACK;
 import static android.content.pm.PackageManager.GET_ACTIVITIES;
+import static android.content.pm.PackageManager.PERMISSION_DENIED;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 import static android.hardware.usb.UsbManager.USB_CONFIGURED;
 import static android.hardware.usb.UsbManager.USB_CONNECTED;
 import static android.hardware.usb.UsbManager.USB_FUNCTION_NCM;
@@ -33,6 +36,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
+import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
 import static android.net.RouteInfo.RTN_UNICAST;
 import static android.net.TetheringManager.ACTION_TETHER_STATE_CHANGED;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -164,6 +168,7 @@
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
+import android.net.wifi.WifiSsid;
 import android.net.wifi.p2p.WifiP2pGroup;
 import android.net.wifi.p2p.WifiP2pInfo;
 import android.net.wifi.p2p.WifiP2pManager;
@@ -190,6 +195,7 @@
 
 import com.android.internal.util.test.BroadcastInterceptingContext;
 import com.android.internal.util.test.FakeSettingsProvider;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.PrivateAddressCoordinator;
@@ -223,6 +229,7 @@
 import java.io.PrintWriter;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -257,6 +264,7 @@
     private static final String TEST_WIFI_REGEX = "test_wlan\\d";
     private static final String TEST_P2P_REGEX = "test_p2p-p2p\\d-.*";
     private static final String TEST_BT_REGEX = "test_pan\\d";
+    private static final int TEST_CALLER_UID = 1000;
     private static final String TEST_CALLER_PKG = "com.test.tethering";
 
     private static final int CELLULAR_NETID = 100;
@@ -328,6 +336,7 @@
 
     private TestConnectivityManager mCm;
     private boolean mForceEthernetServiceUnavailable = false;
+    private int mBinderCallingUid = TEST_CALLER_UID;
 
     private class TestContext extends BroadcastInterceptingContext {
         TestContext(Context base) {
@@ -555,6 +564,11 @@
             }
             return mBluetoothPanShim;
         }
+
+        @Override
+        public int getBinderCallingUid() {
+            return mBinderCallingUid;
+        }
     }
 
     private static LinkProperties buildUpstreamLinkProperties(String interfaceName,
@@ -2335,7 +2349,7 @@
         // 2. Enable wifi tethering.
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
         tetherState = callback.pollTetherStatesChanged();
@@ -2380,6 +2394,105 @@
     }
 
     @Test
+    public void testSoftApConfigInTetheringEventCallback() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastV());
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_DENIED);
+        when(mContext.checkCallingOrSelfPermission(NETWORK_STACK))
+                .thenReturn(PERMISSION_DENIED);
+        when(mContext.checkCallingOrSelfPermission(PERMISSION_MAINLINE_NETWORK_STACK))
+                .thenReturn(PERMISSION_DENIED);
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        TestTetheringEventCallback differentCallback = new TestTetheringEventCallback();
+        TestTetheringEventCallback settingsCallback = new TestTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        final TetheringInterface wifiIfaceWithoutConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, null);
+        final TetheringInterface wifiIfaceWithConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, softApConfig);
+
+        // Register callback before running any tethering.
+        mTethering.registerTetheringEventCallback(callback);
+        mLooper.dispatchAll();
+        callback.expectTetheredClientChanged(Collections.emptyList());
+        callback.expectUpstreamChanged(NULL_NETWORK);
+        callback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+        // Register callback with different UID
+        mBinderCallingUid = TEST_CALLER_UID + 1;
+        mTethering.registerTetheringEventCallback(differentCallback);
+        mLooper.dispatchAll();
+        differentCallback.expectTetheredClientChanged(Collections.emptyList());
+        differentCallback.expectUpstreamChanged(NULL_NETWORK);
+        differentCallback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+        // Register Settings callback
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_GRANTED);
+        mTethering.registerTetheringEventCallback(settingsCallback);
+        mLooper.dispatchAll();
+        settingsCallback.expectTetheredClientChanged(Collections.emptyList());
+        settingsCallback.expectUpstreamChanged(NULL_NETWORK);
+        settingsCallback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        assertTetherStatesNotNullButEmpty(differentCallback.pollTetherStatesChanged());
+        assertTetherStatesNotNullButEmpty(settingsCallback.pollTetherStatesChanged());
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
+        mLooper.dispatchAll();
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                callback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                differentCallback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                settingsCallback.pollTetherStatesChanged().availableList);
+
+        // Enable wifi tethering
+        mBinderCallingUid = TEST_CALLER_UID;
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                callback.pollTetherStatesChanged().tetheredList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                differentCallback.pollTetherStatesChanged().tetheredList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                settingsCallback.pollTetherStatesChanged().tetheredList);
+        callback.expectUpstreamChanged(upstreamState.network);
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STARTED);
+
+        // Disable wifi tethering
+        mLooper.dispatchAll();
+        mTethering.stopTethering(TETHERING_WIFI);
+        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED);
+        if (isAtLeastT()) {
+            // After T, tethering doesn't support WIFI_AP_STATE_DISABLED with null interface name.
+            callback.assertNoStateChangeCallback();
+            sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
+                    IFACE_IP_MODE_TETHERED);
+        }
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                callback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                differentCallback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                settingsCallback.pollTetherStatesChanged().availableList);
+        mLooper.dispatchAll();
+        callback.expectUpstreamChanged(NULL_NETWORK);
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        callback.assertNoCallback();
+    }
+
+    @Test
     public void testReportFailCallbackIfOffloadNotSupported() throws Exception {
         initTetheringOnTestThread();
         final UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
diff --git a/bpf/headers/include/bpf/BpfClassic.h b/bpf/headers/include/bpf/BpfClassic.h
index 81be37d..e6cef89 100644
--- a/bpf/headers/include/bpf/BpfClassic.h
+++ b/bpf/headers/include/bpf/BpfClassic.h
@@ -63,6 +63,10 @@
 #define BPF_LOAD_SKB_PROTOCOL \
 	BPF_STMT(BPF_LD | BPF_H | BPF_ABS, (__u32)SKF_AD_OFF + SKF_AD_PROTOCOL)
 
+// loads skb->pkt_type (0..7: see uapi/linux/if_packet.h PACKET_* constants)
+#define BPF_LOAD_SKB_PKTTYPE \
+	BPF_STMT(BPF_LD | BPF_B | BPF_ABS, (__u32)SKF_AD_OFF + SKF_AD_PKTTYPE)
+
 // 8-bit load relative to start of link layer (mac/ethernet) header.
 #define BPF_LOAD_MAC_RELATIVE_U8(ofs) \
 	BPF_STMT(BPF_LD | BPF_B | BPF_ABS, (__u32)SKF_LL_OFF + (ofs))
@@ -160,6 +164,9 @@
 #define BPF_LOAD_NETX_RELATIVE_ICMP_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
 #define BPF_LOAD_NETX_RELATIVE_ICMP_CODE BPF_LOAD_NETX_RELATIVE_L4_U8(1)
 
+// IGMP start with u8 type
+#define BPF_LOAD_NETX_RELATIVE_IGMP_TYPE BPF_LOAD_NETX_RELATIVE_L4_U8(0)
+
 // IPv6 extension headers (HOPOPTS, DSTOPS, FRAG) begin with a u8 nexthdr
 #define BPF_LOAD_NETX_RELATIVE_V6EXTHDR_NEXTHDR BPF_LOAD_NETX_RELATIVE_L4_U8(0)
 
diff --git a/bpf/headers/include/bpf/BpfRingbuf.h b/bpf/headers/include/bpf/BpfRingbuf.h
index 4bcd259..5fe4ef7 100644
--- a/bpf/headers/include/bpf/BpfRingbuf.h
+++ b/bpf/headers/include/bpf/BpfRingbuf.h
@@ -99,6 +99,7 @@
   // 32-bit kernel will just ignore the high-order bits.
   std::atomic_uint64_t* mConsumerPos = nullptr;
   std::atomic_uint32_t* mProducerPos = nullptr;
+  std::atomic_uint32_t* mLength = nullptr;
 
   // In order to guarantee atomic access in a 32 bit userspace environment, atomic_uint64_t is used
   // in addition to std::atomic<T>::is_always_lock_free that guarantees that read / write operations
@@ -247,7 +248,8 @@
     //   u32 len;
     //   u32 pg_off;
     // };
-    uint32_t length = *reinterpret_cast<volatile uint32_t*>(start_ptr);
+    mLength = reinterpret_cast<decltype(mLength)>(start_ptr);
+    uint32_t length = mLength->load(std::memory_order_acquire);
 
     // If the sample isn't committed, we're caught up with the producer.
     if (length & BPF_RINGBUF_BUSY_BIT) return count;
diff --git a/bpf/headers/include/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
index ac5ffda..b994a9f 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -403,6 +403,8 @@
 static unsigned long long (*bpf_get_smp_processor_id)(void) = (void*) BPF_FUNC_get_smp_processor_id;
 static long (*bpf_get_stackid)(void* ctx, void* map, uint64_t flags) = (void*) BPF_FUNC_get_stackid;
 static long (*bpf_get_current_comm)(void* buf, uint32_t buf_size) = (void*) BPF_FUNC_get_current_comm;
+// bpf_sk_fullsock requires 5.1+ kernel
+static struct bpf_sock* (*bpf_sk_fullsock)(struct bpf_sock* sk) = (void*) BPF_FUNC_sk_fullsock;
 
 // GPL only:
 static int (*bpf_trace_printk)(const char* fmt, int fmt_size, ...) = (void*) BPF_FUNC_trace_printk;
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 9a049c7..c2a1d6e 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -1288,6 +1288,8 @@
 
 #define APEX_MOUNT_POINT "/apex/com.android.tethering"
 const char * const platformBpfLoader = "/system/bin/bpfloader";
+const char *const uprobestatsBpfLoader =
+    "/apex/com.android.uprobestats/bin/uprobestatsbpfload";
 
 static int logTetheringApexVersion(void) {
     char * found_blockdev = NULL;
@@ -1657,8 +1659,17 @@
     }
 
     // unreachable before U QPR3
-    ALOGI("done, transferring control to platform bpfloader.");
+    {
+      ALOGI("done, transferring control to uprobestatsbpfload.");
+      const char *args[] = {
+          uprobestatsBpfLoader,
+          NULL,
+      };
+      execve(args[0], (char **)args, envp);
+    }
 
+    ALOGI("unable to execute uprobestatsbpfload, transferring control to "
+          "platform bpfloader.");
     // platform BpfLoader *needs* to run as root
     const char * args[] = { platformBpfLoader, NULL, };
     execve(args[0], (char**)args, envp);
diff --git a/bpf/netd/Android.bp b/bpf/netd/Android.bp
index fe4d999..473c8c9 100644
--- a/bpf/netd/Android.bp
+++ b/bpf/netd/Android.bp
@@ -82,7 +82,6 @@
         "libcutils",
         "liblog",
         "libnetdutils",
-        "libprocessgroup",
     ],
     compile_multilib: "both",
     multilib: {
diff --git a/bpf/netd/BpfBaseTest.cpp b/bpf/netd/BpfBaseTest.cpp
index 34dfbb4..4b8a04e 100644
--- a/bpf/netd/BpfBaseTest.cpp
+++ b/bpf/netd/BpfBaseTest.cpp
@@ -29,7 +29,6 @@
 #include <gtest/gtest.h>
 
 #include <cutils/qtaguid.h>
-#include <processgroup/processgroup.h>
 
 #include <android-base/stringprintf.h>
 #include <android-base/strings.h>
@@ -54,13 +53,6 @@
     BpfBasicTest() {}
 };
 
-TEST_F(BpfBasicTest, TestCgroupMounted) {
-    std::string cg2_path;
-    ASSERT_EQ(true, CgroupGetControllerPath(CGROUPV2_HIERARCHY_NAME, &cg2_path));
-    ASSERT_EQ(0, access(cg2_path.c_str(), R_OK));
-    ASSERT_EQ(0, access((cg2_path + "/cgroup.controllers").c_str(), R_OK));
-}
-
 TEST_F(BpfBasicTest, TestTagSocket) {
     BpfMap<uint64_t, UidTagValue> cookieTagMap(COOKIE_TAG_MAP_PATH);
     ASSERT_TRUE(cookieTagMap.isValid());
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 8e4c2c6..340acda 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -97,6 +97,7 @@
         ALOGE("Failed to open the cgroup directory: %s", strerror(err));
         return statusFromErrno(err, "Open the cgroup directory failed");
     }
+
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_ALLOWLIST_PROG_PATH));
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_DENYLIST_PROG_PATH));
     RETURN_IF_NOT_OK(checkProgramAccessible(XT_BPF_EGRESS_PROG_PATH));
@@ -120,18 +121,22 @@
     }
 
     if (modules::sdklevel::IsAtLeastV()) {
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_INET4_CONNECT));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT6_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_INET6_CONNECT));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_RECVMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP4_RECVMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_RECVMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP6_RECVMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_SENDMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP4_SENDMSG));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_SENDMSG_PROG_PATH,
-                                    cg_fd, BPF_CGROUP_UDP6_SENDMSG));
+        // V requires 4.19+, so technically this 2nd 'if' is not required, but it
+        // doesn't hurt us to try to support AOSP forks that try to support older kernels.
+        if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_INET4_CONNECT));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT6_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_INET6_CONNECT));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_RECVMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP4_RECVMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_RECVMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP6_RECVMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP4_SENDMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP4_SENDMSG));
+            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_UDP6_SENDMSG_PROG_PATH,
+                                        cg_fd, BPF_CGROUP_UDP6_SENDMSG));
+        }
 
         if (bpf::isAtLeastKernelVersion(5, 4, 0)) {
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_GETSOCKOPT_PROG_PATH,
@@ -161,12 +166,16 @@
     }
 
     if (modules::sdklevel::IsAtLeastV()) {
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_RECVMSG) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_RECVMSG) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_SENDMSG) <= 0) abort();
-        if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_SENDMSG) <= 0) abort();
+        // V requires 4.19+, so technically this 2nd 'if' is not required, but it
+        // doesn't hurt us to try to support AOSP forks that try to support older kernels.
+        if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_RECVMSG) <= 0) abort();
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_RECVMSG) <= 0) abort();
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP4_SENDMSG) <= 0) abort();
+            if (bpf::queryProgram(cg_fd, BPF_CGROUP_UDP6_SENDMSG) <= 0) abort();
+        }
 
         if (bpf::isAtLeastKernelVersion(5, 4, 0)) {
             if (bpf::queryProgram(cg_fd, BPF_CGROUP_GETSOCKOPT) <= 0) abort();
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index cbe856d..ed0eed5 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -709,32 +709,32 @@
     return block_port(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("connect6/inet6_connect", AID_ROOT, AID_ROOT, inet6_connect, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("connect6/inet6_connect", AID_ROOT, AID_ROOT, inet6_connect, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("recvmsg4/udp4_recvmsg", AID_ROOT, AID_ROOT, udp4_recvmsg, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg4/udp4_recvmsg", AID_ROOT, AID_ROOT, udp4_recvmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("recvmsg6/udp6_recvmsg", AID_ROOT, AID_ROOT, udp6_recvmsg, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("recvmsg6/udp6_recvmsg", AID_ROOT, AID_ROOT, udp6_recvmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("sendmsg4/udp4_sendmsg", AID_ROOT, AID_ROOT, udp4_sendmsg, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg4/udp4_sendmsg", AID_ROOT, AID_ROOT, udp4_sendmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("sendmsg6/udp6_sendmsg", AID_ROOT, AID_ROOT, udp6_sendmsg, KVER_4_14)
+DEFINE_NETD_V_BPF_PROG_KVER("sendmsg6/udp6_sendmsg", AID_ROOT, AID_ROOT, udp6_sendmsg, KVER_4_19)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
 }
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 4c6d8ba..17ef94b 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -41,7 +41,7 @@
 }
 
 flag {
-  name: "tethering_request_with_soft_ap_config"
+  name: "tethering_with_soft_ap_config"
   is_exported: true
   namespace: "android_core_networking"
   description: "The flag controls the access for the parcelable TetheringRequest with getSoftApConfiguration/setSoftApConfiguration API"
diff --git a/common/networksecurity_flags.aconfig b/common/networksecurity_flags.aconfig
index 6438ba4..4a83af4 100644
--- a/common/networksecurity_flags.aconfig
+++ b/common/networksecurity_flags.aconfig
@@ -8,3 +8,12 @@
     bug: "319829948"
     is_fixed_read_only: true
 }
+
+flag {
+    name: "certificate_transparency_job"
+    is_exported: true
+    namespace: "network_security"
+    description: "Enable daily job service for certificate transparency instead of flags listener"
+    bug: "319829948"
+    is_fixed_read_only: true
+}
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 60120bc..8cc2bb4 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -44,3 +44,12 @@
     description: "Controls whether the setConfiguration API of NAT64 feature is enabled"
     bug: "368456504"
 }
+
+flag {
+    name: "thread_mobile_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether Thread support for mobile devices is enabled"
+    bug: "368867060"
+}
diff --git a/framework-t/src/android/net/INetworkStatsService.aidl b/framework-t/src/android/net/INetworkStatsService.aidl
index 01ac106..b459a13 100644
--- a/framework-t/src/android/net/INetworkStatsService.aidl
+++ b/framework-t/src/android/net/INetworkStatsService.aidl
@@ -21,10 +21,11 @@
 import android.net.Network;
 import android.net.NetworkStateSnapshot;
 import android.net.NetworkStats;
-import android.net.NetworkStatsHistory;
 import android.net.NetworkTemplate;
 import android.net.UnderlyingNetworkInfo;
 import android.net.netstats.IUsageCallback;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProvider;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.os.IBinder;
@@ -78,16 +79,13 @@
     void unregisterUsageRequest(in DataUsageRequest request);
 
     /** Get the uid stats information since boot */
-    NetworkStats getTypelessUidStats(int uid);
+    StatsResult getUidStats(int uid);
 
     /** Get the iface stats information since boot */
-    NetworkStats getTypelessIfaceStats(String iface);
+    StatsResult getIfaceStats(String iface);
 
     /** Get the total network stats information since boot */
-    NetworkStats getTypelessTotalStats();
-
-    /** Get the uid stats information (with specified type) since boot */
-    long getUidStats(int uid, int type);
+    StatsResult getTotalStats();
 
     /** Registers a network stats provider */
     INetworkStatsProviderCallback registerNetworkStatsProvider(String tag,
@@ -107,4 +105,7 @@
 
      /** Clear TrafficStats rate-limit caches. */
      void clearTrafficStatsRateLimitCaches();
+
+     /** Get rate-limit cache config. */
+     TrafficStatsRateLimitCacheConfig getRateLimitCacheConfig();
 }
diff --git a/framework-t/src/android/net/TrafficStats.java b/framework-t/src/android/net/TrafficStats.java
index 3b6a69b..caf3152 100644
--- a/framework-t/src/android/net/TrafficStats.java
+++ b/framework-t/src/android/net/TrafficStats.java
@@ -18,7 +18,10 @@
 
 import static android.annotation.SystemApi.Client.MODULE_LIBRARIES;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
@@ -29,19 +32,21 @@
 import android.compat.annotation.UnsupportedAppUsage;
 import android.content.Context;
 import android.media.MediaPlayer;
+import android.net.netstats.StatsResult;
 import android.os.Binder;
 import android.os.Build;
 import android.os.RemoteException;
 import android.os.StrictMode;
 import android.util.Log;
 
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+
 import java.io.FileDescriptor;
 import java.io.IOException;
 import java.net.DatagramSocket;
 import java.net.Socket;
 import java.net.SocketException;
-import java.util.Iterator;
-import java.util.Objects;
 
 
 /**
@@ -177,10 +182,14 @@
     /** @hide */
     public static final int TAG_SYSTEM_PROBE = 0xFFFFFF42;
 
+    @GuardedBy("TrafficStats.class")
     private static INetworkStatsService sStatsService;
+    @GuardedBy("TrafficStats.class")
+    private static INetworkStatsService sStatsServiceForTest = null;
 
     @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 130143562)
     private synchronized static INetworkStatsService getStatsService() {
+        if (sStatsServiceForTest != null) return sStatsServiceForTest;
         if (sStatsService == null) {
             throw new IllegalStateException("TrafficStats not initialized, uid="
                     + Binder.getCallingUid());
@@ -188,6 +197,23 @@
         return sStatsService;
     }
 
+    /** @hide */
+    private static int getMyUid() {
+        return android.os.Process.myUid();
+    }
+
+    /**
+     * Set the network stats service for testing, or null to reset.
+     *
+     * @hide
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public static void setServiceForTest(INetworkStatsService statsService) {
+        synchronized (TrafficStats.class) {
+            sStatsServiceForTest = statsService;
+        }
+    }
+
     /**
      * Snapshot of {@link NetworkStats} when the currently active profiling
      * session started, or {@code null} if no session active.
@@ -242,8 +268,8 @@
 
     private static class SocketTagger extends dalvik.system.SocketTagger {
 
-        // TODO: set to false
-        private static final boolean LOGD = true;
+        // Enable log with `setprop log.tag.TrafficStats DEBUG` and restart the module.
+        private static final boolean LOGD = Log.isLoggable(TAG, Log.DEBUG);
 
         SocketTagger() {
         }
@@ -450,7 +476,7 @@
      */
     @Deprecated
     public static void setThreadStatsUidSelf() {
-        setThreadStatsUid(android.os.Process.myUid());
+        setThreadStatsUid(getMyUid());
     }
 
     /**
@@ -591,7 +617,7 @@
      * @param operationCount Number of operations to increment count by.
      */
     public static void incrementOperationCount(int tag, int operationCount) {
-        final int uid = android.os.Process.myUid();
+        final int uid = getMyUid();
         try {
             getStatsService().incrementOperationCount(uid, tag, operationCount);
         } catch (RemoteException e) {
@@ -959,45 +985,35 @@
 
     /** @hide */
     public static long getUidStats(int uid, int type) {
-        if (!isEntryValueTypeValid(type)
-                || android.os.Process.myUid() != uid) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessUidStats(uid);
+            stats = getStatsService().getUidStats(uid);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /** @hide */
     public static long getTotalStats(int type) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessTotalStats();
+            stats = getStatsService().getTotalStats();
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /** @hide */
     public static long getIfaceStats(String iface, int type) {
-        if (!isEntryValueTypeValid(type)) {
-            return UNSUPPORTED;
-        }
-        final NetworkStats stats;
+        final StatsResult stats;
         try {
-            stats = getStatsService().getTypelessIfaceStats(iface);
+            stats = getStatsService().getIfaceStats(iface);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
-        return getValueForTypeFromFirstEntry(stats, type);
+        return getEntryValueForType(stats, type);
     }
 
     /**
@@ -1094,7 +1110,7 @@
      */
     private static NetworkStats getDataLayerSnapshotForUid(Context context) {
         // TODO: take snapshot locally, since proc file is now visible
-        final int uid = android.os.Process.myUid();
+        final int uid = getMyUid();
         try {
             return getStatsService().getDataLayerSnapshotForUid(uid);
         } catch (RemoteException e) {
@@ -1127,18 +1143,18 @@
     public static final int TYPE_TX_PACKETS = 3;
 
     /** @hide */
-    private static long getEntryValueForType(@NonNull NetworkStats.Entry entry, int type) {
-        Objects.requireNonNull(entry);
+    private static long getEntryValueForType(@Nullable StatsResult stats, int type) {
+        if (stats == null) return UNSUPPORTED;
         if (!isEntryValueTypeValid(type)) return UNSUPPORTED;
         switch (type) {
             case TYPE_RX_BYTES:
-                return entry.getRxBytes();
+                return stats.rxBytes;
             case TYPE_RX_PACKETS:
-                return entry.getRxPackets();
+                return stats.rxPackets;
             case TYPE_TX_BYTES:
-                return entry.getTxBytes();
+                return stats.txBytes;
             case TYPE_TX_PACKETS:
-                return entry.getTxPackets();
+                return stats.txPackets;
             default:
                 throw new IllegalStateException("Bug: Invalid type: "
                         + type + " should not reach here.");
@@ -1157,13 +1173,5 @@
                 return false;
         }
     }
-
-    /** @hide */
-    public static long getValueForTypeFromFirstEntry(@NonNull NetworkStats stats, int type) {
-        Objects.requireNonNull(stats);
-        Iterator<NetworkStats.Entry> iter = stats.iterator();
-        if (!iter.hasNext()) return UNSUPPORTED;
-        return getEntryValueForType(iter.next(), type);
-    }
 }
 
diff --git a/framework-t/src/android/net/netstats/StatsResult.aidl b/framework-t/src/android/net/netstats/StatsResult.aidl
new file mode 100644
index 0000000..3f09566
--- /dev/null
+++ b/framework-t/src/android/net/netstats/StatsResult.aidl
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2024, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.netstats;
+
+/**
+ * A lightweight class to pass result of TrafficStats#get{Total|Iface|Uid}Stats.
+ *
+ * @hide
+ */
+@JavaDerive(equals=true, toString=true)
+@JavaOnlyImmutable
+parcelable StatsResult {
+    long rxBytes;
+    long rxPackets;
+    long txBytes;
+    long txPackets;
+}
\ No newline at end of file
diff --git a/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl b/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl
new file mode 100644
index 0000000..cdf0b7c
--- /dev/null
+++ b/framework-t/src/android/net/netstats/TrafficStatsRateLimitCacheConfig.aidl
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2024, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.netstats;
+
+/**
+ * Configuration for the TrafficStats rate limit cache.
+ *
+ * @hide
+ */
+@JavaDerive(equals=true, toString=true)
+@JavaOnlyImmutable
+parcelable TrafficStatsRateLimitCacheConfig {
+
+    /**
+     * Whether the cache is enabled for V+ device or target Sdk V+ apps.
+     */
+    boolean isCacheEnabled;
+
+    /**
+     * The duration for which cache entries are valid, in milliseconds.
+     */
+    int expiryDurationMs;
+
+    /**
+     * The maximum number of entries to store in the cache.
+     */
+    int maxEntries;
+}
diff --git a/framework/Android.bp b/framework/Android.bp
index 0334e11..c641de2 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -64,6 +64,7 @@
         ":net-utils-framework-common-srcs",
         ":framework-connectivity-api-shared-srcs",
         ":framework-networksecurity-sources",
+        ":statslog-framework-connectivity-java-gen",
     ],
     aidl: {
         generate_get_transaction_name: true,
@@ -105,6 +106,7 @@
         "app-compat-annotations",
         "framework-connectivity-t.stubs.module_lib",
         "unsupportedappusage",
+        "framework-statsd.stubs.module_lib",
     ],
     apex_available: [
         "com.android.tethering",
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index 574ab2f..cefa1ea 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -31,12 +31,14 @@
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
+import android.os.Process;
 import android.os.RemoteException;
 import android.telephony.data.EpsBearerQosSessionAttributes;
 import android.telephony.data.NrQosSessionAttributes;
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.FrameworkConnectivityStatsLog;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -943,6 +945,19 @@
 
     private void queueOrSendMessage(@NonNull RegistryAction action) {
         synchronized (mPreConnectedQueue) {
+            if (mNetwork == null && !Process.isApplicationUid(Process.myUid())) {
+                // Theoretically, it should not be valid to queue messages here before
+                // registering the NetworkAgent. However, practically, with the way
+                // queueing works right now, it ends up working out just fine.
+                // Log a statistic so that we know if this is happening in the
+                // wild. The check for isApplicationUid is to prevent logging the
+                // metric from test code.
+
+                FrameworkConnectivityStatsLog.write(
+                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED,
+                        FrameworkConnectivityStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_MESSAGE_QUEUED_BEFORE_CONNECT
+                );
+            }
             if (mRegistry != null) {
                 try {
                     action.execute(mRegistry);
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 150394b..e78f999 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -32,7 +32,6 @@
 import android.nearby.aidl.IOffloadCallback;
 import android.os.RemoteException;
 import android.os.SystemProperties;
-import android.provider.Settings;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
@@ -129,16 +128,6 @@
     private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST =
             "persist.bluetooth.finder.supported";
 
-    /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Whether allows Fast Pair to scan.
-     *
-     * (0 = disabled, 1 = enabled)
-     *
-     * @hide
-     */
-    public static final String FAST_PAIR_SCAN_ENABLED = "fast_pair_scan_enabled";
-
     @GuardedBy("sScanListeners")
     private static final WeakHashMap<ScanCallback, WeakReference<ScanListenerTransport>>
             sScanListeners = new WeakHashMap<>();
@@ -479,36 +468,6 @@
     }
 
     /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Read from {@link Settings} whether Fast Pair scan is enabled.
-     *
-     * @param context the {@link Context} to query the setting
-     * @return whether the Fast Pair is enabled
-     * @hide
-     */
-    public static boolean getFastPairScanEnabled(@NonNull Context context) {
-        final int enabled = Settings.Secure.getInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, 0);
-        return enabled != 0;
-    }
-
-    /**
-     * TODO(b/286137024): Remove this when CTS R5 is rolled out.
-     * Write into {@link Settings} whether Fast Pair scan is enabled
-     *
-     * @param context the {@link Context} to set the setting
-     * @param enable whether the Fast Pair scan should be enabled
-     * @hide
-     */
-    @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS)
-    public static void setFastPairScanEnabled(@NonNull Context context, boolean enable) {
-        Settings.Secure.putInt(
-                context.getContentResolver(), FAST_PAIR_SCAN_ENABLED, enable ? 1 : 0);
-        Log.v(TAG, String.format(
-                "successfully %s Fast Pair scan", enable ? "enables" : "disables"));
-    }
-
-    /**
      * Sets the precomputed EIDs for advertising when the phone is powered off. The Bluetooth
      * controller will store these EIDs in its memory, and will start advertising them in Find My
      * Device network EID frames when powered off, only if the powered off finding mode was
diff --git a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
index 1e36676..3291223 100644
--- a/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
+++ b/nearby/tests/cts/fastpair/src/android/nearby/cts/NearbyManagerTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.nearby.PresenceCredential.IDENTITY_TYPE_PRIVATE;
 import static android.nearby.ScanCallback.ERROR_UNSUPPORTED;
@@ -121,7 +122,7 @@
     @Before
     public void setUp() {
         mUiAutomation.adoptShellPermissionIdentity(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG,
-                BLUETOOTH_PRIVILEGED);
+                WRITE_ALLOWLISTED_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
         String nameSpace = SdkLevel.isAtLeastU() ? DeviceConfig.NAMESPACE_NEARBY
                 : DeviceConfig.NAMESPACE_TETHERING;
         DeviceConfig.setProperty(nameSpace,
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
index 644e178..e0dfd31 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyConfigurationTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
@@ -42,7 +43,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
     }
 
     @Test
diff --git a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
index 5b640cc..891e941 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/NearbyServiceTest.java
@@ -19,6 +19,7 @@
 import static android.Manifest.permission.BLUETOOTH_PRIVILEGED;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
@@ -71,7 +72,8 @@
         when(mScanListener.asBinder()).thenReturn(mIBinder);
 
         mUiAutomation.adoptShellPermissionIdentity(
-                READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, BLUETOOTH_PRIVILEGED);
+                READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                BLUETOOTH_PRIVILEGED);
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mService = new NearbyService(mContext);
         mScanRequest = createScanRequest();
diff --git a/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
index 7ff7b13..faa32c0 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/managers/BroadcastProviderManagerTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY;
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
@@ -88,7 +89,8 @@
     @Before
     public void setUp() {
         when(mBroadcastListener.asBinder()).thenReturn(mBinder);
-        mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+        mUiAutomation.adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG,
+                WRITE_ALLOWLISTED_DEVICE_CONFIG, READ_DEVICE_CONFIG);
         DeviceConfig.setProperty(
                 NAMESPACE, NEARBY_ENABLE_PRESENCE_BROADCAST_LEGACY, "true", false);
         DeviceConfig.setProperty(
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
index ce479c8..01028bf 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreCommunicationTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_MAINLINE_NANO_APP_MIN_VERSION;
 import static com.android.server.nearby.provider.ChreCommunication.INVALID_NANO_APP_VERSION;
@@ -76,7 +77,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
         DeviceConfig.setProperty(
                 NAMESPACE, NEARBY_MAINLINE_NANO_APP_MIN_VERSION, "1", false);
 
diff --git a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
index 590a46e..7f391f1 100644
--- a/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
+++ b/nearby/tests/unit/src/com/android/server/nearby/provider/ChreDiscoveryProviderTest.java
@@ -18,6 +18,7 @@
 
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 
 import static com.android.server.nearby.NearbyConfiguration.NEARBY_SUPPORT_TEST_APP;
 
@@ -84,7 +85,8 @@
     @Before
     public void setUp() {
         InstrumentationRegistry.getInstrumentation().getUiAutomation()
-                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, READ_DEVICE_CONFIG);
+                .adoptShellPermissionIdentity(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG,
+                        READ_DEVICE_CONFIG);
 
         MockitoAnnotations.initMocks(this);
         Context context = InstrumentationRegistry.getInstrumentation().getContext();
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index 52667ae..a41e6a0 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -32,6 +32,15 @@
         "service-connectivity-pre-jarjar",
     ],
 
+    static_libs: [
+        "auto_value_annotations",
+    ],
+
+    plugins: [
+        "auto_value_plugin",
+        "auto_annotation_plugin",
+    ],
+
     // This is included in service-connectivity which is 30+
     // TODO (b/293613362): allow APEXes to have service jars with higher min_sdk than the APEX
     // (service-connectivity is only used on 31+) and use 31 here
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
index 16f32c4..d53f007 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -29,6 +29,8 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
@@ -86,12 +88,17 @@
     }
 
     void setPublicKey(String publicKey) throws GeneralSecurityException {
-        mPublicKey =
-                Optional.of(
-                        KeyFactory.getInstance("RSA")
-                                .generatePublic(
-                                        new X509EncodedKeySpec(
-                                                Base64.getDecoder().decode(publicKey))));
+        try {
+            mPublicKey =
+                    Optional.of(
+                            KeyFactory.getInstance("RSA")
+                                    .generatePublic(
+                                            new X509EncodedKeySpec(
+                                                    Base64.getDecoder().decode(publicKey))));
+        } catch (IllegalArgumentException e) {
+            Log.e(TAG, "Invalid public key Base64 encoding", e);
+            mPublicKey = Optional.empty();
+        }
     }
 
     @VisibleForTesting
@@ -147,19 +154,18 @@
     }
 
     private void handleMetadataDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Metadata download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
-
         startContentDownload(mDataStore.getProperty(Config.CONTENT_URL_PENDING));
     }
 
     private void handleContentDownloadCompleted(long downloadId) {
-        if (!mDownloadHelper.isSuccessful(downloadId)) {
-            Log.w(TAG, "Content download failed.");
-            // TODO: re-attempt download
+        DownloadStatus status = mDownloadHelper.getDownloadStatus(downloadId);
+        if (!status.isSuccessful()) {
+            handleDownloadFailed(status);
             return;
         }
 
@@ -202,6 +208,11 @@
         }
     }
 
+    private void handleDownloadFailed(DownloadStatus status) {
+        Log.e(TAG, "Content download failed with " + status);
+        // TODO(378626065): Report failure via statsd.
+    }
+
     private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
         if (!mPublicKey.isPresent()) {
             throw new InvalidKeyException("Missing public key for signature verification");
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 0ae982d..93a7064 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -16,7 +16,6 @@
 package com.android.server.net.ct;
 
 import android.annotation.RequiresApi;
-import android.content.Context;
 import android.os.Build;
 import android.provider.DeviceConfig;
 import android.provider.DeviceConfig.Properties;
@@ -35,10 +34,11 @@
     private final DataStore mDataStore;
     private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
-    CertificateTransparencyFlagsListener(Context context) {
-        mDataStore = new DataStore(Config.PREFERENCES_FILE);
-        mCertificateTransparencyDownloader =
-                new CertificateTransparencyDownloader(context, mDataStore);
+    CertificateTransparencyFlagsListener(
+            DataStore dataStore,
+            CertificateTransparencyDownloader certificateTransparencyDownloader) {
+        mDataStore = dataStore;
+        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
     }
 
     void initialize() {
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
new file mode 100644
index 0000000..6fbf0ba
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import android.annotation.RequiresApi;
+import android.app.AlarmManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Build;
+import android.os.SystemClock;
+import android.provider.DeviceConfig;
+import android.util.Log;
+
+import java.util.HashMap;
+
+/** Implementation of the Certificate Transparency job */
+@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+public class CertificateTransparencyJob extends BroadcastReceiver {
+
+    private static final String TAG = "CertificateTransparencyJob";
+
+    private static final String ACTION_JOB_START = "com.android.server.net.ct.action.JOB_START";
+
+    private final Context mContext;
+    private final DataStore mDataStore;
+    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
+    // TODO(b/374692404): remove dependency to flags.
+    private final CertificateTransparencyFlagsListener mFlagsListener;
+    private final AlarmManager mAlarmManager;
+
+    /** Creates a new {@link CertificateTransparencyJob} object. */
+    public CertificateTransparencyJob(
+            Context context,
+            DataStore dataStore,
+            CertificateTransparencyDownloader certificateTransparencyDownloader,
+            CertificateTransparencyFlagsListener flagsListener) {
+        mContext = context;
+        mFlagsListener = flagsListener;
+        mDataStore = dataStore;
+        mCertificateTransparencyDownloader = certificateTransparencyDownloader;
+        mAlarmManager = context.getSystemService(AlarmManager.class);
+    }
+
+    void initialize() {
+        mDataStore.load();
+        mCertificateTransparencyDownloader.initialize();
+
+        mContext.registerReceiver(
+                this, new IntentFilter(ACTION_JOB_START), Context.RECEIVER_EXPORTED);
+        mAlarmManager.setInexactRepeating(
+                AlarmManager.ELAPSED_REALTIME,
+                SystemClock.elapsedRealtime(), // schedule first job at earliest convenient time.
+                AlarmManager.INTERVAL_DAY,
+                PendingIntent.getBroadcast(
+                        mContext, 0, new Intent(ACTION_JOB_START), PendingIntent.FLAG_IMMUTABLE));
+
+        if (Config.DEBUG) {
+            Log.d(TAG, "CertificateTransparencyJob scheduled successfully.");
+        }
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (!ACTION_JOB_START.equals(intent.getAction())) {
+            Log.w(TAG, "Received unexpected broadcast with action " + intent);
+            return;
+        }
+        mFlagsListener.onPropertiesChanged(
+                new DeviceConfig.Properties(Config.NAMESPACE_NETWORK_SECURITY, new HashMap<>()));
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index edf7c56..ac55e44 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -28,7 +28,10 @@
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
 
+    private final DataStore mDataStore;
+    private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
     private final CertificateTransparencyFlagsListener mFlagsListener;
+    private final CertificateTransparencyJob mCertificateTransparencyJob;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
@@ -41,7 +44,15 @@
 
     /** Creates a new {@link CertificateTransparencyService} object. */
     public CertificateTransparencyService(Context context) {
-        mFlagsListener = new CertificateTransparencyFlagsListener(context);
+        mDataStore = new DataStore(Config.PREFERENCES_FILE);
+        mCertificateTransparencyDownloader =
+                new CertificateTransparencyDownloader(context, mDataStore);
+        mFlagsListener =
+                new CertificateTransparencyFlagsListener(
+                        mDataStore, mCertificateTransparencyDownloader);
+        mCertificateTransparencyJob =
+                new CertificateTransparencyJob(
+                        context, mDataStore, mCertificateTransparencyDownloader, mFlagsListener);
     }
 
     /**
@@ -53,7 +64,11 @@
 
         switch (phase) {
             case SystemService.PHASE_BOOT_COMPLETED:
-                mFlagsListener.initialize();
+                if (Flags.certificateTransparencyJob()) {
+                    mCertificateTransparencyJob.initialize();
+                } else {
+                    mFlagsListener.initialize();
+                }
                 break;
             default:
         }
diff --git a/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
index e3b4124..ba42a82 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DirectoryUtils.java
@@ -13,6 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package com.android.server.net.ct;
 
 import android.annotation.SuppressLint;
@@ -29,16 +30,33 @@
             throw new IOException("Unable to make directory " + dir.getCanonicalPath());
         }
         setWorldReadable(dir);
+        // Needed for the log list file to be accessible.
+        setWorldExecutable(dir);
     }
 
     // CT files and directories are readable by all apps.
     @SuppressLint("SetWorldReadable")
     static void setWorldReadable(File file) throws IOException {
-        if (!file.setReadable(true, false)) {
+        if (!file.setReadable(/* readable= */ true, /* ownerOnly= */ false)) {
             throw new IOException("Failed to set " + file.getCanonicalPath() + " readable");
         }
     }
 
+    // CT directories are executable by all apps, to allow access to the log list by anything on the
+    // device.
+    static void setWorldExecutable(File file) throws IOException {
+        if (!file.isDirectory()) {
+            // Only directories need to be marked as executable to allow for access
+            // to the files inside.
+            // See https://www.redhat.com/en/blog/linux-file-permissions-explained for more details.
+            return;
+        }
+
+        if (!file.setExecutable(/* executable= */ true, /* ownerOnly= */ false)) {
+            throw new IOException("Failed to set " + file.getCanonicalPath() + " executable");
+        }
+    }
+
     static boolean removeDir(File dir) {
         return deleteContentsAndDir(dir);
     }
diff --git a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
index cc8c4c0..5748416 100644
--- a/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
+++ b/networksecurity/service/src/com/android/server/net/ct/DownloadHelper.java
@@ -24,6 +24,8 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.google.auto.value.AutoValue;
+
 /** Class to handle downloads for Certificate Transparency. */
 public class DownloadHelper {
 
@@ -53,25 +55,22 @@
     }
 
     /**
-     * Returns true if the specified download completed successfully.
+     * Returns the status of the provided download id.
      *
      * @param downloadId the download.
-     * @return true if the download completed successfully.
+     * @return {@link DownloadStatus} of the download.
      */
-    public boolean isSuccessful(long downloadId) {
+    public DownloadStatus getDownloadStatus(long downloadId) {
+        DownloadStatus.Builder builder = DownloadStatus.builder().setDownloadId(downloadId);
         try (Cursor cursor = mDownloadManager.query(new Query().setFilterById(downloadId))) {
-            if (cursor == null) {
-                return false;
-            }
-            if (cursor.moveToFirst()) {
-                int status =
-                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
-                if (DownloadManager.STATUS_SUCCESSFUL == status) {
-                    return true;
-                }
+            if (cursor != null && cursor.moveToFirst()) {
+                builder.setStatus(
+                        cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)));
+                builder.setReason(
+                        cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON)));
             }
         }
-        return false;
+        return builder.build();
     }
 
     /**
@@ -87,4 +86,58 @@
         }
         return mDownloadManager.getUriForDownloadedFile(downloadId);
     }
+
+    /** A wrapper around the status and reason Ids returned by the {@link DownloadManager}. */
+    @AutoValue
+    public abstract static class DownloadStatus {
+
+        abstract long downloadId();
+
+        abstract int status();
+
+        abstract int reason();
+
+        boolean isSuccessful() {
+            return status() == DownloadManager.STATUS_SUCCESSFUL;
+        }
+
+        boolean isStorageError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_DEVICE_NOT_FOUND
+                            || reason == DownloadManager.ERROR_FILE_ERROR
+                            || reason == DownloadManager.ERROR_FILE_ALREADY_EXISTS
+                            || reason == DownloadManager.ERROR_INSUFFICIENT_SPACE);
+        }
+
+        boolean isHttpError() {
+            int status = status();
+            int reason = reason();
+            return status == DownloadManager.STATUS_FAILED
+                    && (reason == DownloadManager.ERROR_HTTP_DATA_ERROR
+                            || reason == DownloadManager.ERROR_TOO_MANY_REDIRECTS
+                            || reason == DownloadManager.ERROR_UNHANDLED_HTTP_CODE
+                            // If an HTTP error occurred, reason will hold the HTTP status code.
+                            || (400 <= reason && reason < 600));
+        }
+
+        @AutoValue.Builder
+        abstract static class Builder {
+            abstract Builder setDownloadId(long downloadId);
+
+            abstract Builder setStatus(int status);
+
+            abstract Builder setReason(int reason);
+
+            abstract DownloadStatus build();
+        }
+
+        static Builder builder() {
+            return new AutoValue_DownloadHelper_DownloadStatus.Builder()
+                    .setDownloadId(-1)
+                    .setStatus(-1)
+                    .setReason(-1);
+        }
+    }
 }
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index df02446..fb55295 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -31,6 +31,8 @@
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
+import com.android.server.net.ct.DownloadHelper.DownloadStatus;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -115,11 +117,11 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteSuccessful() {
+    public void testDownloader_metadataDownloadSuccess_startContentDownload() {
         long metadataId = 123;
         mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(true);
-
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(makeSuccessfulDownloadStatus(metadataId));
         long contentId = 666;
         String contentUrl = "http://test-content.org";
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
@@ -132,23 +134,28 @@
     }
 
     @Test
-    public void testDownloader_handleMetadataCompleteFailed() {
+    public void testDownloader_metadataDownloadFail_doNotStartContentDownload() {
         long metadataId = 123;
         mDataStore.setPropertyLong(Config.METADATA_URL_KEY, metadataId);
-        when(mDownloadHelper.isSuccessful(metadataId)).thenReturn(false);
-
         String contentUrl = "http://test-content.org";
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUrl);
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(metadataId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(metadataId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(metadataId),
+                        makeStorageErrorDownloadStatus(metadataId));
 
-        mCertificateTransparencyDownloader.onReceive(
-                mContext, makeDownloadCompleteIntent(metadataId));
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
 
         verify(mDownloadHelper, never()).startDownload(contentUrl);
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallSuccessful() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_installSuccess_updateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
@@ -157,8 +164,8 @@
         Uri metadataUri = Uri.fromFile(metadataFile);
         mCertificateTransparencyDownloader.setPublicKey(
                 Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(
                         eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
                 .thenReturn(true);
@@ -166,7 +173,6 @@
         assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
         assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
-
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
 
@@ -178,16 +184,38 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteInstallFails() throws Exception {
-        String version = "666";
+    public void testDownloader_contentDownloadFail_doNotInstall() throws Exception {
+        mDataStore.setProperty(Config.VERSION_PENDING, "123");
+        long contentId = 666;
+        Intent downloadCompleteIntent = makeDownloadCompleteIntent(contentId);
+        // In all these failure cases we give up on the download.
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(
+                        makeHttpErrorDownloadStatus(contentId),
+                        makeStorageErrorDownloadStatus(contentId));
+
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+        mCertificateTransparencyDownloader.onReceive(mContext, downloadCompleteIntent);
+
+        verify(mCertificateTransparencyInstaller, never()).install(any(), any(), any());
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
+    @Test
+    public void testDownloader_contentDownloadSuccess_installFail_doNotUpdateDataStore()
+            throws Exception {
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(
                         eq(Config.COMPATIBILITY_VERSION), any(), eq(version)))
                 .thenReturn(false);
@@ -201,14 +229,15 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteVerificationFails() throws IOException {
-        String version = "666";
+    public void testDownloader_contentDownloadSuccess_verificationFail_doNotInstall()
+            throws IOException {
+        String version = "456";
         long contentId = 666;
         Uri contentUri = Uri.fromFile(File.createTempFile("log_list", "json"));
         long metadataId = 123;
         Uri metadataUri = Uri.fromFile(File.createTempFile("log_list-wrong_metadata", "sig"));
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -221,17 +250,17 @@
     }
 
     @Test
-    public void testDownloader_handleContentCompleteMissingVerificationPublicKey()
+    public void testDownloader_contentDownloadSuccess_missingVerificationPublicKey_doNotInstall()
             throws Exception {
-        String version = "666";
+        String version = "456";
         long contentId = 666;
         File logListFile = File.createTempFile("log_list", "json");
         Uri contentUri = Uri.fromFile(logListFile);
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
-
-        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+        setUpContentDownloadCompleteSuccessful(
+                version, metadataId, metadataUri, contentId, contentUri);
 
         mCertificateTransparencyDownloader.onReceive(
                 mContext, makeDownloadCompleteIntent(contentId));
@@ -248,7 +277,7 @@
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
     }
 
-    private void setUpDownloadComplete(
+    private void setUpContentDownloadCompleteSuccessful(
             String version, long metadataId, Uri metadataUri, long contentId, Uri contentUri)
             throws IOException {
         mDataStore.setProperty(Config.VERSION_PENDING, version);
@@ -259,10 +288,34 @@
 
         mDataStore.setPropertyLong(Config.CONTENT_URL_KEY, contentId);
         mDataStore.setProperty(Config.CONTENT_URL_PENDING, contentUri.toString());
-        when(mDownloadHelper.isSuccessful(contentId)).thenReturn(true);
+        when(mDownloadHelper.getDownloadStatus(contentId))
+                .thenReturn(makeSuccessfulDownloadStatus(contentId));
         when(mDownloadHelper.getUri(contentId)).thenReturn(contentUri);
     }
 
+    private DownloadStatus makeSuccessfulDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_SUCCESSFUL)
+                .build();
+    }
+
+    private DownloadStatus makeStorageErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_INSUFFICIENT_SPACE)
+                .build();
+    }
+
+    private DownloadStatus makeHttpErrorDownloadStatus(long downloadId) {
+        return DownloadStatus.builder()
+                .setDownloadId(downloadId)
+                .setStatus(DownloadManager.STATUS_FAILED)
+                .setReason(DownloadManager.ERROR_HTTP_DATA_ERROR)
+                .build();
+    }
+
     private File sign(File file) throws IOException, GeneralSecurityException {
         File signatureFile = File.createTempFile("log_list-metadata", "sig");
         Signature signer = Signature.getInstance("SHA256withRSA");
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
index 4399f2d..3eae3c7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import java.io.IOException;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -29,7 +27,6 @@
 import java.util.Objects;
 
 /** An mDNS "AAAA" or "A" record, which holds an IPv6 or IPv4 address. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsInetAddressRecord extends MdnsRecord {
     @Nullable private Inet6Address inet6Address;
     @Nullable private Inet4Address inet4Address;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
index 39bf653..e8f5e71 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
@@ -18,15 +18,12 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.net.module.util.DnsUtils;
 
 import java.io.IOException;
 import java.util.Arrays;
 
 /** An mDNS "PTR" record, which holds a name (the "pointer"). */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsPointerRecord extends MdnsRecord {
     private String[] pointer;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
index e52dd2f..356b738 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
@@ -107,7 +107,7 @@
         final QueryTaskConfig nextRunConfig = currentConfig.getConfigForNextRun(queryMode);
         long timeToRun;
         if (mLastScheduledQueryTaskArgs == null && !forceEnableBackoff) {
-            timeToRun = now + nextRunConfig.delayBeforeTaskWithoutBackoffMs;
+            timeToRun = now + nextRunConfig.getDelayBeforeTaskWithoutBackoff();
         } else {
             timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs,
                     nextRunConfig, now, minRemainingTtl, lastSentTime, numOfQueriesBeforeBackoff,
@@ -133,7 +133,7 @@
     private static long calculateTimeToRun(@Nullable ScheduledQueryTaskArgs taskArgs,
             QueryTaskConfig queryTaskConfig, long now, long minRemainingTtl, long lastSentTime,
             int numOfQueriesBeforeBackoff, boolean forceEnableBackoff) {
-        final long baseDelayInMs = queryTaskConfig.delayBeforeTaskWithoutBackoffMs;
+        final long baseDelayInMs = queryTaskConfig.getDelayBeforeTaskWithoutBackoff();
         if (!(forceEnableBackoff
                 || queryTaskConfig.shouldUseQueryBackoff(numOfQueriesBeforeBackoff))) {
             return lastSentTime + baseDelayInMs;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
index fd716d2..907e2ff 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.net.module.util.DnsUtils;
 
 import java.io.IOException;
@@ -28,7 +26,6 @@
 import java.util.Objects;
 
 /** An mDNS "SRV" record, which contains service information. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsServiceRecord extends MdnsRecord {
     public static final int PROTO_NONE = 0;
     public static final int PROTO_TCP = 1;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
index 77d1d7a..2b3ebf9 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
@@ -18,8 +18,6 @@
 
 import android.annotation.Nullable;
 
-import androidx.annotation.VisibleForTesting;
-
 import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 
 import java.io.IOException;
@@ -29,7 +27,6 @@
 import java.util.Objects;
 
 /** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
-@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsTextRecord extends MdnsRecord {
     private List<TextEntry> entries;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
index 4e74159..dd4073f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
+++ b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
@@ -52,118 +52,124 @@
     final int transactionId;
     @VisibleForTesting
     final boolean expectUnicastResponse;
-    private final int queriesPerBurst;
-    private final int timeBetweenBurstsInMs;
-    private final int burstCounter;
-    final long delayBeforeTaskWithoutBackoffMs;
-    private final boolean isFirstBurst;
-    private final long queryIndex;
+    private final int queryIndex;
+    private final int queryMode;
 
-    QueryTaskConfig(long queryIndex, int transactionId,
-            boolean expectUnicastResponse, boolean isFirstBurst, int burstCounter,
-            int queriesPerBurst, int timeBetweenBurstsInMs,
-            long delayBeforeTaskWithoutBackoffMs) {
+    QueryTaskConfig(int queryMode, int queryIndex, int transactionId,
+            boolean expectUnicastResponse) {
+        this.queryMode = queryMode;
         this.transactionId = transactionId;
-        this.expectUnicastResponse = expectUnicastResponse;
-        this.queriesPerBurst = queriesPerBurst;
-        this.timeBetweenBurstsInMs = timeBetweenBurstsInMs;
-        this.burstCounter = burstCounter;
-        this.delayBeforeTaskWithoutBackoffMs = delayBeforeTaskWithoutBackoffMs;
-        this.isFirstBurst = isFirstBurst;
         this.queryIndex = queryIndex;
+        this.expectUnicastResponse = expectUnicastResponse;
     }
 
     QueryTaskConfig(int queryMode) {
-        this.queriesPerBurst = QUERIES_PER_BURST;
-        this.burstCounter = 0;
-        this.transactionId = 1;
-        this.expectUnicastResponse = true;
-        this.isFirstBurst = true;
-        // Config the scan frequency based on the scan mode.
-        if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            this.timeBetweenBurstsInMs = INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS;
-            this.delayBeforeTaskWithoutBackoffMs =
-                    TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS;
-        } else if (queryMode == PASSIVE_QUERY_MODE) {
-            // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and then
-            // in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
-            // queries.
-            this.timeBetweenBurstsInMs = MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-            this.delayBeforeTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
-        } else {
-            // In active scan mode, sends a burst of QUERIES_PER_BURST queries,
-            // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and
-            // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and
-            // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS.
-            this.timeBetweenBurstsInMs = INITIAL_TIME_BETWEEN_BURSTS_MS;
-            this.delayBeforeTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
-        }
-        this.queryIndex = 0;
+        this(queryMode, 0, 1, true);
     }
 
-    long getDelayBeforeNextTaskWithoutBackoff(boolean isFirstQueryInBurst,
-            boolean isLastQueryInBurst, int queryMode) {
-        if (isFirstQueryInBurst && queryMode == AGGRESSIVE_QUERY_MODE) {
-            return 0;
+    private static int getBurstIndex(int queryIndex, int queryMode) {
+        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
+            // In passive mode, after the first burst of QUERIES_PER_BURST queries, subsequent
+            // bursts have QUERIES_PER_BURST_PASSIVE_MODE queries.
+            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
+            return 1 + (queryIndexAfterFirstBurst / QUERIES_PER_BURST_PASSIVE_MODE);
+        } else {
+            return queryIndex / QUERIES_PER_BURST;
         }
-        if (isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
+    }
+
+    private static int getQueryIndexInBurst(int queryIndex, int queryMode) {
+        if (queryMode == PASSIVE_QUERY_MODE && queryIndex >= QUERIES_PER_BURST) {
+            final int queryIndexAfterFirstBurst = queryIndex - QUERIES_PER_BURST;
+            return queryIndexAfterFirstBurst % QUERIES_PER_BURST_PASSIVE_MODE;
+        } else {
+            return queryIndex % QUERIES_PER_BURST;
+        }
+    }
+
+    private static boolean isFirstBurst(int queryIndex, int queryMode) {
+        return getBurstIndex(queryIndex, queryMode) == 0;
+    }
+
+    private static boolean isFirstQueryInBurst(int queryIndex, int queryMode) {
+        return getQueryIndexInBurst(queryIndex, queryMode) == 0;
+    }
+
+    // TODO: move delay calculations to MdnsQueryScheduler
+    long getDelayBeforeTaskWithoutBackoff() {
+        return getDelayBeforeTaskWithoutBackoff(queryIndex, queryMode);
+    }
+
+    private static long getDelayBeforeTaskWithoutBackoff(int queryIndex, int queryMode) {
+        final int burstIndex = getBurstIndex(queryIndex, queryMode);
+        final int queryIndexInBurst = getQueryIndexInBurst(queryIndex, queryMode);
+        if (queryIndexInBurst == 0) {
+            return getTimeToBurstMs(burstIndex, queryMode);
+        } else if (queryIndexInBurst == 1 && queryMode == AGGRESSIVE_QUERY_MODE) {
+            // In aggressive mode, the first 2 queries are sent without delay.
+            return 0;
         }
         return queryMode == AGGRESSIVE_QUERY_MODE
                 ? TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS
                 : TIME_BETWEEN_QUERIES_IN_BURST_MS;
     }
 
-    boolean getNextExpectUnicastResponse(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return false;
-        }
+    private boolean getExpectUnicastResponse(int queryIndex, int queryMode) {
         if (queryMode == AGGRESSIVE_QUERY_MODE) {
-            return true;
+            if (isFirstQueryInBurst(queryIndex, queryMode)) {
+                return true;
+            }
         }
         return alwaysAskForUnicastResponse;
     }
 
-    int getNextTimeBetweenBurstsMs(boolean isLastQueryInBurst, int queryMode) {
-        if (!isLastQueryInBurst) {
-            return timeBetweenBurstsInMs;
+    /**
+     * Shifts a value left by the specified number of bits, coercing to at most maxValue.
+     *
+     * <p>This allows calculating min(value*2^shift, maxValue) without overflow.
+     */
+    private static int boundedLeftShift(int value, int shift, int maxValue) {
+        // There must be at least one leading zero for positive values, so the maximum left shift
+        // without overflow is the number of leading zeros minus one.
+        final int maxShift = Integer.numberOfLeadingZeros(value) - 1;
+        if (shift > maxShift) {
+            // The shift would overflow positive integers, so is greater than maxValue.
+            return maxValue;
         }
-        final int maxTimeBetweenBursts = queryMode == AGGRESSIVE_QUERY_MODE
-                ? MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS : MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-        return Math.min(timeBetweenBurstsInMs * 2, maxTimeBetweenBursts);
+        return Math.min(value << shift, maxValue);
+    }
+
+    private static int getTimeToBurstMs(int burstIndex, int queryMode) {
+        if (burstIndex == 0) {
+            // No delay before the first burst
+            return 0;
+        }
+        switch (queryMode) {
+            case PASSIVE_QUERY_MODE:
+                return MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
+            case AGGRESSIVE_QUERY_MODE:
+                return boundedLeftShift(INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS,
+                        burstIndex - 1,
+                        MAX_TIME_BETWEEN_AGGRESSIVE_BURSTS_MS);
+            default: // ACTIVE_QUERY_MODE
+                return boundedLeftShift(INITIAL_TIME_BETWEEN_BURSTS_MS,
+                        burstIndex - 1,
+                        MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS);
+        }
     }
 
     /**
      * Get new QueryTaskConfig for next run.
      */
     public QueryTaskConfig getConfigForNextRun(int queryMode) {
-        long newQueryCount = queryIndex + 1;
+        final int newQueryIndex = queryIndex + 1;
         int newTransactionId = transactionId + 1;
         if (newTransactionId > UNSIGNED_SHORT_MAX_VALUE) {
             newTransactionId = 1;
         }
 
-        int newQueriesPerBurst = queriesPerBurst;
-        int newBurstCounter = burstCounter + 1;
-        final boolean isFirstQueryInBurst = newBurstCounter == 1;
-        final boolean isLastQueryInBurst = newBurstCounter == queriesPerBurst;
-        boolean newIsFirstBurst = isFirstBurst && !isLastQueryInBurst;
-        if (isLastQueryInBurst) {
-            newBurstCounter = 0;
-            // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and
-            // then in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
-            // queries.
-            if (isFirstBurst && queryMode == PASSIVE_QUERY_MODE) {
-                newQueriesPerBurst = QUERIES_PER_BURST_PASSIVE_MODE;
-            }
-        }
-
-        return new QueryTaskConfig(newQueryCount, newTransactionId,
-                getNextExpectUnicastResponse(isLastQueryInBurst, queryMode), newIsFirstBurst,
-                newBurstCounter, newQueriesPerBurst,
-                getNextTimeBetweenBurstsMs(isLastQueryInBurst, queryMode),
-                getDelayBeforeNextTaskWithoutBackoff(
-                        isFirstQueryInBurst, isLastQueryInBurst, queryMode));
+        return new QueryTaskConfig(queryMode, newQueryIndex, newTransactionId,
+                getExpectUnicastResponse(newQueryIndex, queryMode));
     }
 
     /**
@@ -171,7 +177,7 @@
      */
     public boolean shouldUseQueryBackoff(int numOfQueriesBeforeBackoff) {
         // Don't enable backoff mode during the burst or in the first burst
-        if (burstCounter != 0 || isFirstBurst) {
+        if (!isFirstQueryInBurst(queryIndex, queryMode) || isFirstBurst(queryIndex, queryMode)) {
             return false;
         }
         return queryIndex > numOfQueriesBeforeBackoff;
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 67d0891..07469b1 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -215,9 +215,6 @@
         // Note: processNetlinkMessage is called on the handler thread.
         @Override
         protected void processNetlinkMessage(NetlinkMessage nlMsg, long whenMs) {
-            // ignore all updates when ethernet is disabled.
-            if (mEthernetState == ETHERNET_STATE_DISABLED) return;
-
             if (nlMsg instanceof RtNetlinkLinkMessage) {
                 processRtNetlinkLinkMessage((RtNetlinkLinkMessage) nlMsg);
             } else {
@@ -596,10 +593,13 @@
             // Read the flags before attempting to bring up the interface. If the interface is
             // already running an UP event is created after adding the interface.
             config = NetdUtils.getInterfaceConfigParcel(mNetd, iface);
-            if (NetdUtils.hasFlag(config, INetd.IF_STATE_DOWN)) {
+            // Only bring the interface up when ethernet is enabled.
+            if (mEthernetState == ETHERNET_STATE_ENABLED) {
                 // As a side-effect, NetdUtils#setInterfaceUp() also clears the interface's IPv4
                 // address and readds it which *could* lead to unexpected behavior in the future.
                 NetdUtils.setInterfaceUp(mNetd, iface);
+            } else if (mEthernetState == ETHERNET_STATE_DISABLED) {
+                NetdUtils.setInterfaceDown(mNetd, iface);
             }
         } catch (IllegalStateException e) {
             // Either the system is crashing or the interface has disappeared. Just ignore the
@@ -646,6 +646,10 @@
     }
 
     private void setInterfaceAdministrativeState(String iface, boolean up, EthernetCallback cb) {
+        if (mEthernetState == ETHERNET_STATE_DISABLED) {
+            cb.onError("Cannot enable/disable interface when ethernet is disabled");
+            return;
+        }
         if (getInterfaceState(iface) == EthernetManager.STATE_ABSENT) {
             cb.onError("Failed to enable/disable absent interface: " + iface);
             return;
@@ -965,22 +969,26 @@
 
             mEthernetState = newState;
 
-            if (enabled) {
-                trackAvailableInterfaces();
-            } else {
-                // TODO: maybe also disable server mode interface as well.
-                untrackFactoryInterfaces();
+            // Interface in server mode should also be included.
+            ArrayList<String> interfaces =
+                    new ArrayList<>(
+                    List.of(mFactory.getAvailableInterfaces(/* includeRestricted */ true)));
+
+            if (mTetheringInterfaceMode == INTERFACE_MODE_SERVER) {
+                interfaces.add(mTetheringInterface);
+            }
+
+            for (String iface : interfaces) {
+                if (enabled) {
+                    NetdUtils.setInterfaceUp(mNetd, iface);
+                } else {
+                    NetdUtils.setInterfaceDown(mNetd, iface);
+                }
             }
             broadcastEthernetStateChange(mEthernetState);
         });
     }
 
-    private void untrackFactoryInterfaces() {
-        for (String iface : mFactory.getAvailableInterfaces(true /* includeRestricted */)) {
-            stopTrackingInterface(iface);
-        }
-    }
-
     private void unicastEthernetStateChange(@NonNull IEthernetServiceListener listener,
             int state) {
         ensureRunningOnEthernetServiceThread();
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 294a85a..fb712a1 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -52,7 +52,6 @@
 import static android.net.TrafficStats.KB_IN_BYTES;
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.net.TrafficStats.UID_TETHERING;
-import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -127,6 +126,8 @@
 import android.net.Uri;
 import android.net.netstats.IUsageCallback;
 import android.net.netstats.NetworkStatsDataMigrationUtils;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProvider;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.net.netstats.provider.NetworkStatsProvider;
@@ -485,16 +486,23 @@
     @GuardedBy("mStatsLock")
     private long mLatestNetworkStatsUpdatedBroadcastScheduledTime = Long.MIN_VALUE;
 
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsTotalCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsIfaceCache;
+    @Nullable
     private final TrafficStatsRateLimitCache mTrafficStatsUidCache;
+    // A feature flag to control whether the client-side rate limit cache should be enabled.
+    static final String TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG =
+            "trafficstats_client_rate_limit_cache_enabled_flag";
     static final String TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG =
             "trafficstats_rate_limit_cache_enabled_flag";
     static final String BROADCAST_NETWORK_STATS_UPDATED_RATE_LIMIT_ENABLED_FLAG =
             "broadcast_network_stats_updated_rate_limit_enabled_flag";
-    private final boolean mAlwaysUseTrafficStatsServiceRateLimitCache;
+    private final boolean mIsTrafficStatsServiceRateLimitCacheEnabled;
     private final int mTrafficStatsRateLimitCacheExpiryDuration;
     private final int mTrafficStatsServiceRateLimitCacheMaxEntries;
+    private final TrafficStatsRateLimitCacheConfig mTrafficStatsRateLimitCacheClientSideConfig;
     private final boolean mBroadcastNetworkStatsUpdatedRateLimitEnabled;
 
 
@@ -688,23 +696,34 @@
             mEventLogger = null;
         }
 
-        mAlwaysUseTrafficStatsServiceRateLimitCache =
-                mDeps.alwaysUseTrafficStatsServiceRateLimitCache(mContext);
+        mTrafficStatsRateLimitCacheClientSideConfig =
+                mDeps.getTrafficStatsRateLimitCacheClientSideConfig(mContext);
+        // If the client side cache feature is enabled, disable the service side
+        // cache unconditionally.
+        mIsTrafficStatsServiceRateLimitCacheEnabled =
+                mDeps.isTrafficStatsServiceRateLimitCacheEnabled(mContext,
+                        mTrafficStatsRateLimitCacheClientSideConfig.isCacheEnabled);
         mBroadcastNetworkStatsUpdatedRateLimitEnabled =
                 mDeps.enabledBroadcastNetworkStatsUpdatedRateLimiting(mContext);
         mTrafficStatsRateLimitCacheExpiryDuration =
                 mDeps.getTrafficStatsRateLimitCacheExpiryDuration();
         mTrafficStatsServiceRateLimitCacheMaxEntries =
                 mDeps.getTrafficStatsServiceRateLimitCacheMaxEntries();
-        mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
-        mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
-        mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
-                mTrafficStatsRateLimitCacheExpiryDuration,
-                mTrafficStatsServiceRateLimitCacheMaxEntries);
+        if (mIsTrafficStatsServiceRateLimitCacheEnabled) {
+            mTrafficStatsTotalCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+            mTrafficStatsIfaceCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+            mTrafficStatsUidCache = new TrafficStatsRateLimitCache(mClock,
+                    mTrafficStatsRateLimitCacheExpiryDuration,
+                    mTrafficStatsServiceRateLimitCacheMaxEntries);
+        } else {
+            mTrafficStatsTotalCache = null;
+            mTrafficStatsIfaceCache = null;
+            mTrafficStatsUidCache = null;
+        }
 
         // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
         // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
@@ -964,13 +983,42 @@
         }
 
         /**
-         * Get whether TrafficStats service side rate-limit cache is always applied.
+         * Get client side traffic stats rate-limit cache config.
          *
          * This method should only be called once in the constructor,
          * to ensure that the code does not need to deal with flag values changing at runtime.
          */
-        public boolean alwaysUseTrafficStatsServiceRateLimitCache(@NonNull Context ctx) {
-            return SdkLevel.isAtLeastV() && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+        @NonNull
+        public TrafficStatsRateLimitCacheConfig getTrafficStatsRateLimitCacheClientSideConfig(
+                @NonNull Context ctx) {
+            final TrafficStatsRateLimitCacheConfig config =
+                    new TrafficStatsRateLimitCacheConfig.Builder()
+                            .setIsCacheEnabled(DeviceConfigUtils.isTetheringFeatureEnabled(
+                                    ctx, TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG))
+                            .setExpiryDurationMs(getDeviceConfigPropertyInt(
+                                    NAMESPACE_TETHERING, TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
+                                    DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS))
+                            .setMaxEntries(getDeviceConfigPropertyInt(
+                                    NAMESPACE_TETHERING,
+                                    TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
+                                    DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES))
+                            .build();
+            return config;
+        }
+
+        /**
+         * Determines whether the service-side rate-limiting cache is enabled.
+         *
+         * The cache is enabled for devices running Android V+ or apps targeting SDK V+
+         * if the `TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG` feature flag
+         * is enabled and client-side caching is disabled.
+         *
+         * This method should only be called once in the constructor,
+         * to ensure that the code does not need to deal with flag values changing at runtime.
+         */
+        public boolean isTrafficStatsServiceRateLimitCacheEnabled(@NonNull Context ctx,
+                boolean clientCacheEnabled) {
+            return !clientCacheEnabled && DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
                     ctx, TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG);
         }
 
@@ -2133,30 +2181,48 @@
         }
     }
 
-    @Override
-    public long getUidStats(int uid, int type) {
-        return getValueForTypeFromFirstEntry(getTypelessUidStats(uid), type);
+    /**
+     * Determines whether to use the client-side cache for traffic stats rate limiting.
+     *
+     * This is based on the cache enabled feature flag. If enabled, the client-side cache
+     * is used for V+ devices or callers with V+ target sdk.
+     *
+     * @param callingUid The UID of the app making the request.
+     * @return True if the client-side cache should be used, false otherwise.
+     */
+    private boolean useClientSideCache(int callingUid) {
+        return mTrafficStatsRateLimitCacheClientSideConfig.isCacheEnabled && (SdkLevel.isAtLeastV()
+                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid));
     }
 
-    @NonNull
+    /**
+     * Determines whether to use the service-side cache for traffic stats rate limiting.
+     *
+     * This is based on the cache enabled feature flag. If enabled, the service-side cache
+     * is used for V+ devices or callers with V+ target sdk.
+     *
+     * @param callingUid The UID of the app making the request.
+     * @return True if the service-side cache should be used, false otherwise.
+     */
+    private boolean useServiceSideCache(int callingUid) {
+        return mIsTrafficStatsServiceRateLimitCacheEnabled && (SdkLevel.isAtLeastV()
+                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid));
+    }
+
+    @Nullable
     @Override
-    public NetworkStats getTypelessUidStats(int uid) {
-        final NetworkStats stats = new NetworkStats(0, 0);
+    public StatsResult getUidStats(int uid) {
         final int callingUid = Binder.getCallingUid();
         if (callingUid != android.os.Process.SYSTEM_UID && callingUid != uid) {
-            return stats;
+            return null;
         }
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || mDeps.isChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, callingUid)) {
+        if (useServiceSideCache(callingUid)) {
             entry = mTrafficStatsUidCache.getOrCompute(IFACE_ALL, uid,
                     () -> mDeps.nativeGetUidStat(uid));
         } else entry = mDeps.nativeGetUidStat(uid);
 
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
     }
 
     @Nullable
@@ -2173,24 +2239,24 @@
         return entry;
     }
 
-    @NonNull
+    @Nullable
     @Override
-    public NetworkStats getTypelessIfaceStats(@NonNull String iface) {
+    public StatsResult getIfaceStats(@NonNull String iface) {
         Objects.requireNonNull(iface);
 
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || mDeps.isChangeEnabled(
-                        ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
+        if (useServiceSideCache(Binder.getCallingUid())) {
             entry = mTrafficStatsIfaceCache.getOrCompute(iface, UID_ALL,
                     () -> getIfaceStatsInternal(iface));
         } else entry = getIfaceStatsInternal(iface);
 
-        NetworkStats stats = new NetworkStats(0, 0);
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
+    }
+
+    @Nullable
+    private StatsResult getStatsResultFromEntryOrNull(@Nullable NetworkStats.Entry entry) {
+        if (entry == null) return null;
+        return new StatsResult(entry.rxBytes, entry.rxPackets, entry.txBytes, entry.txPackets);
     }
 
     @Nullable
@@ -2203,30 +2269,39 @@
         return entry;
     }
 
-    @NonNull
+    @Nullable
     @Override
-    public NetworkStats getTypelessTotalStats() {
+    public StatsResult getTotalStats() {
         final NetworkStats.Entry entry;
-        if (mAlwaysUseTrafficStatsServiceRateLimitCache
-                || mDeps.isChangeEnabled(
-                        ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, Binder.getCallingUid())) {
+        if (useServiceSideCache(Binder.getCallingUid())) {
             entry = mTrafficStatsTotalCache.getOrCompute(
                     IFACE_ALL, UID_ALL, () -> getTotalStatsInternal());
         } else entry = getTotalStatsInternal();
 
-        final NetworkStats stats = new NetworkStats(0, 0);
-        if (entry != null) {
-            stats.insertEntry(entry);
-        }
-        return stats;
+        return getStatsResultFromEntryOrNull(entry);
     }
 
     @Override
     public void clearTrafficStatsRateLimitCaches() {
         PermissionUtils.enforceNetworkStackPermissionOr(mContext, NETWORK_SETTINGS);
-        mTrafficStatsUidCache.clear();
-        mTrafficStatsIfaceCache.clear();
-        mTrafficStatsTotalCache.clear();
+        if (mIsTrafficStatsServiceRateLimitCacheEnabled) {
+            mTrafficStatsUidCache.clear();
+            mTrafficStatsIfaceCache.clear();
+            mTrafficStatsTotalCache.clear();
+        }
+    }
+
+    @Override
+    public TrafficStatsRateLimitCacheConfig getRateLimitCacheConfig() {
+        // Build a per uid config for the client based on the checking result.
+        final TrafficStatsRateLimitCacheConfig config =
+                new TrafficStatsRateLimitCacheConfig.Builder()
+                        .setIsCacheEnabled(useClientSideCache(Binder.getCallingUid()))
+                        .setExpiryDurationMs(
+                                mTrafficStatsRateLimitCacheClientSideConfig.expiryDurationMs)
+                        .setMaxEntries(mTrafficStatsRateLimitCacheClientSideConfig.maxEntries)
+                        .build();
+        return config;
     }
 
     private NetworkStats.Entry getProviderIfaceStats(@Nullable String iface) {
@@ -2996,8 +3071,8 @@
             } catch (IOException e) {
                 pw.println("(failed to dump FastDataInput counters)");
             }
-            pw.print("trafficstats.service.cache.alwaysuse",
-                    mAlwaysUseTrafficStatsServiceRateLimitCache);
+            pw.print("trafficstats.service.cache.isenabled",
+                    mIsTrafficStatsServiceRateLimitCacheEnabled);
             pw.println();
             pw.print(TRAFFIC_STATS_CACHE_EXPIRY_DURATION_NAME,
                     mTrafficStatsRateLimitCacheExpiryDuration);
@@ -3005,6 +3080,9 @@
             pw.print(TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES_NAME,
                     mTrafficStatsServiceRateLimitCacheMaxEntries);
             pw.println();
+            pw.print("trafficstats.client.cache.config",
+                    mTrafficStatsRateLimitCacheClientSideConfig);
+            pw.println();
 
             pw.decreaseIndent();
 
diff --git a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
index ca97d07..667aad1 100644
--- a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
+++ b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
@@ -19,9 +19,8 @@
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.net.NetworkStats;
-import android.util.LruCache;
 
-import com.android.internal.annotations.GuardedBy;
+import com.android.net.module.util.LruCacheWithExpiry;
 
 import java.time.Clock;
 import java.util.Objects;
@@ -31,9 +30,8 @@
  * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
  * with an adjustable expiry duration to manage data freshness.
  */
-class TrafficStatsRateLimitCache {
-    private final Clock mClock;
-    private final long mExpiryDurationMs;
+class TrafficStatsRateLimitCache extends
+        LruCacheWithExpiry<TrafficStatsRateLimitCache.TrafficStatsCacheKey, NetworkStats.Entry> {
 
     /**
      * Constructs a new {@link TrafficStatsRateLimitCache} with the specified expiry duration.
@@ -43,19 +41,17 @@
      * @param maxSize Maximum number of entries.
      */
     TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs, int maxSize) {
-        mClock = clock;
-        mExpiryDurationMs = expiryDurationMs;
-        mMap = new LruCache<>(maxSize);
+        super(clock, expiryDurationMs, maxSize, it -> !it.isEmpty());
     }
 
-    private static class TrafficStatsCacheKey {
+    public static class TrafficStatsCacheKey {
         @Nullable
-        public final String iface;
-        public final int uid;
+        private final String mIface;
+        private final int mUid;
 
         TrafficStatsCacheKey(@Nullable String iface, int uid) {
-            this.iface = iface;
-            this.uid = uid;
+            this.mIface = iface;
+            this.mUid = uid;
         }
 
         @Override
@@ -63,29 +59,15 @@
             if (this == o) return true;
             if (!(o instanceof TrafficStatsCacheKey)) return false;
             TrafficStatsCacheKey that = (TrafficStatsCacheKey) o;
-            return uid == that.uid && Objects.equals(iface, that.iface);
+            return mUid == that.mUid && Objects.equals(mIface, that.mIface);
         }
 
         @Override
         public int hashCode() {
-            return Objects.hash(iface, uid);
+            return Objects.hash(mIface, mUid);
         }
     }
 
-    private static class TrafficStatsCacheValue {
-        public final long timestamp;
-        @NonNull
-        public final NetworkStats.Entry entry;
-
-        TrafficStatsCacheValue(long timestamp, NetworkStats.Entry entry) {
-            this.timestamp = timestamp;
-            this.entry = entry;
-        }
-    }
-
-    @GuardedBy("mMap")
-    private final LruCache<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap;
-
     /**
      * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
      *
@@ -95,16 +77,7 @@
      */
     @Nullable
     NetworkStats.Entry get(String iface, int uid) {
-        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
-        synchronized (mMap) { // Synchronize for thread-safety
-            final TrafficStatsCacheValue value = mMap.get(key);
-            if (value != null && !isExpired(value.timestamp)) {
-                return value.entry;
-            } else {
-                mMap.remove(key); // Remove expired entries
-                return null;
-            }
-        }
+        return super.get(new TrafficStatsCacheKey(iface, uid));
     }
 
     /**
@@ -122,19 +95,7 @@
     @Nullable
     NetworkStats.Entry getOrCompute(String iface, int uid,
             @NonNull Supplier<NetworkStats.Entry> supplier) {
-        synchronized (mMap) {
-            final NetworkStats.Entry cachedValue = get(iface, uid);
-            if (cachedValue != null) {
-                return cachedValue;
-            }
-
-            // Entry not found or expired, compute it
-            final NetworkStats.Entry computedEntry = supplier.get();
-            if (computedEntry != null && !computedEntry.isEmpty()) {
-                put(iface, uid, computedEntry);
-            }
-            return computedEntry;
-        }
+        return super.getOrCompute(new TrafficStatsCacheKey(iface, uid), supplier);
     }
 
     /**
@@ -145,23 +106,7 @@
      * @param entry The {@link NetworkStats.Entry} to store in the cache.
      */
     void put(String iface, int uid, @NonNull final NetworkStats.Entry entry) {
-        Objects.requireNonNull(entry);
-        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
-        synchronized (mMap) { // Synchronize for thread-safety
-            mMap.put(key, new TrafficStatsCacheValue(mClock.millis(), entry));
-        }
+        super.put(new TrafficStatsCacheKey(iface, uid), entry);
     }
 
-    /**
-     * Clear the cache.
-     */
-    void clear() {
-        synchronized (mMap) {
-            mMap.evictAll();
-        }
-    }
-
-    private boolean isExpired(long timestamp) {
-        return mClock.millis() > timestamp + mExpiryDurationMs;
-    }
 }
diff --git a/service/libconnectivity/Android.bp b/service/libconnectivity/Android.bp
index 3a72134..9bfe3a9 100644
--- a/service/libconnectivity/Android.bp
+++ b/service/libconnectivity/Android.bp
@@ -42,6 +42,7 @@
     ],
     llndk: {
         symbol_file: "libconnectivity_native.map.txt",
+        moved_to_apex: true,
     },
     stubs: {
         symbol_file: "libconnectivity_native.map.txt",
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
old mode 100755
new mode 100644
index e503312..0d0f6fc
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -121,6 +121,7 @@
 import static android.net.connectivity.ConnectivityCompatChanges.NETWORK_BLOCKED_WITHOUT_INTERNET_PERMISSION;
 import static android.os.Process.INVALID_UID;
 import static android.os.Process.VPN_UID;
+import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 import static android.system.OsConstants.ETH_P_ALL;
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
@@ -145,10 +146,12 @@
 import static com.android.net.module.util.PermissionUtils.enforceNetworkStackPermissionOr;
 import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf;
 import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
+import static com.android.server.connectivity.ConnectivityFlags.CELLULAR_DATA_INACTIVITY_TIMEOUT;
 import static com.android.server.connectivity.ConnectivityFlags.DELAY_DESTROY_SOCKETS;
 import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.server.connectivity.ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS;
 import static com.android.server.connectivity.ConnectivityFlags.REQUEST_RESTRICTED_WIFI;
+import static com.android.server.connectivity.ConnectivityFlags.WIFI_DATA_INACTIVITY_TIMEOUT;
 
 import android.Manifest;
 import android.annotation.CheckResult;
@@ -1610,6 +1613,18 @@
                     connectivityServiceInternalHandler);
         }
 
+        /** Returns the data inactivity timeout to be used for cellular networks */
+        public int getDefaultCellularDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING,
+                    CELLULAR_DATA_INACTIVITY_TIMEOUT, 10);
+        }
+
+        /** Returns the data inactivity timeout to be used for WiFi networks */
+        public int getDefaultWifiDataInactivityTimeout() {
+            return DeviceConfigUtils.getDeviceConfigPropertyInt(NAMESPACE_TETHERING,
+                    WIFI_DATA_INACTIVITY_TIMEOUT, 15);
+        }
+
         /**
          * @see DeviceConfigUtils#isTetheringFeatureEnabled
          */
@@ -1958,8 +1973,13 @@
         // But reading the trunk stable flags from mainline modules is not supported yet.
         // So enabling this feature on V+ release.
         mTrackMultiNetworkActivities = mDeps.isAtLeastV();
+        final int defaultCellularDataInactivityTimeout =
+                mDeps.getDefaultCellularDataInactivityTimeout();
+        final int defaultWifiDataInactivityTimeout =
+                mDeps.getDefaultWifiDataInactivityTimeout();
         mNetworkActivityTracker = new LegacyNetworkActivityTracker(mContext, mNetd, mHandler,
-                mTrackMultiNetworkActivities);
+                mTrackMultiNetworkActivities, defaultCellularDataInactivityTimeout,
+                defaultWifiDataInactivityTimeout);
 
         final NetdCallback netdCallback = new NetdCallback();
         try {
@@ -5531,7 +5551,7 @@
         }
 
         // Delayed teardown.
-        if (nai.isCreated()) {
+        if (nai.isCreated() && !nai.isDestroyed()) {
             try {
                 mNetd.networkSetPermissionForNetwork(nai.network.netId, INetd.PERMISSION_SYSTEM);
             } catch (RemoteException e) {
@@ -13027,6 +13047,8 @@
         // Key is netId. Value is configured idle timer information.
         private final SparseArray<IdleTimerParams> mActiveIdleTimers = new SparseArray<>();
         private final boolean mTrackMultiNetworkActivities;
+        private final int mDefaultCellularDataInactivityTimeout;
+        private final int mDefaultWifiDataInactivityTimeout;
         // Store netIds of Wi-Fi networks whose idletimers report that they are active
         private final Set<Integer> mActiveWifiNetworks = new ArraySet<>();
         // Store netIds of cellular networks whose idletimers report that they are active
@@ -13043,11 +13065,14 @@
         }
 
         LegacyNetworkActivityTracker(@NonNull Context context, @NonNull INetd netd,
-                @NonNull Handler handler, boolean trackMultiNetworkActivities) {
+                @NonNull Handler handler, boolean trackMultiNetworkActivities,
+                int defaultCellularDataInactivityTimeout, int defaultWifiDataInactivityTimeout) {
             mContext = context;
             mNetd = netd;
             mHandler = handler;
             mTrackMultiNetworkActivities = trackMultiNetworkActivities;
+            mDefaultCellularDataInactivityTimeout = defaultCellularDataInactivityTimeout;
+            mDefaultWifiDataInactivityTimeout = defaultWifiDataInactivityTimeout;
         }
 
         private void ensureRunningOnConnectivityServiceThread() {
@@ -13247,13 +13272,13 @@
                     NetworkCapabilities.TRANSPORT_CELLULAR)) {
                 timeout = Settings.Global.getInt(mContext.getContentResolver(),
                         ConnectivitySettingsManager.DATA_ACTIVITY_TIMEOUT_MOBILE,
-                        10);
+                        mDefaultCellularDataInactivityTimeout);
                 type = NetworkCapabilities.TRANSPORT_CELLULAR;
             } else if (networkAgent.networkCapabilities.hasTransport(
                     NetworkCapabilities.TRANSPORT_WIFI)) {
                 timeout = Settings.Global.getInt(mContext.getContentResolver(),
                         ConnectivitySettingsManager.DATA_ACTIVITY_TIMEOUT_WIFI,
-                        15);
+                        mDefaultWifiDataInactivityTimeout);
                 type = NetworkCapabilities.TRANSPORT_WIFI;
             } else {
                 return false; // do not track any other networks
@@ -13377,6 +13402,12 @@
 
         public void dump(IndentingPrintWriter pw) {
             pw.print("mTrackMultiNetworkActivities="); pw.println(mTrackMultiNetworkActivities);
+
+            pw.print("mDefaultCellularDataInactivityTimeout=");
+            pw.println(mDefaultCellularDataInactivityTimeout);
+            pw.print("mDefaultWifiDataInactivityTimeout=");
+            pw.println(mDefaultWifiDataInactivityTimeout);
+
             pw.print("mIsDefaultNetworkActive="); pw.println(mIsDefaultNetworkActive);
             pw.print("mDefaultNetwork="); pw.println(mDefaultNetwork);
             pw.println("Idle timers:");
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index df87316..93335f1 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -44,6 +44,11 @@
 
     public static final String BACKGROUND_FIREWALL_CHAIN = "background_firewall_chain";
 
+    public static final String CELLULAR_DATA_INACTIVITY_TIMEOUT =
+            "cellular_data_inactivity_timeout";
+
+    public static final String WIFI_DATA_INACTIVITY_TIMEOUT = "wifi_data_inactivity_timeout";
+
     public static final String DELAY_DESTROY_SOCKETS = "delay_destroy_sockets";
 
     public static final String USE_DECLARED_METHODS_FOR_CALLBACKS =
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index a825b87..c29004c 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -671,11 +671,12 @@
     visibility: ["//visibility:private"],
 }
 
+// Sources outside of com.android.net.module.util should not be added because many modules depend on
+// them and need jarjar rules
 filegroup {
     name: "net-utils-all-srcs",
     srcs: [
         "device/**/*.java",
-        ":framework-connectivity-shared-srcs",
         ":net-utils-framework-common-srcs",
     ],
     visibility: ["//visibility:private"],
@@ -724,3 +725,10 @@
     ],
     apex_available: ["com.android.wifi"],
 }
+
+genrule {
+    name: "statslog-framework-connectivity-java-gen",
+    tools: ["stats-log-api-gen"],
+    cmd: "$(location stats-log-api-gen) --java $(out) --module connectivity --javaPackage com.android.net.module.util --javaClass FrameworkConnectivityStatsLog",
+    out: ["com/android/net/module/util/FrameworkConnectivityStatsLog.java"],
+}
diff --git a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index 7fcbd4e..bb95585 100644
--- a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -35,12 +35,12 @@
 import android.net.NetworkCapabilities;
 import android.os.RemoteException;
 import android.util.ArrayMap;
-import android.util.IndentingPrintWriter;
 
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
 
+import java.io.PrintWriter;
 import java.net.Inet4Address;
 import java.net.InetAddress;
 import java.util.ArrayList;
@@ -459,34 +459,27 @@
         }
     }
 
-    // TODO: dump PrivateAddressCoordinator when dumping RoutingCoordinatorService.
-    void dump(final IndentingPrintWriter pw) {
+    // TODO: dump PrivateAddressCoordinator when dumping RoutingCoordinatorService and apply
+    // indentation.
+    void dump(final PrintWriter pw) {
         pw.println("mTetheringPrefixes:");
-        pw.increaseIndent();
         for (IpPrefix prefix : mTetheringPrefixes) {
             pw.println(prefix);
         }
-        pw.decreaseIndent();
 
         pw.println("mUpstreamPrefixMap:");
-        pw.increaseIndent();
         for (int i = 0; i < mUpstreamPrefixMap.size(); i++) {
             pw.println(mUpstreamPrefixMap.keyAt(i) + " - " + mUpstreamPrefixMap.valueAt(i));
         }
-        pw.decreaseIndent();
 
         pw.println("mDownstreams:");
-        pw.increaseIndent();
         for (LinkAddress downstream : mDownstreams.values()) {
             pw.println(downstream);
         }
-        pw.decreaseIndent();
 
         pw.println("mCachedAddresses:");
-        pw.increaseIndent();
         for (int i = 0; i < mCachedAddresses.size(); i++) {
             pw.println(mCachedAddresses.keyAt(i) + " - " + mCachedAddresses.valueAt(i));
         }
-        pw.decreaseIndent();
     }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
new file mode 100644
index 0000000..80088b9
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/LruCacheWithExpiry.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.LruCache;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.time.Clock;
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/**
+ * An LRU cache that stores key-value pairs with an expiry time.
+ *
+ * <p>This cache uses an {@link LruCache} to store entries and evicts the least
+ * recently used entries when the cache reaches its maximum capacity. It also
+ * supports an expiry time for each entry, allowing entries to be automatically
+ * removed from the cache after a certain duration.
+ *
+ * @param <K> The type of keys used to identify cached entries.
+ * @param <V> The type of values stored in the cache.
+ *
+ * @hide
+ */
+public class LruCacheWithExpiry<K, V> {
+    private final Clock mClock;
+    private final long mExpiryDurationMs;
+    @GuardedBy("mMap")
+    private final LruCache<K, CacheValue<V>> mMap;
+    private final Predicate<V> mShouldCacheValue;
+
+    /**
+     * Constructs a new {@link LruCacheWithExpiry} with the specified parameters.
+     *
+     * @param clock            The {@link Clock} to use for determining timestamps.
+     * @param expiryDurationMs The expiry duration for cached entries in milliseconds.
+     * @param maxSize          The maximum number of entries to hold in the cache.
+     * @param shouldCacheValue A {@link Predicate} that determines whether a given value should be
+     *                         cached. This can be used to filter out certain values from being
+     *                         stored in the cache.
+     */
+    public LruCacheWithExpiry(@NonNull Clock clock, long expiryDurationMs, int maxSize,
+            Predicate<V> shouldCacheValue) {
+        mClock = clock;
+        mExpiryDurationMs = expiryDurationMs;
+        mMap = new LruCache<>(maxSize);
+        mShouldCacheValue = shouldCacheValue;
+    }
+
+    /**
+     * Retrieves a value from the cache, associated with the given key.
+     *
+     * @param key The key to look up in the cache.
+     * @return The cached value, or {@code null} if not found or expired.
+     */
+    @Nullable
+    public V get(@NonNull K key) {
+        synchronized (mMap) {
+            final CacheValue<V> value = mMap.get(key);
+            if (value != null && !isExpired(value.timestamp)) {
+                return value.entry;
+            } else {
+                mMap.remove(key); // Remove expired entries
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Retrieves a value from the cache, associated with the given key.
+     * If the entry is not found in the cache or has expired, computes it using the provided
+     * {@code supplier} and stores the result in the cache.
+     *
+     * @param key      The key to look up in the cache.
+     * @param supplier The {@link Supplier} to compute the value if not found or expired.
+     * @return The cached or computed value, or {@code null} if the {@code supplier} returns null.
+     */
+    @Nullable
+    public V getOrCompute(@NonNull K key, @NonNull Supplier<V> supplier) {
+        synchronized (mMap) {
+            final V cachedValue = get(key);
+            if (cachedValue != null) {
+                return cachedValue;
+            }
+
+            // Entry not found or expired, compute it
+            final V computedValue = supplier.get();
+            if (computedValue != null && mShouldCacheValue.test(computedValue)) {
+                put(key, computedValue);
+            }
+            return computedValue;
+        }
+    }
+
+    /**
+     * Stores a value in the cache, associated with the given key.
+     *
+     * @param key   The key to associate with the value.
+     * @param value The value to store in the cache.
+     */
+    public void put(@NonNull K key, @NonNull V value) {
+        Objects.requireNonNull(value);
+        synchronized (mMap) {
+            mMap.put(key, new CacheValue<>(mClock.millis(), value));
+        }
+    }
+
+    /**
+     * Clear the cache.
+     */
+    public void clear() {
+        synchronized (mMap) {
+            mMap.evictAll();
+        }
+    }
+
+    private boolean isExpired(long timestamp) {
+        return mClock.millis() > timestamp + mExpiryDurationMs;
+    }
+
+    private static class CacheValue<V> {
+        public final long timestamp;
+        @NonNull
+        public final V entry;
+
+        CacheValue(long timestamp, V entry) {
+            this.timestamp = timestamp;
+            this.entry = entry;
+        }
+    }
+}
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
index f1ff2e4..5d588cc 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
@@ -100,6 +100,15 @@
     public static final int IPV4_ADDR_LEN = 4;
     public static final int IPV4_FLAG_MF = 0x2000;
     public static final int IPV4_FLAG_DF = 0x4000;
+    public static final int IPV4_PROTOCOL_IGMP = 2;
+    public static final int IPV4_IGMP_MIN_SIZE = 8;
+    public static final int IPV4_IGMP_GROUP_RECORD_SIZE = 8;
+    public static final int IPV4_IGMP_TYPE_V1_REPORT = 0x12;
+    public static final int IPV4_IGMP_TYPE_V2_JOIN_REPORT = 0x16;
+    public static final int IPV4_IGMP_TYPE_V2_LEAVE_REPORT = 0x17;
+    public static final int IPV4_IGMP_TYPE_V3_REPORT = 0x22;
+    public static final int IPV4_OPTION_TYPE_ROUTER_ALERT = 0x94;
+    public static final int IPV4_OPTION_LEN_ROUTER_ALERT = 4;
     // getSockOpt() for v4 MTU
     public static final int IP_MTU = 14;
     public static final Inet4Address IPV4_ADDR_ALL = makeInet4Address(
diff --git a/staticlibs/framework/com/android/net/module/util/PermissionUtils.java b/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
index 0d7d96f..0fa91d5 100644
--- a/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
+++ b/staticlibs/framework/com/android/net/module/util/PermissionUtils.java
@@ -192,6 +192,8 @@
 
     /**
      * Enforces that the given package name belongs to the given uid.
+     * Note: b/377758490 - Figure out how to correct this to avoid mis-usage.
+     * Meanwhile, avoid calling this method from the networkstack.
      *
      * @param context {@link android.content.Context} for the process.
      * @param uid User ID to check the package ownership for.
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index f4ed9e4..2a26ef8 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -97,11 +97,7 @@
         "general-tests",
         "cts",
         "mts-networking",
-        "mcts-networking",
         "mts-tethering",
-        "mcts-tethering",
-        "mcts-wifi",
-        "mcts-dnsresolver",
     ],
     device_common_data: [":ConnectivityTestPreparer"],
 }
diff --git a/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
new file mode 100644
index 0000000..46e511e
--- /dev/null
+++ b/staticlibs/testutils/app/connectivitychecker/src/com/android/testutils/connectivitypreparer/CarrierConfigSetupTest.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils.connectivitypreparer
+
+import android.Manifest.permission.MODIFY_PHONE_STATE
+import android.Manifest.permission.READ_PHONE_STATE
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageManager.FEATURE_TELEPHONY_IMS
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.os.Build
+import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+import android.os.ParcelFileDescriptor
+import android.os.PersistableBundle
+import android.telephony.CarrierConfigManager
+import android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED
+import android.telephony.SubscriptionManager
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.net.module.util.ArrayTrackRecord
+import com.android.testutils.runAsShell
+import kotlin.test.assertNotNull
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val CONFIG_CHANGE_TIMEOUT_MS = 10_000L
+private val TAG = CarrierConfigSetupTest::class.simpleName
+
+@RunWith(AndroidJUnit4::class)
+class CarrierConfigSetupTest {
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val pm by lazy { context.packageManager }
+    private val carrierConfigManager by lazy {
+        context.getSystemService(CarrierConfigManager::class.java)
+    }
+
+    @Test
+    fun testSetCarrierConfig() {
+        if (!shouldDisableIwlan()) return
+        overrideAllSubscriptions(PersistableBundle().apply {
+            putBoolean(CarrierConfigManager.KEY_CARRIER_WFC_IMS_AVAILABLE_BOOL, false)
+        })
+    }
+
+    @Test
+    fun testClearCarrierConfig() {
+        // set/clear are in different test runs so it is difficult to share state between them.
+        // The conditions to disable IWLAN should not change over time (in particular
+        // force_iwlan_mms is a readonly flag), so just perform the same check again on teardown.
+        // CarrierConfigManager overrides are cleared on reboot by default anyway, so any missed
+        // cleanup should not be too damaging.
+        if (!shouldDisableIwlan()) return
+        overrideAllSubscriptions(null)
+    }
+
+    private class ConfigChangedReceiver : BroadcastReceiver() {
+        val receivedSubIds = ArrayTrackRecord<Int>()
+        override fun onReceive(context: Context, intent: Intent) {
+            if (intent.action != ACTION_CARRIER_CONFIG_CHANGED) return
+            val subIdx = intent.getIntExtra(SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX, -1)
+            // It is possible this is a configuration change for a different setting, so the test
+            // may not wait for long enough. Unfortunately calling CarrierConfigManager to check
+            // if the config was applied does not help because it will always return the override,
+            // even if it was not applied to the subscription yet.
+            // In practice, it is very unlikely that a different broadcast arrives, and then a test
+            // flakes because of the iwlan behavior in the time it takes for the config to be
+            // applied.
+            Log.d(TAG, "Received config change for sub $subIdx")
+            receivedSubIds.add(subIdx)
+        }
+    }
+
+    private fun overrideAllSubscriptions(bundle: PersistableBundle?) {
+        runAsShell(READ_PHONE_STATE, MODIFY_PHONE_STATE) {
+            val receiver = ConfigChangedReceiver()
+            context.registerReceiver(receiver, IntentFilter(ACTION_CARRIER_CONFIG_CHANGED))
+            val subscriptions = context.getSystemService(SubscriptionManager::class.java)
+                .activeSubscriptionInfoList
+            subscriptions?.forEach { subInfo ->
+                Log.d(TAG, "Overriding config for subscription $subInfo")
+                carrierConfigManager.overrideConfig(subInfo.subscriptionId, bundle)
+            }
+            // Don't wait after each update before the next one, but expect all updates to be done
+            // eventually
+            subscriptions?.forEach { subInfo ->
+                assertNotNull(receiver.receivedSubIds.poll(CONFIG_CHANGE_TIMEOUT_MS, pos = 0) {
+                    it == subInfo.subscriptionId
+                }, "Config override broadcast not received for subscription $subInfo")
+            }
+        }
+    }
+
+    private fun shouldDisableIwlan(): Boolean {
+        // IWLAN on U 24Q2 release (U QPR3) causes cell data to reconnect when Wi-Fi is toggled due
+        // to the implementation of the force_iwlan_mms feature, which does not work well with
+        // multinetworking tests. Disable the feature on such builds (b/368477391).
+        // The behavior changed in more recent releases (V) so only U 24Q2 is affected.
+        return pm.hasSystemFeature(FEATURE_TELEPHONY_IMS) && pm.hasSystemFeature(FEATURE_WIFI) &&
+                Build.VERSION.SDK_INT == UPSIDE_DOWN_CAKE &&
+                isForceIwlanMmsEnabled()
+    }
+
+    private fun isForceIwlanMmsEnabled(): Boolean {
+        val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+        val flagEnabledRegex = Regex(
+            """telephony/com\.android\.internal\.telephony\.flags\.force_iwlan_mms:""" +
+                    """.*ENABLED \(system\)""")
+        ParcelFileDescriptor.AutoCloseInputStream(
+            uiAutomation.executeShellCommand("printflags")).bufferedReader().use { reader ->
+                return reader.lines().anyMatch {
+                    it.contains(flagEnabledRegex)
+                }
+        }
+    }
+}
\ No newline at end of file
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
index 785e55a..044b410 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DeviceConfigRule.kt
@@ -18,6 +18,7 @@
 
 import android.Manifest.permission.READ_DEVICE_CONFIG
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.provider.DeviceConfig
 import android.util.Log
 import com.android.modules.utils.build.SdkLevel
@@ -87,7 +88,7 @@
                     }
                     throw e
                 } cleanupStep {
-                    runAsShell(WRITE_DEVICE_CONFIG) {
+                    runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
                         originalConfig.forEach { (key, value) ->
                             Log.i(TAG, "Resetting config \"${key.second}\" to \"$value\"")
                             DeviceConfig.setProperty(
@@ -116,7 +117,8 @@
      */
     fun setConfig(namespace: String, key: String, value: String?): String? {
         Log.i(TAG, "Setting config \"$key\" to \"$value\"")
-        val readWritePermissions = arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG)
+        val readWritePermissions =
+            arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG)
 
         val keyPair = Pair(namespace, key)
         val existingValue = runAsShell(*readWritePermissions) {
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
index d5e91c2..7b970d3 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/SetFeatureFlagsRule.kt
@@ -57,6 +57,7 @@
      * @param enabled The desired state (true for enabled, false for disabled) of the feature flag.
      */
     @Target(AnnotationTarget.FUNCTION)
+    @Repeatable
     @Retention(AnnotationRetention.RUNTIME)
     annotation class FeatureFlag(val name: String, val enabled: Boolean = true)
 
diff --git a/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
index 435fdd8..f6168af 100644
--- a/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
+++ b/staticlibs/testutils/host/java/com/android/testutils/ConnectivityTestTargetPreparer.kt
@@ -28,6 +28,7 @@
 private const val CONNECTIVITY_CHECKER_APK = "ConnectivityTestPreparer.apk"
 private const val CONNECTIVITY_PKG_NAME = "com.android.testutils.connectivitypreparer"
 private const val CONNECTIVITY_CHECK_CLASS = "$CONNECTIVITY_PKG_NAME.ConnectivityCheckTest"
+private const val CARRIER_CONFIG_SETUP_CLASS = "$CONNECTIVITY_PKG_NAME.CarrierConfigSetupTest"
 
 // As per the <instrumentation> defined in the checker manifest
 private const val CONNECTIVITY_CHECK_RUNNER_NAME = "androidx.test.runner.AndroidJUnitRunner"
@@ -84,27 +85,28 @@
         installer.setShouldGrantPermission(true)
         installer.setUp(testInfo)
 
-        val testMethods = mutableListOf<String>()
+        val testMethods = mutableListOf<Pair<String, String>>()
         if (!ignoreWifiCheck) {
-            testMethods.add("testCheckWifiSetup")
+            testMethods.add(CONNECTIVITY_CHECK_CLASS to "testCheckWifiSetup")
         }
         if (!ignoreMobileDataCheck) {
-            testMethods.add("testCheckTelephonySetup")
+            testMethods.add(CARRIER_CONFIG_SETUP_CLASS to "testSetCarrierConfig")
+            testMethods.add(CONNECTIVITY_CHECK_CLASS to "testCheckTelephonySetup")
         }
 
         testMethods.forEach {
-            runTestMethod(testInfo, it)
+            runTestMethod(testInfo, it.first, it.second)
         }
     }
 
-    private fun runTestMethod(testInfo: TestInformation, method: String) {
+    private fun runTestMethod(testInfo: TestInformation, clazz: String, method: String) {
         val runner = DefaultRemoteAndroidTestRunner(
             CONNECTIVITY_PKG_NAME,
             CONNECTIVITY_CHECK_RUNNER_NAME,
             testInfo.device.iDevice
         )
         runner.runOptions = "--no-hidden-api-checks"
-        runner.setMethodName(CONNECTIVITY_CHECK_CLASS, method)
+        runner.setMethodName(clazz, method)
 
         val receiver = CollectingTestListener()
         if (!testInfo.device.runInstrumentationTests(runner, receiver)) {
@@ -187,6 +189,9 @@
 
     override fun tearDown(testInfo: TestInformation, e: Throwable?) {
         if (isTearDownDisabled) return
+        if (!ignoreMobileDataCheck) {
+            runTestMethod(testInfo, CARRIER_CONFIG_SETUP_CLASS, "testClearCarrierConfig")
+        }
         installer.tearDown(testInfo, e)
         setUpdaterNetworkingEnabled(
             testInfo,
diff --git a/tests/cts/hostside/aidl/Android.bp b/tests/cts/hostside/aidl/Android.bp
index 31924f0..ca3e77f 100644
--- a/tests/cts/hostside/aidl/Android.bp
+++ b/tests/cts/hostside/aidl/Android.bp
@@ -19,8 +19,7 @@
 
 java_test_helper_library {
     name: "CtsHostsideNetworkTestsAidl",
-    sdk_version: "current",
-    min_sdk_version: "30",
+    sdk_version: "system_current",
     srcs: [
         "com/android/cts/net/hostside/*.aidl",
         "com/android/cts/net/hostside/*.java",
diff --git a/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl b/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl
new file mode 100644
index 0000000..a9f5ed4
--- /dev/null
+++ b/tests/cts/hostside/aidl/com/android/cts/net/hostside/ITetheringHelper.aidl
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.cts.net.hostside;
+
+import android.net.TetheringInterface;
+
+interface ITetheringHelper {
+    TetheringInterface getTetheredWifiInterface();
+}
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java
new file mode 100644
index 0000000..5f5ebb0
--- /dev/null
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringHelperClient.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.cts.net.hostside;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.TetheringInterface;
+import android.os.ConditionVariable;
+import android.os.IBinder;
+import android.os.RemoteException;
+
+public class TetheringHelperClient {
+    private static final int TIMEOUT_MS = 5000;
+    private static final String PACKAGE = TetheringHelperClient.class.getPackage().getName();
+    private static final String APP2_PACKAGE = PACKAGE + ".app2";
+    private static final String SERVICE_NAME = APP2_PACKAGE + ".TetheringHelperService";
+
+    private Context mContext;
+    private ServiceConnection mServiceConnection;
+    private ITetheringHelper mService;
+
+    public TetheringHelperClient(Context context) {
+        mContext = context;
+    }
+
+    /**
+     * Binds to TetheringHelperService.
+     */
+    public void bind() {
+        if (mService != null) {
+            throw new IllegalStateException("Already bound");
+        }
+
+        final ConditionVariable cv = new ConditionVariable();
+        mServiceConnection = new ServiceConnection() {
+            @Override
+            public void onServiceConnected(ComponentName name, IBinder service) {
+                mService = ITetheringHelper.Stub.asInterface(service);
+                cv.open();
+            }
+            @Override
+            public void onServiceDisconnected(ComponentName name) {
+                mService = null;
+            }
+        };
+
+        final Intent intent = new Intent();
+        intent.setComponent(new ComponentName(APP2_PACKAGE, SERVICE_NAME));
+        mContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
+        cv.block(TIMEOUT_MS);
+        if (mService == null) {
+            throw new IllegalStateException(
+                    "Could not bind to TetheringHelperService after " + TIMEOUT_MS + "ms");
+        }
+    }
+
+    /**
+     * Unbinds from TetheringHelperService.
+     */
+    public void unbind() {
+        if (mService != null) {
+            mContext.unbindService(mServiceConnection);
+        }
+    }
+
+    /**
+     * Returns the tethered Wifi interface as seen from TetheringHelperService.
+     */
+    public TetheringInterface getTetheredWifiInterface() throws RemoteException {
+        return mService.getTetheredWifiInterface();
+    }
+}
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
new file mode 100644
index 0000000..ad98a29
--- /dev/null
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/TetheringTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.cts.net.hostside;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import android.content.Context;
+import android.net.TetheringInterface;
+import android.net.cts.util.CtsTetheringUtils;
+import android.net.wifi.SoftApConfiguration;
+import android.net.wifi.WifiSsid;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+public class TetheringTest {
+    private CtsTetheringUtils mCtsTetheringUtils;
+    private TetheringHelperClient mTetheringHelperClient;
+
+    @Before
+    public void setUp() throws Exception {
+        Context targetContext = getInstrumentation().getTargetContext();
+        mCtsTetheringUtils = new CtsTetheringUtils(targetContext);
+        mTetheringHelperClient = new TetheringHelperClient(targetContext);
+        mTetheringHelperClient.bind();
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mTetheringHelperClient.unbind();
+    }
+
+    /**
+     * Starts Wifi tethering and tests that the SoftApConfiguration is redacted from
+     * TetheringEventCallback for other apps.
+     */
+    @Test
+    public void testSoftApConfigurationRedactedForOtherUids() throws Exception {
+        final CtsTetheringUtils.TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setWifiSsid(WifiSsid.fromBytes("This is an SSID!"
+                        .getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringInterface tetheringInterface =
+                mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+        assertNotNull(tetheringInterface);
+        assertEquals(softApConfig, tetheringInterface.getSoftApConfiguration());
+        try {
+            TetheringInterface tetheringInterfaceForApp2 =
+                    mTetheringHelperClient.getTetheredWifiInterface();
+            assertNotNull(tetheringInterfaceForApp2);
+            assertNull(tetheringInterfaceForApp2.getSoftApConfiguration());
+            assertEquals(
+                    tetheringInterface.getInterface(), tetheringInterfaceForApp2.getInterface());
+        } finally {
+            mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
+        }
+    }
+}
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index d05a8d0..3430196 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -20,6 +20,7 @@
 import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG;
 import static android.content.Context.RECEIVER_EXPORTED;
 import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
@@ -1209,7 +1210,7 @@
                     AUTOMATIC_ON_OFF_KEEPALIVE_VERSION,
                     AUTOMATIC_ON_OFF_KEEPALIVE_ENABLED, false /* makeDefault */);
             return mode;
-        }, READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG);
+        }, READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG);
 
         final IpSecManager ipSec = mTargetContext.getSystemService(IpSecManager.class);
         SocketKeepalive kp = null;
@@ -1249,7 +1250,7 @@
                                 AUTOMATIC_ON_OFF_KEEPALIVE_VERSION,
                                 origMode, false);
                 mCM.setTestLowTcpPollingTimerForKeepalive(0);
-            }, WRITE_DEVICE_CONFIG, NETWORK_SETTINGS);
+            }, WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG, NETWORK_SETTINGS);
         }
     }
 
diff --git a/tests/cts/hostside/app2/AndroidManifest.xml b/tests/cts/hostside/app2/AndroidManifest.xml
index 412b307..6ccaf4f 100644
--- a/tests/cts/hostside/app2/AndroidManifest.xml
+++ b/tests/cts/hostside/app2/AndroidManifest.xml
@@ -42,6 +42,8 @@
             android:debuggable="true">
         <service android:name=".RemoteSocketFactoryService"
              android:exported="true"/>
+        <service android:name=".TetheringHelperService"
+             android:exported="true"/>
     </application>
 
     <!--
diff --git a/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java
new file mode 100644
index 0000000..56a8cbb
--- /dev/null
+++ b/tests/cts/hostside/app2/src/com/android/cts/net/hostside/app2/TetheringHelperService.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.cts.net.hostside.app2;
+
+
+import static android.net.TetheringManager.TETHERING_WIFI;
+
+import android.app.Service;
+import android.content.Intent;
+import android.net.TetheringInterface;
+import android.net.TetheringManager;
+import android.os.IBinder;
+
+import androidx.annotation.NonNull;
+
+import com.android.cts.net.hostside.ITetheringHelper;
+
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+public class TetheringHelperService extends Service {
+    private static final String TAG = TetheringHelperService.class.getSimpleName();
+
+    private ITetheringHelper.Stub mBinder = new ITetheringHelper.Stub() {
+        public TetheringInterface getTetheredWifiInterface() {
+            ArrayBlockingQueue<TetheringInterface> queue = new ArrayBlockingQueue<>(1);
+            TetheringManager.TetheringEventCallback callback =
+                    new TetheringManager.TetheringEventCallback() {
+                        @Override
+                        public void onTetheredInterfacesChanged(
+                                @NonNull Set<TetheringInterface> interfaces) {
+                            for (TetheringInterface iface : interfaces) {
+                                if (iface.getType() == TETHERING_WIFI) {
+                                    queue.offer(iface);
+                                    break;
+                                }
+                            }
+                        }
+                    };
+            TetheringManager tm =
+                    TetheringHelperService.this.getSystemService(TetheringManager.class);
+            tm.registerTetheringEventCallback(Runnable::run, callback);
+            TetheringInterface iface;
+            try {
+                iface = queue.poll(5, TimeUnit.SECONDS);
+            } catch (InterruptedException e) {
+                throw new IllegalStateException("Wait for wifi TetheredInterface interrupted");
+            } finally {
+                tm.unregisterTetheringEventCallback(callback);
+            }
+            if (iface == null) {
+                throw new IllegalStateException(
+                        "No wifi TetheredInterface received after 5 seconds");
+            }
+            return iface;
+        }
+    };
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return mBinder;
+    }
+}
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java b/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java
new file mode 100644
index 0000000..d73e01a
--- /dev/null
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideTetheringTests.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.cts.net;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
+import com.android.tradefed.testtype.junit4.AfterClassWithInfo;
+import com.android.tradefed.testtype.junit4.BeforeClassWithInfo;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(DeviceJUnit4ClassRunner.class)
+public class HostsideTetheringTests extends HostsideNetworkTestCase {
+    /**
+     * Set up the test once before running all the tests.
+     */
+    @BeforeClassWithInfo
+    public static void setUpOnce(TestInformation testInfo) throws Exception {
+        uninstallPackage(testInfo, TEST_APP2_PKG, false);
+        installPackage(testInfo, TEST_APP2_APK);
+    }
+
+    /**
+     * Tear down the test once after running all the tests.
+     */
+    @AfterClassWithInfo
+    public static void tearDownOnce(TestInformation testInfo)
+            throws DeviceNotAvailableException {
+        uninstallPackage(testInfo, TEST_APP2_PKG, true);
+    }
+
+    @Test
+    public void testSoftApConfigurationRedactedForOtherApps() throws Exception {
+        runDeviceTests(TEST_PKG, TEST_PKG + ".TetheringTest",
+                "testSoftApConfigurationRedactedForOtherUids");
+    }
+}
diff --git a/tests/cts/net/Android.bp b/tests/cts/net/Android.bp
index a5ad7f2..a9ac29c 100644
--- a/tests/cts/net/Android.bp
+++ b/tests/cts/net/Android.bp
@@ -70,6 +70,14 @@
         ":ConnectivityTestPreparer",
         ":CtsCarrierServicePackage",
     ],
+    errorprone: {
+        enabled: true,
+        // Error-prone checking only warns of problems when building. To make the build fail with
+        // these errors, list the specific error-prone problems below.
+        javacflags: [
+            "-Xep:NullablePrimitive:ERROR",
+        ],
+    },
 }
 
 // Networking CTS tests for development and release. These tests always target the platform SDK
@@ -88,14 +96,8 @@
     ],
     test_suites: [
         "cts",
-        "mts-dnsresolver",
-        "mts-networking",
         "mts-tethering",
-        "mts-wifi",
-        "mcts-dnsresolver",
-        "mcts-networking",
         "mcts-tethering",
-        "mcts-wifi",
         "general-tests",
     ],
 }
diff --git a/tests/cts/net/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index 7590a2b..55b6494 100644
--- a/tests/cts/net/AndroidTestTemplate.xml
+++ b/tests/cts/net/AndroidTestTemplate.xml
@@ -19,6 +19,7 @@
     <option name="config-descriptor:metadata" key="parameter" value="instant_app" />
     <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
     <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user_on_secondary_display" />
 
     <option name="config-descriptor:metadata" key="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex" />
     <option name="config-descriptor:metadata" key="mainline-param" value="CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk" />
@@ -60,8 +61,7 @@
     </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="log-data-type" value="CONNDIAG" />
+        <option name="pull-pattern-keys" value="com.android.testutils.ConnectivityDiagnosticsCollector.*"/>
         <option name="collect-on-run-ended-only" value="true" />
     </metrics_collector>
     <!-- When this test is run in a Mainline context (e.g. with `mts-tradefed`), only enable it if
diff --git a/tests/cts/net/native/dns/Android.bp b/tests/cts/net/native/dns/Android.bp
index de4a3bf..8138598 100644
--- a/tests/cts/net/native/dns/Android.bp
+++ b/tests/cts/net/native/dns/Android.bp
@@ -62,8 +62,6 @@
         "cts",
         "general-tests",
         "mts-dnsresolver",
-        "mts-networking",
         "mcts-dnsresolver",
-        "mcts-networking",
     ],
 }
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index 1e2a212..9be579b 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -145,6 +145,8 @@
 
     private var tetheredInterfaceRequest: TetheredInterfaceRequest? = null
 
+    private var ethernetEnabled = true
+
     private class EthernetTestInterface(
         context: Context,
         private val handler: Handler,
@@ -428,7 +430,7 @@
 
         // when an interface comes up, we should always see a down cb before an up cb.
         ifaceListener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
-        if (hasCarrier) {
+        if (hasCarrier && ethernetEnabled) {
             ifaceListener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
         }
         return iface
@@ -514,6 +516,7 @@
     private fun setEthernetEnabled(enabled: Boolean) {
         runAsShell(NETWORK_SETTINGS) { em.setEthernetEnabled(enabled) }
 
+        ethernetEnabled = enabled
         val listener = EthernetStateListener()
         addEthernetStateListener(listener)
         listener.eventuallyExpect(if (enabled) ETHERNET_STATE_ENABLED else ETHERNET_STATE_DISABLED)
@@ -600,26 +603,6 @@
         }
     }
 
-    @Test
-    fun testCallbacks_withRunningInterface() {
-        assumeFalse(isAdbOverEthernet())
-        // Only run this test when no non-restricted / physical interfaces are present.
-        assumeNoInterfaceForTetheringAvailable()
-
-        val iface = createInterface()
-        val listener = EthernetStateListener()
-        addInterfaceStateListener(listener)
-        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
-
-        // Remove running interface. The interface stays running but is no longer tracked.
-        setEthernetEnabled(false)
-        listener.expectCallback(iface, STATE_ABSENT, ROLE_NONE)
-
-        setEthernetEnabled(true)
-        listener.expectCallback(iface, STATE_LINK_UP, ROLE_CLIENT)
-        listener.assertNoCallback()
-    }
-
     private fun assumeNoInterfaceForTetheringAvailable() {
         // Interfaces that have configured NetworkCapabilities will never be used for tethering,
         // see aosp/2123900.
@@ -911,6 +894,30 @@
     }
 
     @Test
+    fun testEnableDisableInterface_disableEnableEthernet() {
+        val iface = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+
+        // When ethernet is disabled, interface should be down and enable/disableInterface()
+        // should not bring the interfaces up.
+        setEthernetEnabled(false)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectError()
+        disableInterface(iface).expectError()
+        listener.assertNoCallback()
+
+        // When ethernet is enabled, enable/disableInterface() should succeed.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+        disableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+        enableInterface(iface).expectResult(iface.name)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+    @Test
     fun testUpdateConfiguration_forBothIpConfigAndCapabilities() {
         val iface = createInterface()
         val cb = requestNetwork(ETH_REQUEST.copyWithEthernetSpecifier(iface.name))
@@ -1018,4 +1025,68 @@
         cb.eventuallyExpectCapabilities(TEST_CAPS)
         cb.eventuallyExpectLpForStaticConfig(STATIC_IP_CONFIGURATION.staticIpConfiguration)
     }
+
+    @Test
+    fun testAddInterface_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        // When ethernet is disabled, newly added interfaces should not be brought up.
+        setEthernetEnabled(false)
+        val iface = createInterface(/* hasCarrier */ true)
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled after interface is added, it will be brought up.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
+
+
+    @Test
+    fun testRemoveInterface_disableEnableEthernet() {
+        // Set up 2 interfaces for testing
+        val iface1 = createInterface()
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+        listener.eventuallyExpect(iface1, STATE_LINK_UP, ROLE_CLIENT)
+        val iface2 = createInterface()
+        listener.eventuallyExpect(iface2, STATE_LINK_UP, ROLE_CLIENT)
+
+        // Removing interfaces when ethernet is enabled will first send link down, then
+        // STATE_ABSENT/ROLE_NONE.
+        removeInterface(iface1)
+        listener.expectCallback(iface1, STATE_LINK_DOWN, ROLE_CLIENT)
+        listener.expectCallback(iface1, STATE_ABSENT, ROLE_NONE)
+
+        // Removing interfaces after ethernet is disabled will first send link down when ethernet is
+        // disabled, then STATE_ABSENT/ROLE_NONE when interface is removed.
+        setEthernetEnabled(false)
+        listener.expectCallback(iface2, STATE_LINK_DOWN, ROLE_CLIENT)
+        removeInterface(iface2)
+        listener.expectCallback(iface2, STATE_ABSENT, ROLE_NONE)
+    }
+
+    @Test
+    fun testSetTetheringInterfaceMode_disableEnableEthernet() {
+        val listener = EthernetStateListener()
+        addInterfaceStateListener(listener)
+
+        val iface = createInterface()
+        requestTetheredInterface().expectOnAvailable()
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_SERVER)
+
+        // (b/234743836): Currently the state of server mode interfaces always returns true due to
+        // that interface state for server mode interfaces is not tracked properly.
+        // So we do not get any state change when disabling ethernet.
+        setEthernetEnabled(false)
+        listener.assertNoCallback()
+
+        // When ethernet is disabled, change interface mode will not bring the interface up.
+        releaseTetheredInterface()
+        listener.eventuallyExpect(iface, STATE_LINK_DOWN, ROLE_CLIENT)
+
+        // When ethernet is re-enabled, interface will be brought up.
+        setEthernetEnabled(true)
+        listener.eventuallyExpect(iface, STATE_LINK_UP, ROLE_CLIENT)
+    }
 }
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 60081d4..815c3a5 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -83,13 +83,17 @@
 import android.os.ConditionVariable
 import android.os.Handler
 import android.os.HandlerThread
+import android.os.Looper
 import android.os.Message
 import android.os.PersistableBundle
 import android.os.Process
 import android.os.SystemClock
 import android.platform.test.annotations.AppModeFull
+import android.system.Os
+import android.system.OsConstants.AF_INET6
 import android.system.OsConstants.IPPROTO_TCP
 import android.system.OsConstants.IPPROTO_UDP
+import android.system.OsConstants.SOCK_DGRAM
 import android.telephony.CarrierConfigManager
 import android.telephony.SubscriptionManager
 import android.telephony.TelephonyManager
@@ -105,6 +109,10 @@
 import com.android.compatibility.common.util.UiccUtil
 import com.android.modules.utils.build.SdkLevel
 import com.android.net.module.util.ArrayTrackRecord
+import com.android.net.module.util.NetworkStackConstants.ETHER_MTU
+import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
+import com.android.net.module.util.NetworkStackConstants.IPV6_PROTOCOL_OFFSET
+import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
 import com.android.testutils.CompatUtil
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
@@ -115,6 +123,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Losing
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAddKeepalivePacketFilter
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnAutomaticReconnectDisabled
@@ -133,6 +142,7 @@
 import com.android.testutils.assertThrows
 import com.android.testutils.runAsShell
 import com.android.testutils.tryTest
+import com.android.testutils.waitForIdle
 import java.io.Closeable
 import java.io.IOException
 import java.net.DatagramSocket
@@ -140,10 +150,13 @@
 import java.net.InetSocketAddress
 import java.net.Socket
 import java.security.MessageDigest
+import java.nio.ByteBuffer
 import java.time.Duration
 import java.util.Arrays
+import java.util.Random
 import java.util.UUID
 import java.util.concurrent.Executors
+import kotlin.collections.ArrayList
 import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertFalse
@@ -188,6 +201,11 @@
     it.obj = obj
 }
 
+private val LINK_ADDRESS = LinkAddress("2001:db8::1/64")
+private val REMOTE_ADDRESS = InetAddresses.parseNumericAddress("2001:db8::123")
+private val PREFIX = IpPrefix("2001:db8::/64")
+private val NEXTHOP = InetAddresses.parseNumericAddress("fe80::abcd")
+
 // On T and below, the native network is only created when the agent connects.
 // Starting in U, the native network was to be created as soon as the agent is registered,
 // but this has been flagged off for now pending resolution of race conditions.
@@ -321,6 +339,15 @@
         if (transports.size > 0) removeCapability(NET_CAPABILITY_NOT_RESTRICTED)
     }
 
+    private fun makeTestLinkProperties(ifName: String): LinkProperties {
+        return LinkProperties().apply {
+            interfaceName = ifName
+            addLinkAddress(LINK_ADDRESS)
+            addRoute(RouteInfo(PREFIX, null /* nextHop */, ifName))
+            addRoute(RouteInfo(IpPrefix("::/0"), NEXTHOP, ifName))
+        }
+    }
+
     private fun createNetworkAgent(
         context: Context = realContext,
         specifier: String? = null,
@@ -341,6 +368,7 @@
 
     private fun createConnectedNetworkAgent(
         context: Context = realContext,
+        lp: LinkProperties? = null,
         specifier: String? = UUID.randomUUID().toString(),
         initialConfig: NetworkAgentConfig? = null,
         expectedInitSignalStrengthThresholds: IntArray = intArrayOf(),
@@ -350,7 +378,8 @@
         // Ensure this NetworkAgent is never unneeded by filing a request with its specifier.
         requestNetwork(makeTestNetworkRequest(specifier), callback)
         val nc = makeTestNetworkCapabilities(specifier, transports)
-        val agent = createNetworkAgent(context, initialConfig = initialConfig, initialNc = nc)
+        val agent = createNetworkAgent(context, initialConfig = initialConfig, initialLp = lp,
+            initialNc = nc)
         agent.setTeardownDelayMillis(0)
         // Connect the agent and verify initial status callbacks.
         agent.register()
@@ -361,8 +390,9 @@
         return agent to callback
     }
 
-    private fun connectNetwork(vararg transports: Int): Pair<TestableNetworkAgent, Network> {
-        val (agent, callback) = createConnectedNetworkAgent(transports = transports)
+    private fun connectNetwork(vararg transports: Int, lp: LinkProperties? = null):
+            Pair<TestableNetworkAgent, Network> {
+        val (agent, callback) = createConnectedNetworkAgent(transports = transports, lp = lp)
         val network = agent.network!!
         // createConnectedNetworkAgent internally files a request; release it so that the network
         // will be torn down if unneeded.
@@ -382,8 +412,9 @@
         assertNoCallback()
     }
 
-    private fun createTunInterface(): TestNetworkInterface = realContext.getSystemService(
-                TestNetworkManager::class.java)!!.createTunInterface(emptyList()).also {
+    private fun createTunInterface(addrs: Collection<LinkAddress> = emptyList()):
+            TestNetworkInterface = realContext.getSystemService(
+                TestNetworkManager::class.java)!!.createTunInterface(addrs).also {
             ifacesToCleanUp.add(it)
     }
 
@@ -1501,15 +1532,75 @@
 
     private fun createEpsAttributes(qci: Int = 1): EpsBearerQosSessionAttributes {
         val remoteAddresses = ArrayList<InetSocketAddress>()
-        remoteAddresses.add(InetSocketAddress("2001:db8::123", 80))
+        remoteAddresses.add(InetSocketAddress(REMOTE_ADDRESS, 80))
         return EpsBearerQosSessionAttributes(
                 qci, 2, 3, 4, 5,
                 remoteAddresses
         )
     }
 
+    fun sendAndExpectUdpPacket(net: Network,
+                               reader: PollPacketReader, iface: TestNetworkInterface) {
+        val s = Os.socket(AF_INET6, SOCK_DGRAM, 0)
+        net.bindSocket(s)
+        val content = ByteArray(16)
+        Random().nextBytes(content)
+        Os.sendto(s, ByteBuffer.wrap(content), 0, REMOTE_ADDRESS, 7 /* port */)
+        val match = reader.poll(DEFAULT_TIMEOUT_MS) {
+            val udpStart = IPV6_HEADER_LEN + UDP_HEADER_LEN
+            it.size == udpStart + content.size &&
+                    it[0].toInt() and 0xf0 == 0x60 &&
+                    it[IPV6_PROTOCOL_OFFSET].toInt() == IPPROTO_UDP &&
+                    Arrays.equals(content, it.copyOfRange(udpStart, udpStart + content.size))
+        }
+        assertNotNull(match, "Did not receive matching packet on ${iface.interfaceName} " +
+                " after ${DEFAULT_TIMEOUT_MS}ms")
+    }
+
+    fun createInterfaceAndReader(): Triple<TestNetworkInterface, PollPacketReader, LinkProperties> {
+        val iface = createTunInterface(listOf(LINK_ADDRESS))
+        val handler = Handler(Looper.getMainLooper())
+        val reader = PollPacketReader(handler, iface.fileDescriptor.fileDescriptor, ETHER_MTU)
+        reader.startAsyncForTest()
+        handler.waitForIdle(DEFAULT_TIMEOUT_MS)
+        val ifName = iface.interfaceName
+        val lp = makeTestLinkProperties(ifName)
+        return Triple(iface, reader, lp)
+    }
+
+    @Test
+    fun testRegisterAfterUnregister() {
+        val (iface, reader, lp) = createInterfaceAndReader()
+
+        // File a request that matches and keeps up the best-scoring test network.
+        val testCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
+        requestNetwork(makeTestNetworkRequest(), testCallback)
+
+        // Register and unregister networkagents in a loop, checking that every time an agent
+        // connects, the native network is correctly configured and packets can be sent.
+        // Running 10 iterations takes about 1 second on x86 cuttlefish, and detects the race in
+        // b/286649301 most of the time.
+        for (i in 1..10) {
+            val agent1 = createNetworkAgent(realContext, initialLp = lp)
+            agent1.register()
+            agent1.unregister()
+
+            val agent2 = createNetworkAgent(realContext, initialLp = lp)
+            agent2.register()
+            agent2.markConnected()
+            val network2 = agent2.network!!
+
+            testCallback.expectAvailableThenValidatedCallbacks(network2)
+            sendAndExpectUdpPacket(network2, reader, iface)
+            agent2.unregister()
+            testCallback.expect<Lost>(network2)
+        }
+    }
+
     @Test
     fun testUnregisterAfterReplacement() {
+        val (iface, reader, lp) = createInterfaceAndReader()
+
         // Keeps an eye on all test networks.
         val matchAllCallback = TestableNetworkCallback(timeoutMs = DEFAULT_TIMEOUT_MS)
         registerNetworkCallback(makeTestNetworkRequest(), matchAllCallback)
@@ -1519,14 +1610,13 @@
         requestNetwork(makeTestNetworkRequest(), testCallback)
 
         // Connect the first network. This should satisfy the request.
-        val (agent1, network1) = connectNetwork()
+        val (agent1, network1) = connectNetwork(lp = lp)
         matchAllCallback.expectAvailableThenValidatedCallbacks(network1)
         testCallback.expectAvailableThenValidatedCallbacks(network1)
-        // Check that network1 exists by binding a socket to it and getting no exceptions.
-        network1.bindSocket(DatagramSocket())
+        sendAndExpectUdpPacket(network1, reader, iface)
 
         // Connect a second agent. network1 is preferred because it was already registered, so
-        // testCallback will not see any events. agent2 is be torn down because it has no requests.
+        // testCallback will not see any events. agent2 is torn down because it has no requests.
         val (agent2, network2) = connectNetwork()
         matchAllCallback.expectAvailableThenValidatedCallbacks(network2)
         matchAllCallback.expect<Lost>(network2)
@@ -1551,9 +1641,10 @@
         // as soon as it validates (until then, it is outscored by network1).
         // The fact that the first events seen by matchAllCallback is the connection of network3
         // implicitly ensures that no callbacks are sent since network1 was lost.
-        val (agent3, network3) = connectNetwork()
+        val (agent3, network3) = connectNetwork(lp = lp)
         matchAllCallback.expectAvailableThenValidatedCallbacks(network3)
         testCallback.expectAvailableDoubleValidatedCallbacks(network3)
+        sendAndExpectUdpPacket(network3, reader, iface)
 
         // As soon as the replacement arrives, network1 is disconnected.
         // Check that this happens before the replacement timeout (5 seconds) fires.
@@ -1573,6 +1664,7 @@
         matchAllCallback.expect<Losing>(network3)
         testCallback.expectAvailableCallbacks(network4, validated = true)
         mCM.unregisterNetworkCallback(agent4callback)
+        sendAndExpectUdpPacket(network3, reader, iface)
         agent3.unregisterAfterReplacement(5_000)
         agent3.expectCallback<OnNetworkUnwanted>()
         matchAllCallback.expect<Lost>(network3, 1000L)
@@ -1588,9 +1680,10 @@
 
         // If a network that is awaiting replacement is unregistered, it disconnects immediately,
         // before the replacement timeout fires.
-        val (agent5, network5) = connectNetwork()
+        val (agent5, network5) = connectNetwork(lp = lp)
         matchAllCallback.expectAvailableThenValidatedCallbacks(network5)
         testCallback.expectAvailableThenValidatedCallbacks(network5)
+        sendAndExpectUdpPacket(network5, reader, iface)
         agent5.unregisterAfterReplacement(5_000 /* timeoutMillis */)
         agent5.unregister()
         matchAllCallback.expect<Lost>(network5, 1000L /* timeoutMs */)
@@ -1637,7 +1730,7 @@
         matchAllCallback.assertNoCallback(200 /* timeoutMs */)
 
         // If wifi is replaced within the timeout, the device does not switch to cellular.
-        val (_, cellNetwork) = connectNetwork(TRANSPORT_CELLULAR)
+        val (cellAgent, cellNetwork) = connectNetwork(TRANSPORT_CELLULAR)
         testCallback.expectAvailableThenValidatedCallbacks(cellNetwork)
         matchAllCallback.expectAvailableThenValidatedCallbacks(cellNetwork)
 
@@ -1674,6 +1767,34 @@
         matchAllCallback.expectAvailableThenValidatedCallbacks(newWifiNetwork)
         matchAllCallback.expect<Lost>(wifiNetwork)
         wifiAgent.expectCallback<OnNetworkUnwanted>()
+        testCallback.expect<CapabilitiesChanged>(newWifiNetwork)
+
+        cellAgent.unregister()
+        matchAllCallback.expect<Lost>(cellNetwork)
+        newWifiAgent.unregister()
+        matchAllCallback.expect<Lost>(newWifiNetwork)
+        testCallback.expect<Lost>(newWifiNetwork)
+
+        // Calling unregisterAfterReplacement several times in quick succession works.
+        // These networks are all kept up by testCallback.
+        val agent10 = createNetworkAgent(realContext, initialLp = lp)
+        agent10.register()
+        agent10.unregisterAfterReplacement(5_000)
+
+        val agent11 = createNetworkAgent(realContext, initialLp = lp)
+        agent11.register()
+        agent11.unregisterAfterReplacement(5_000)
+
+        val agent12 = createNetworkAgent(realContext, initialLp = lp)
+        agent12.register()
+        agent12.unregisterAfterReplacement(5_000)
+
+        val agent13 = createNetworkAgent(realContext, initialLp = lp)
+        agent13.register()
+        agent13.markConnected()
+        testCallback.expectAvailableThenValidatedCallbacks(agent13.network!!)
+        sendAndExpectUdpPacket(agent13.network!!, reader, iface)
+        agent13.unregister()
     }
 
     @Test
@@ -1706,14 +1827,7 @@
                 it.underlyingNetworks = listOf()
             }
         }
-        val lp = LinkProperties().apply {
-            interfaceName = ifName
-            addLinkAddress(LinkAddress("2001:db8::1/64"))
-            addRoute(RouteInfo(IpPrefix("2001:db8::/64"), null /* nextHop */, ifName))
-            addRoute(RouteInfo(IpPrefix("::/0"),
-                    InetAddresses.parseNumericAddress("fe80::abcd"),
-                    ifName))
-        }
+        val lp = makeTestLinkProperties(ifName)
 
         // File a request containing the agent's specifier to receive callbacks and to ensure that
         // the agent is not torn down due to being unneeded.
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
index 1a48983..10adee0 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsBinderTest.java
@@ -19,28 +19,28 @@
 import static android.os.Process.INVALID_UID;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.content.Context;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.net.INetworkStatsService;
 import android.net.TrafficStats;
+import android.net.connectivity.android.net.netstats.StatsResult;
 import android.os.Build;
 import android.os.IBinder;
 import android.os.Process;
 import android.os.RemoteException;
-import android.test.AndroidTestCase;
-import android.util.SparseArray;
 
 import androidx.test.InstrumentationRegistry;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.internal.util.CollectionUtils;
+import com.android.testutils.ConnectivityModuleTest;
 import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRunner;
 
-import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -48,37 +48,20 @@
 import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.function.Function;
 import java.util.function.Predicate;
 
-@RunWith(AndroidJUnit4.class)
+@ConnectivityModuleTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2) // Mainline NetworkStats starts from T.
+@RunWith(DevSdkIgnoreRunner.class)
 public class NetworkStatsBinderTest {
-    // NOTE: These are shamelessly copied from TrafficStats.
-    private static final int TYPE_RX_BYTES = 0;
-    private static final int TYPE_RX_PACKETS = 1;
-    private static final int TYPE_TX_BYTES = 2;
-    private static final int TYPE_TX_PACKETS = 3;
-
-    @Rule
-    public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule(
-            Build.VERSION_CODES.Q /* ignoreClassUpTo */);
-
-    private final SparseArray<Function<Integer, Long>> mUidStatsQueryOpArray = new SparseArray<>();
-
-    @Before
-    public void setUp() throws Exception {
-        mUidStatsQueryOpArray.put(TYPE_RX_BYTES, uid -> TrafficStats.getUidRxBytes(uid));
-        mUidStatsQueryOpArray.put(TYPE_RX_PACKETS, uid -> TrafficStats.getUidRxPackets(uid));
-        mUidStatsQueryOpArray.put(TYPE_TX_BYTES, uid -> TrafficStats.getUidTxBytes(uid));
-        mUidStatsQueryOpArray.put(TYPE_TX_PACKETS, uid -> TrafficStats.getUidTxPackets(uid));
-    }
-
-    private long getUidStatsFromBinder(int uid, int type) throws Exception {
-        Method getServiceMethod = Class.forName("android.os.ServiceManager")
+    @Nullable
+    private StatsResult getUidStatsFromBinder(int uid) throws Exception {
+        final Method getServiceMethod = Class.forName("android.os.ServiceManager")
                 .getDeclaredMethod("getService", new Class[]{String.class});
-        IBinder binder = (IBinder) getServiceMethod.invoke(null, Context.NETWORK_STATS_SERVICE);
-        INetworkStatsService nss = INetworkStatsService.Stub.asInterface(binder);
-        return nss.getUidStats(uid, type);
+        final IBinder binder = (IBinder) getServiceMethod.invoke(
+                null, Context.NETWORK_STATS_SERVICE);
+        final INetworkStatsService nss = INetworkStatsService.Stub.asInterface(binder);
+        return nss.getUidStats(uid);
     }
 
     private int getFirstAppUidThat(@NonNull Predicate<Integer> predicate) {
@@ -108,38 +91,34 @@
         if (notMyUid != INVALID_UID) testUidList.add(notMyUid);
 
         for (final int uid : testUidList) {
-            for (int i = 0; i < mUidStatsQueryOpArray.size(); i++) {
-                final int type = mUidStatsQueryOpArray.keyAt(i);
-                try {
-                    final long uidStatsFromBinder = getUidStatsFromBinder(uid, type);
-                    final long uidTrafficStats = mUidStatsQueryOpArray.get(type).apply(uid);
+            try {
+                final StatsResult uidStatsFromBinder = getUidStatsFromBinder(uid);
 
-                    // Verify that UNSUPPORTED is returned if the uid is not current app uid.
-                    if (uid != myUid) {
-                        assertEquals(uidStatsFromBinder, TrafficStats.UNSUPPORTED);
-                    }
+                if (uid != myUid) {
+                    // Verify that null is returned if the uid is not current app uid.
+                    assertNull(uidStatsFromBinder);
+                } else {
                     // Verify that returned result is the same with the result get from
                     // TrafficStats.
-                    // TODO: If the test is flaky then it should instead assert that the values
-                    //  are approximately similar.
-                    assertEquals("uidStats is not matched for query type " + type
-                                    + ", uid=" + uid + ", myUid=" + myUid, uidTrafficStats,
-                            uidStatsFromBinder);
-                } catch (IllegalAccessException e) {
-                    /* Java language access prevents exploitation. */
-                    return;
-                } catch (InvocationTargetException e) {
-                    /* Underlying method has been changed. */
-                    return;
-                } catch (ClassNotFoundException e) {
-                    /* not vulnerable if hidden API no longer available */
-                    return;
-                } catch (NoSuchMethodException e) {
-                    /* not vulnerable if hidden API no longer available */
-                    return;
-                } catch (RemoteException e) {
-                    return;
+                    assertEquals(uidStatsFromBinder.rxBytes, TrafficStats.getUidRxBytes(uid));
+                    assertEquals(uidStatsFromBinder.rxPackets, TrafficStats.getUidRxPackets(uid));
+                    assertEquals(uidStatsFromBinder.txBytes, TrafficStats.getUidTxBytes(uid));
+                    assertEquals(uidStatsFromBinder.txPackets, TrafficStats.getUidTxPackets(uid));
                 }
+            } catch (IllegalAccessException e) {
+                /* Java language access prevents exploitation. */
+                return;
+            } catch (InvocationTargetException e) {
+                /* Underlying method has been changed. */
+                return;
+            } catch (ClassNotFoundException e) {
+                /* not vulnerable if hidden API no longer available */
+                return;
+            } catch (NoSuchMethodException e) {
+                /* not vulnerable if hidden API no longer available */
+                return;
+            } catch (RemoteException e) {
+                return;
             }
         }
     }
diff --git a/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
index a0b40aa..d3d4f4d 100644
--- a/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkValidationTestUtil.kt
@@ -17,6 +17,7 @@
 package android.net.cts
 
 import android.Manifest.permission.WRITE_DEVICE_CONFIG
+import android.Manifest.permission.WRITE_ALLOWLISTED_DEVICE_CONFIG
 import android.provider.DeviceConfig
 import android.provider.DeviceConfig.NAMESPACE_CONNECTIVITY
 import com.android.net.module.util.NetworkStackConstants
@@ -33,7 +34,7 @@
      * Clear the test network validation URLs.
      */
     @JvmStatic fun clearValidationTestUrlsDeviceConfig() {
-        runAsShell(WRITE_DEVICE_CONFIG) {
+        runAsShell(WRITE_DEVICE_CONFIG, WRITE_ALLOWLISTED_DEVICE_CONFIG) {
             DeviceConfig.setProperty(NAMESPACE_CONNECTIVITY,
                     NetworkStackConstants.TEST_CAPTIVE_PORTAL_HTTPS_URL, null, false)
             DeviceConfig.setProperty(NAMESPACE_CONNECTIVITY,
diff --git a/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
index dffd9d5..243cd27 100644
--- a/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
+++ b/tests/cts/net/util/java/android/net/cts/util/CtsTetheringUtils.java
@@ -46,6 +46,7 @@
 import android.net.TetheringManager.TetheringEventCallback;
 import android.net.TetheringManager.TetheringInterfaceRegexps;
 import android.net.TetheringManager.TetheringRequest;
+import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
@@ -491,13 +492,29 @@
         }
     }
 
+    /**
+     * Starts Wi-Fi tethering.
+     */
     public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback)
             throws InterruptedException {
+        return startWifiTethering(callback, null);
+    }
+
+    /**
+     * Starts Wi-Fi tethering with the specified SoftApConfiguration.
+     */
+    public TetheringInterface startWifiTethering(final TestTetheringEventCallback callback,
+            final SoftApConfiguration softApConfiguration)
+            throws InterruptedException {
         final List<String> wifiRegexs = getWifiTetherableInterfaceRegexps(callback);
 
         final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
-        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
-                .setShouldShowEntitlementUi(false).build();
+        TetheringRequest.Builder builder = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setShouldShowEntitlementUi(false);
+        if (softApConfiguration != null) {
+            builder.setSoftApConfiguration(softApConfiguration);
+        }
+        final TetheringRequest request = builder.build();
 
         return runAsShell(TETHER_PRIVILEGED, () -> {
             mTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
diff --git a/tests/cts/netpermission/internetpermission/Android.bp b/tests/cts/netpermission/internetpermission/Android.bp
index e0424ac..71d2b6e 100644
--- a/tests/cts/netpermission/internetpermission/Android.bp
+++ b/tests/cts/netpermission/internetpermission/Android.bp
@@ -32,4 +32,7 @@
     ],
     host_required: ["net-tests-utils-host-common"],
     sdk_version: "test_current",
+    data: [
+        ":ConnectivityTestPreparer",
+    ],
 }
diff --git a/tests/cts/netpermission/internetpermission/AndroidTest.xml b/tests/cts/netpermission/internetpermission/AndroidTest.xml
index ad9a731..13deb82 100644
--- a/tests/cts/netpermission/internetpermission/AndroidTest.xml
+++ b/tests/cts/netpermission/internetpermission/AndroidTest.xml
@@ -20,6 +20,7 @@
     <option name="config-descriptor:metadata" key="parameter" value="instant_app" />
     <option name="config-descriptor:metadata" key="parameter" value="not_multi_abi" />
     <option name="config-descriptor:metadata" key="parameter" value="secondary_user" />
+    <option name="config-descriptor:metadata" key="parameter" value="secondary_user_on_secondary_display" />
     <option name="not-shardable" value="true" />
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="cleanup-apks" value="true" />
diff --git a/tests/cts/netpermission/updatestatspermission/Android.bp b/tests/cts/netpermission/updatestatspermission/Android.bp
index 689ce74..b324dc8 100644
--- a/tests/cts/netpermission/updatestatspermission/Android.bp
+++ b/tests/cts/netpermission/updatestatspermission/Android.bp
@@ -36,5 +36,6 @@
         "cts",
         "general-tests",
     ],
+    data: [":ConnectivityTestPreparer"],
     host_required: ["net-tests-utils-host-common"],
 }
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index a07c9ea..47d444f 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -22,6 +22,8 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
 import static android.net.TetheringManager.TETHERING_BLUETOOTH;
 import static android.net.TetheringManager.TETHERING_ETHERNET;
 import static android.net.TetheringManager.TETHERING_NCM;
@@ -244,9 +246,16 @@
         assertNull(tr.getClientStaticIpv4Address());
         assertFalse(tr.isExemptFromEntitlementCheck());
         assertTrue(tr.getShouldShowEntitlementUi());
+        assertEquals(CONNECTIVITY_SCOPE_GLOBAL, tr.getConnectivityScope());
         assertEquals(softApConfiguration, tr.getSoftApConfiguration());
         assertEquals(INVALID_UID, tr.getUid());
         assertNull(tr.getPackageName());
+        assertEquals(tr.toString(), "TetheringRequest[ "
+                + "TETHERING_WIFI, "
+                + "showProvisioningUi, "
+                + "CONNECTIVITY_SCOPE_GLOBAL, "
+                + "softApConfig=" + softApConfiguration.toString()
+                + " ]");
 
         final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
         final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
@@ -254,6 +263,7 @@
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
                 .setShouldShowEntitlementUi(false)
+                .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL)
                 .build();
         int uid = 1000;
         String packageName = "package";
@@ -265,13 +275,26 @@
         assertEquals(TETHERING_USB, tr2.getTetheringType());
         assertTrue(tr2.isExemptFromEntitlementCheck());
         assertFalse(tr2.getShouldShowEntitlementUi());
+        assertEquals(CONNECTIVITY_SCOPE_LOCAL, tr2.getConnectivityScope());
+        assertNull(tr2.getSoftApConfiguration());
         assertEquals(uid, tr2.getUid());
         assertEquals(packageName, tr2.getPackageName());
+        assertEquals(tr2.toString(), "TetheringRequest[ "
+                + "TETHERING_USB, "
+                + "localIpv4Address=" + localAddr + ", "
+                + "staticClientAddress=" + clientAddr + ", "
+                + "exemptFromEntitlementCheck, "
+                + "CONNECTIVITY_SCOPE_LOCAL, "
+                + "uid=1000, "
+                + "packageName=package"
+                + " ]");
 
         final TetheringRequest tr3 = new TetheringRequest.Builder(TETHERING_USB)
                 .setStaticIpv4Addresses(localAddr, clientAddr)
                 .setExemptFromEntitlementCheck(true)
-                .setShouldShowEntitlementUi(false).build();
+                .setShouldShowEntitlementUi(false)
+                .setConnectivityScope(CONNECTIVITY_SCOPE_LOCAL)
+                .build();
         tr3.setUid(uid);
         tr3.setPackageName(packageName);
         assertEquals(tr2, tr3);
@@ -340,10 +363,14 @@
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
             tetherEventCallback.expectNoTetheringActive();
 
+            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes("This is an SSID!"
+                            .getBytes(StandardCharsets.UTF_8))).build();
             final TetheringInterface tetheredIface =
-                    mCtsTetheringUtils.startWifiTethering(tetherEventCallback);
+                    mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
             assertNotNull(tetheredIface);
+            assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
             final String wifiTetheringIface = tetheredIface.getInterface();
 
             mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
diff --git a/tests/unit/java/android/net/TrafficStatsTest.kt b/tests/unit/java/android/net/TrafficStatsTest.kt
deleted file mode 100644
index c61541e..0000000
--- a/tests/unit/java/android/net/TrafficStatsTest.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
-* Copyright (C) 2024 The Android Open Source Project
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*      http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
-*/
-
-package android.net
-
-import android.net.TrafficStats.getValueForTypeFromFirstEntry
-import android.net.TrafficStats.TYPE_RX_BYTES
-import android.net.TrafficStats.UNSUPPORTED
-import android.os.Build
-import com.android.testutils.DevSdkIgnoreRule
-import com.android.testutils.DevSdkIgnoreRunner
-import org.junit.Test
-import org.junit.runner.RunWith
-import kotlin.test.assertEquals
-
-private const val TEST_IFACE1 = "test_iface1"
-
-@RunWith(DevSdkIgnoreRunner::class)
-@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
-class TrafficStatsTest {
-
-    @Test
-    fun testGetValueForTypeFromFirstEntry() {
-        var stats: NetworkStats = NetworkStats(0, 0)
-        // empty stats
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), UNSUPPORTED.toLong())
-        // invalid type
-        stats.insertEntry(TEST_IFACE1, 1, 2, 3, 4)
-        assertEquals(getValueForTypeFromFirstEntry(stats, 1000), UNSUPPORTED.toLong())
-        // valid type
-        assertEquals(getValueForTypeFromFirstEntry(stats, TYPE_RX_BYTES), 1)
-    }
-}
\ No newline at end of file
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index 999d17d..f7d7c87 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -2369,6 +2369,18 @@
             mScheduledEvaluationTimeouts.add(new Pair<>(network.netId, delayMs));
             super.scheduleEvaluationTimeout(handler, network, delayMs);
         }
+
+        @Override
+        public int getDefaultCellularDataInactivityTimeout() {
+            // Needed to mock out the dependency on DeviceConfig
+            return 10;
+        }
+
+        @Override
+        public int getDefaultWifiDataInactivityTimeout() {
+            // Needed to mock out the dependency on DeviceConfig
+            return 15;
+        }
     }
 
     private class AutomaticOnOffKeepaliveTrackerDependencies
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
index 5c29e3a..b824531 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSDestroyedNetworkTests.kt
@@ -27,9 +27,26 @@
 import com.android.testutils.TestableNetworkCallback
 import org.junit.Test
 import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyInt
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.ArgumentMatchers.eq
+import org.mockito.Mockito.clearInvocations
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
 
 private const val LONG_TIMEOUT_MS = 5_000
 
+private val CAPABILITIES = NetworkCapabilities.Builder()
+        .addTransportType(TRANSPORT_WIFI)
+        .build()
+
+private val REQUEST = NetworkRequest.Builder()
+        .clearCapabilities()
+        .addTransportType(TRANSPORT_WIFI)
+        .build()
+
+
 @DevSdkIgnoreRunner.MonitorThreadLeak
 @RunWith(DevSdkIgnoreRunner::class)
 @SmallTest
@@ -37,29 +54,53 @@
 class CSDestroyedNetworkTests : CSTest() {
     @Test
     fun testDestroyNetworkNotKeptWhenUnvalidated() {
-        val nc = NetworkCapabilities.Builder()
-                .addTransportType(TRANSPORT_WIFI)
-                .build()
-
-        val nr = NetworkRequest.Builder()
-                .clearCapabilities()
-                .addTransportType(TRANSPORT_WIFI)
-                .build()
         val cbRequest = TestableNetworkCallback()
         val cbCallback = TestableNetworkCallback()
-        cm.requestNetwork(nr, cbRequest)
-        cm.registerNetworkCallback(nr, cbCallback)
+        cm.requestNetwork(REQUEST, cbRequest)
+        cm.registerNetworkCallback(REQUEST, cbCallback)
 
-        val firstAgent = Agent(nc = nc)
+        val firstAgent = Agent(nc = CAPABILITIES)
         firstAgent.connect()
         cbCallback.expectAvailableCallbacks(firstAgent.network, validated = false)
 
         firstAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
 
-        val secondAgent = Agent(nc = nc)
+        val secondAgent = Agent(nc = CAPABILITIES)
         secondAgent.connect()
         cbCallback.expectAvailableCallbacks(secondAgent.network, validated = false)
 
         cbCallback.expect<Lost>(timeoutMs = 500) { it.network == firstAgent.network }
     }
+
+    @Test
+    fun testDestroyNetworkWithDelayedTeardown() {
+        val cbRequest = TestableNetworkCallback()
+        val cbCallback = TestableNetworkCallback()
+        cm.requestNetwork(REQUEST, cbRequest)
+        cm.registerNetworkCallback(REQUEST, cbCallback)
+
+        val firstAgent = Agent(nc = CAPABILITIES)
+        firstAgent.connect()
+        firstAgent.setTeardownDelayMillis(1)
+        cbCallback.expectAvailableCallbacks(firstAgent.network, validated = false)
+
+        clearInvocations(netd)
+        val inOrder = inOrder(netd)
+        firstAgent.unregisterAfterReplacement(LONG_TIMEOUT_MS)
+
+        val secondAgent = Agent(nc = CAPABILITIES)
+        secondAgent.connect()
+        cbCallback.expectAvailableCallbacks(secondAgent.network, validated = false)
+        secondAgent.disconnect()
+
+        cbCallback.expect<Lost>(timeoutMs = 500) { it.network == firstAgent.network }
+        cbCallback.expect<Lost>(timeoutMs = 500) { it.network == secondAgent.network }
+        // onLost is fired before the network is destroyed.
+        waitForIdle()
+
+        inOrder.verify(netd).networkDestroy(eq(firstAgent.network.netId))
+        inOrder.verify(netd).networkCreate(argThat{ it.netId == secondAgent.network.netId })
+        inOrder.verify(netd).networkDestroy(eq(secondAgent.network.netId))
+        verify(netd, never()).networkSetPermissionForNetwork(anyInt(), anyInt())
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
index df0a2cc..ccbd6b3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSNetworkActivityTest.kt
@@ -21,6 +21,7 @@
 import android.net.ConnectivityManager.EXTRA_DEVICE_TYPE
 import android.net.ConnectivityManager.EXTRA_IS_ACTIVE
 import android.net.ConnectivityManager.EXTRA_REALTIME_NS
+import android.net.ConnectivitySettingsManager
 import android.net.LinkProperties
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_IMS
@@ -41,12 +42,14 @@
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
+import java.time.Duration
 import kotlin.test.assertNotNull
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.anyString
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.anyLong
@@ -69,6 +72,18 @@
 @IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 class CSNetworkActivityTest : CSTest() {
 
+    private fun setMobileDataActivityTimeout(timeoutSeconds: Int) {
+        ConnectivitySettingsManager.setMobileDataActivityTimeout(
+            context, Duration.ofSeconds(timeoutSeconds.toLong())
+        )
+    }
+
+    private fun setWifiDataActivityTimeout(timeoutSeconds: Int) {
+        ConnectivitySettingsManager.setWifiDataActivityTimeout(
+            context, Duration.ofSeconds(timeoutSeconds.toLong())
+        )
+    }
+
     private fun getRegisteredNetdUnsolicitedEventListener(): BaseNetdUnsolicitedEventListener {
         val captor = ArgumentCaptor.forClass(BaseNetdUnsolicitedEventListener::class.java)
         verify(netd).registerUnsolicitedEventListener(captor.capture())
@@ -252,8 +267,122 @@
         cm.unregisterNetworkCallback(dataNetworkCb)
         cm.unregisterNetworkCallback(imsNetworkCb)
     }
+
+    @Test
+    fun testCellularIdleTimerSettingsTimeout() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+
+        val settingsTimeout: Int = deps.defaultCellDataInactivityTimeoutForTest + 432
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is set, so the default should be ignored.
+        setMobileDataActivityTimeout(settingsTimeout)
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(DATA_CELL_IFNAME), eq(settingsTimeout), anyString())
+    }
+
+    @Test
+    fun testCellularIdleTimerDefaultTimeout() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+
+        val testTimeout: Int = deps.defaultCellDataInactivityTimeoutForTest
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is not set, so the default should be used.
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(DATA_CELL_IFNAME), eq(testTimeout), anyString())
+    }
+
+    @Test
+    fun testCellularIdleTimerDisabled() {
+        val cellNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val cellLp = LinkProperties().apply {
+            interfaceName = DATA_CELL_IFNAME
+        }
+        setMobileDataActivityTimeout(0)
+        val cellAgent = Agent(nc = cellNc, lp = cellLp)
+        cellAgent.connect()
+
+        verify(netd, never()).idletimerAddInterface(eq(DATA_CELL_IFNAME), anyInt(), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerSettingsTimeout() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        val settingsTimeout: Int = deps.defaultWifiDataInactivityTimeout + 435
+        setWifiDataActivityTimeout(settingsTimeout)
+        // DATA_ACTIVITY_TIMEOUT_MOBILE is set, so the default should be ignored.
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(WIFI_IFNAME), eq(settingsTimeout), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerDefaultTimeout() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        val testTimeout: Int = deps.defaultWifiDataInactivityTimeoutForTest
+        // DATA_ACTIVITY_TIMEOUT_WIFI is not set, so the default should be used.
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd).idletimerAddInterface(eq(WIFI_IFNAME), eq(testTimeout), anyString())
+    }
+
+    @Test
+    fun testWifiIdleTimerDisabled() {
+        val wifiNc = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            .build()
+        val wifiLp = LinkProperties().apply {
+            interfaceName = WIFI_IFNAME
+        }
+        setWifiDataActivityTimeout(0)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+
+        verify(netd, never()).idletimerAddInterface(eq(WIFI_IFNAME), anyInt(), anyString())
+    }
 }
 
+
 internal fun CSContext.expectDataActivityBroadcast(
         deviceType: Int,
         isActive: Boolean,
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index 1f5ee32..9be7d11 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -181,6 +181,7 @@
         cb.eventuallyExpect<Lost> { it.network == agent.network }
     }
 
+    fun setTeardownDelayMillis(delayMillis: Int) = agent.setTeardownDelayMillis(delayMillis)
     fun unregisterAfterReplacement(timeoutMs: Int) = agent.unregisterAfterReplacement(timeoutMs)
 
     fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index 46c25d2..ae196a6 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -341,6 +341,18 @@
             }
         }
 
+        // Need a non-zero value to avoid disarming the timer.
+        val defaultCellDataInactivityTimeoutForTest: Int = 81
+        override fun getDefaultCellularDataInactivityTimeout(): Int {
+            return defaultCellDataInactivityTimeoutForTest
+        }
+
+        // Need a non-zero value to avoid disarming the timer.
+        val defaultWifiDataInactivityTimeoutForTest: Int = 121
+        override fun getDefaultWifiDataInactivityTimeout(): Int {
+            return defaultWifiDataInactivityTimeoutForTest
+        }
+
         override fun isChangeEnabled(changeId: Long, pkg: String, user: UserHandle) =
                 changeId in enabledChangeIds
         override fun isChangeEnabled(changeId: Long, uid: Int) =
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index ef4c44d..b528480 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -56,7 +56,6 @@
 import static android.net.TrafficStats.MB_IN_BYTES;
 import static android.net.TrafficStats.UID_REMOVED;
 import static android.net.TrafficStats.UID_TETHERING;
-import static android.net.TrafficStats.getValueForTypeFromFirstEntry;
 import static android.net.connectivity.ConnectivityCompatChanges.ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID;
 import static android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID_TAG;
@@ -79,6 +78,7 @@
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_SUCCESSES_COUNTER_NAME;
+import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG;
 import static com.android.server.net.NetworkStatsService.TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
@@ -128,10 +128,12 @@
 import android.net.TestNetworkSpecifier;
 import android.net.TetherStatsParcel;
 import android.net.TetheringManager;
-import android.net.TrafficStats;
 import android.net.UnderlyingNetworkInfo;
+import android.net.netstats.StatsResult;
+import android.net.netstats.TrafficStatsRateLimitCacheConfig;
 import android.net.netstats.provider.INetworkStatsProviderCallback;
 import android.net.wifi.WifiInfo;
+import android.os.Build;
 import android.os.DropBoxManager;
 import android.os.Handler;
 import android.os.HandlerThread;
@@ -209,7 +211,6 @@
 import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Function;
 
 /**
  * Tests for {@link NetworkStatsService}.
@@ -223,6 +224,8 @@
 // NetworkStatsService is not updatable before T, so tests do not need to be backwards compatible
 @DevSdkIgnoreRule.IgnoreUpTo(SC_V2)
 public class NetworkStatsServiceTest extends NetworkStatsBaseTest {
+    @Rule
+    public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
 
     private static final String TAG = "NetworkStatsServiceTest";
 
@@ -621,8 +624,9 @@
         }
 
         @Override
-        public boolean alwaysUseTrafficStatsServiceRateLimitCache(Context ctx) {
-            return mFeatureFlags.getOrDefault(
+        public boolean isTrafficStatsServiceRateLimitCacheEnabled(Context ctx,
+                boolean isClientCacheEnabled) {
+            return !isClientCacheEnabled && mFeatureFlags.getOrDefault(
                     TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, false);
         }
 
@@ -643,6 +647,19 @@
         }
 
         @Override
+        public TrafficStatsRateLimitCacheConfig getTrafficStatsRateLimitCacheClientSideConfig(
+                @NonNull Context ctx) {
+            final TrafficStatsRateLimitCacheConfig config =
+                    new TrafficStatsRateLimitCacheConfig.Builder()
+                            .setIsCacheEnabled(mFeatureFlags.getOrDefault(
+                                    TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, false))
+                            .setExpiryDurationMs(DEFAULT_TRAFFIC_STATS_CACHE_EXPIRY_DURATION_MS)
+                            .setMaxEntries(DEFAULT_TRAFFIC_STATS_SERVICE_CACHE_MAX_ENTRIES)
+                            .build();
+            return config;
+        }
+
+        @Override
         public boolean isChangeEnabled(long changeId, int uid) {
             return mCompatChanges.getOrDefault(changeId, true);
         }
@@ -2453,11 +2470,49 @@
         assertUidTotal(sTemplateWifi, UID_GREEN, 64L, 3L, 1024L, 8L, 0);
     }
 
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
+    @Test
+    public void testGetRateLimitCacheConfig_featureDisabled() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testGetRateLimitCacheConfig_vOrAbove() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testGetRateLimitCacheConfig_belowV() {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        assertFalse(mService.getRateLimitCacheConfig().isCacheEnabled);
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        assertTrue(mService.getRateLimitCacheConfig().isCacheEnabled);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_CLIENT_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @Test
+    public void testTrafficStatsRateLimitCache_clientCacheEnabledDisableServiceCache()
+            throws Exception {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
+    }
+
     @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG, enabled = false)
     @Test
     public void testTrafficStatsRateLimitCache_disabledWithCompatChangeEnabled() throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, true);
-        doTestTrafficStatsRateLimitCache(true /* expectCached */);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
     }
 
     @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
@@ -2475,8 +2530,19 @@
     }
 
     @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     @Test
-    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled() throws Exception {
+    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled_belowV()
+            throws Exception {
+        mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
+        doTestTrafficStatsRateLimitCache(false /* expectCached */);
+    }
+
+    @FeatureFlag(name = TRAFFICSTATS_SERVICE_RATE_LIMIT_CACHE_ENABLED_FLAG)
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+    @Test
+    public void testTrafficStatsRateLimitCache_enabledWithCompatChangeDisabled_vOrAbove()
+            throws Exception {
         mDeps.setChangeEnabled(ENABLE_TRAFFICSTATS_RATE_LIMIT_CACHE, false);
         doTestTrafficStatsRateLimitCache(true /* expectCached */);
     }
@@ -2515,22 +2581,18 @@
     // Assert for 3 different API return values respectively.
     private void assertTrafficStatsValues(String iface, int uid, long rxBytes, long rxPackets,
             long txBytes, long txPackets) {
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessTotalStats(), type));
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(
-                        mService.getTypelessIfaceStats(iface), type)
-        );
-        assertTrafficStatsValuesThat(rxBytes, rxPackets, txBytes, txPackets,
-                (type) -> getValueForTypeFromFirstEntry(mService.getTypelessUidStats(uid), type));
+        assertStatsResultEquals(mService.getTotalStats(), rxBytes, rxPackets, txBytes, txPackets);
+        assertStatsResultEquals(mService.getIfaceStats(iface), rxBytes, rxPackets, txBytes,
+                txPackets);
+        assertStatsResultEquals(mService.getUidStats(uid), rxBytes, rxPackets, txBytes, txPackets);
     }
 
-    private void assertTrafficStatsValuesThat(long rxBytes, long rxPackets, long txBytes,
-            long txPackets, Function<Integer, Long> fetcher) {
-        assertEquals(rxBytes, (long) fetcher.apply(TrafficStats.TYPE_RX_BYTES));
-        assertEquals(rxPackets, (long) fetcher.apply(TrafficStats.TYPE_RX_PACKETS));
-        assertEquals(txBytes, (long) fetcher.apply(TrafficStats.TYPE_TX_BYTES));
-        assertEquals(txPackets, (long) fetcher.apply(TrafficStats.TYPE_TX_PACKETS));
+    private void assertStatsResultEquals(StatsResult stats, long rxBytes, long rxPackets,
+            long txBytes, long txPackets) {
+        assertEquals(rxBytes, stats.rxBytes);
+        assertEquals(rxPackets, stats.rxPackets);
+        assertEquals(txBytes, stats.txBytes);
+        assertEquals(txPackets, stats.txPackets);
     }
 
     private void assertShouldRunComparison(boolean expected, boolean isDebuggable) {
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 34d67bb..40842f1 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -13,6 +13,9 @@
   "postsubmit": [
     {
       "name": "ThreadNetworkMultiDeviceTests"
+    },
+    {
+      "name": "ThreadNetworkTrelDisabledTests"
     }
   ]
 }
diff --git a/thread/framework/java/android/net/thread/ThreadConfiguration.java b/thread/framework/java/android/net/thread/ThreadConfiguration.java
index edb5021..0829265 100644
--- a/thread/framework/java/android/net/thread/ThreadConfiguration.java
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.java
@@ -44,18 +44,38 @@
 @FlaggedApi(Flags.FLAG_CONFIGURATION_ENABLED)
 @SystemApi
 public final class ThreadConfiguration implements Parcelable {
+    private final boolean mBorderRouterEnabled;
     private final boolean mNat64Enabled;
     private final boolean mDhcpv6PdEnabled;
 
     private ThreadConfiguration(Builder builder) {
-        this(builder.mNat64Enabled, builder.mDhcpv6PdEnabled);
+        this(builder.mBorderRouterEnabled, builder.mNat64Enabled, builder.mDhcpv6PdEnabled);
     }
 
-    private ThreadConfiguration(boolean nat64Enabled, boolean dhcpv6PdEnabled) {
+    private ThreadConfiguration(
+            boolean borderRouterEnabled, boolean nat64Enabled, boolean dhcpv6PdEnabled) {
+        this.mBorderRouterEnabled = borderRouterEnabled;
         this.mNat64Enabled = nat64Enabled;
         this.mDhcpv6PdEnabled = dhcpv6PdEnabled;
     }
 
+    /**
+     * Returns {@code true} if this device is operating as a Thread Border Router.
+     *
+     * <p>A Thread Border Router works on both Thread and infrastructure networks. For example, it
+     * can route packets between Thread and infrastructure networks (e.g. Wi-Fi or Ethernet), makes
+     * devices in both networks discoverable to each other, and accepts connections from external
+     * commissioner.
+     *
+     * <p>Note it costs significantly more power to operate as a Border Router, so this is typically
+     * only enabled for wired Android devices (e.g. TV or display).
+     *
+     * @hide
+     */
+    public boolean isBorderRouterEnabled() {
+        return mBorderRouterEnabled;
+    }
+
     /** Returns {@code true} if NAT64 is enabled. */
     public boolean isNat64Enabled() {
         return mNat64Enabled;
@@ -78,22 +98,24 @@
             return false;
         } else {
             ThreadConfiguration otherConfig = (ThreadConfiguration) other;
-            return mNat64Enabled == otherConfig.mNat64Enabled
+            return mBorderRouterEnabled == otherConfig.mBorderRouterEnabled
+                    && mNat64Enabled == otherConfig.mNat64Enabled
                     && mDhcpv6PdEnabled == otherConfig.mDhcpv6PdEnabled;
         }
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(mNat64Enabled, mDhcpv6PdEnabled);
+        return Objects.hash(mBorderRouterEnabled, mNat64Enabled, mDhcpv6PdEnabled);
     }
 
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append('{');
-        sb.append("Nat64Enabled=").append(mNat64Enabled);
-        sb.append(", Dhcpv6PdEnabled=").append(mDhcpv6PdEnabled);
+        sb.append("borderRouterEnabled=").append(mBorderRouterEnabled);
+        sb.append(", nat64Enabled=").append(mNat64Enabled);
+        sb.append(", dhcpv6PdEnabled=").append(mDhcpv6PdEnabled);
         sb.append('}');
         return sb.toString();
     }
@@ -105,6 +127,7 @@
 
     @Override
     public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeBoolean(mBorderRouterEnabled);
         dest.writeBoolean(mNat64Enabled);
         dest.writeBoolean(mDhcpv6PdEnabled);
     }
@@ -114,6 +137,7 @@
                 @Override
                 public ThreadConfiguration createFromParcel(Parcel in) {
                     ThreadConfiguration.Builder builder = new ThreadConfiguration.Builder();
+                    builder.setBorderRouterEnabled(in.readBoolean());
                     builder.setNat64Enabled(in.readBoolean());
                     builder.setDhcpv6PdEnabled(in.readBoolean());
                     return builder.build();
@@ -133,6 +157,10 @@
     @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
     @SystemApi
     public static final class Builder {
+        // Thread in Android V is default to a Border Router device, so the default value here needs
+        // to be {@code true} to be compatible.
+        private boolean mBorderRouterEnabled = true;
+
         private boolean mNat64Enabled = false;
         private boolean mDhcpv6PdEnabled = false;
 
@@ -156,11 +184,26 @@
         public Builder(@NonNull ThreadConfiguration config) {
             Objects.requireNonNull(config);
 
+            mBorderRouterEnabled = config.mBorderRouterEnabled;
             mNat64Enabled = config.mNat64Enabled;
             mDhcpv6PdEnabled = config.mDhcpv6PdEnabled;
         }
 
         /**
+         * Enables or disables this device as a Border Router.
+         *
+         * <p>Defaults to {@code true} if this method is not called.
+         *
+         * @see ThreadConfiguration#isBorderRouterEnabled
+         * @hide
+         */
+        @NonNull
+        public Builder setBorderRouterEnabled(boolean enabled) {
+            this.mBorderRouterEnabled = enabled;
+            return this;
+        }
+
+        /**
          * Enables or disables NAT64 for the device.
          *
          * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 14d22d1..73a6bda 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -908,7 +908,6 @@
 
         for (int i = 0; i < channelMaxPowers.size(); i++) {
             int channel = channelMaxPowers.keyAt(i);
-            int maxPower = channelMaxPowers.get(channel);
 
             if ((channel < ActiveOperationalDataset.CHANNEL_MIN_24_GHZ)
                     || (channel > ActiveOperationalDataset.CHANNEL_MAX_24_GHZ)) {
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java
new file mode 100644
index 0000000..205c16e
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkSpecifier.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.net.NetworkSpecifier;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import com.android.net.module.util.HexDump;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+/**
+ * Represents and identifies a Thread network.
+ *
+ * @hide
+ */
+public final class ThreadNetworkSpecifier extends NetworkSpecifier implements Parcelable {
+    /** The Extended PAN ID of a Thread network. */
+    @NonNull private final byte[] mExtendedPanId;
+
+    /** The Active Timestamp of a Thread network. */
+    @Nullable private final OperationalDatasetTimestamp mActiveTimestamp;
+
+    private final boolean mRouterEligibleForLeader;
+
+    private ThreadNetworkSpecifier(@NonNull Builder builder) {
+        mExtendedPanId = builder.mExtendedPanId.clone();
+        mActiveTimestamp = builder.mActiveTimestamp;
+        mRouterEligibleForLeader = builder.mRouterEligibleForLeader;
+    }
+
+    /** Returns the Extended PAN ID of the Thread network this specifier refers to. */
+    @NonNull
+    public byte[] getExtendedPanId() {
+        return mExtendedPanId.clone();
+    }
+
+    /**
+     * Returns the Active Timestamp of the Thread network this specifier refers to, or {@code null}
+     * if not specified.
+     */
+    @Nullable
+    public OperationalDatasetTimestamp getActiveTimestamp() {
+        return mActiveTimestamp;
+    }
+
+    /**
+     * Returns {@code true} if this device can be a leader during attachment when there are no
+     * nearby routers.
+     */
+    public boolean isRouterEligibleForLeader() {
+        return mRouterEligibleForLeader;
+    }
+
+    /**
+     * Returns {@code true} if both {@link #getExtendedPanId()} and {@link #getActiveTimestamp()}
+     * (if not {@code null}) of the two {@link ThreadNetworkSpecifier} objects are equal.
+     *
+     * <p>Note value of {@link #isRouterEligibleForLeader()} is expiclitly excluded because this is
+     * not part of the identifier.
+     *
+     * @hide
+     */
+    @Override
+    public boolean canBeSatisfiedBy(@Nullable NetworkSpecifier other) {
+        if (!(other instanceof ThreadNetworkSpecifier)) {
+            return false;
+        }
+        ThreadNetworkSpecifier otherSpecifier = (ThreadNetworkSpecifier) other;
+
+        if (mActiveTimestamp != null && !mActiveTimestamp.equals(otherSpecifier.mActiveTimestamp)) {
+            return false;
+        }
+
+        return Arrays.equals(mExtendedPanId, otherSpecifier.mExtendedPanId);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (!(other instanceof ThreadNetworkSpecifier)) {
+            return false;
+        } else if (this == other) {
+            return true;
+        }
+
+        ThreadNetworkSpecifier otherSpecifier = (ThreadNetworkSpecifier) other;
+
+        return Arrays.equals(mExtendedPanId, otherSpecifier.mExtendedPanId)
+                && Objects.equals(mActiveTimestamp, otherSpecifier.mActiveTimestamp)
+                && mRouterEligibleForLeader == otherSpecifier.mRouterEligibleForLeader;
+    }
+
+    @Override
+    public int hashCode() {
+        return deepHashCode(mExtendedPanId, mActiveTimestamp, mRouterEligibleForLeader);
+    }
+
+    /** An easy-to-use wrapper of {@link Arrays#deepHashCode}. */
+    private static int deepHashCode(Object... values) {
+        return Arrays.deepHashCode(values);
+    }
+
+    @Override
+    public String toString() {
+        return "ThreadNetworkSpecifier{extendedPanId="
+                + HexDump.toHexString(mExtendedPanId)
+                + ", activeTimestamp="
+                + mActiveTimestamp
+                + ", routerEligibleForLeader="
+                + mRouterEligibleForLeader
+                + "}";
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(mExtendedPanId);
+        dest.writeByteArray(mActiveTimestamp != null ? mActiveTimestamp.toTlvValue() : null);
+        dest.writeBoolean(mRouterEligibleForLeader);
+    }
+
+    public static final @NonNull Parcelable.Creator<ThreadNetworkSpecifier> CREATOR =
+            new Parcelable.Creator<ThreadNetworkSpecifier>() {
+                @Override
+                public ThreadNetworkSpecifier createFromParcel(Parcel in) {
+                    byte[] extendedPanId = in.createByteArray();
+                    byte[] activeTimestampBytes = in.createByteArray();
+                    OperationalDatasetTimestamp activeTimestamp =
+                            (activeTimestampBytes != null)
+                                    ? OperationalDatasetTimestamp.fromTlvValue(activeTimestampBytes)
+                                    : null;
+                    boolean routerEligibleForLeader = in.readBoolean();
+
+                    return new Builder(extendedPanId)
+                            .setActiveTimestamp(activeTimestamp)
+                            .setRouterEligibleForLeader(routerEligibleForLeader)
+                            .build();
+                }
+
+                @Override
+                public ThreadNetworkSpecifier[] newArray(int size) {
+                    return new ThreadNetworkSpecifier[size];
+                }
+            };
+
+    /** The builder for creating {@link ActiveOperationalDataset} objects. */
+    public static final class Builder {
+        @NonNull private final byte[] mExtendedPanId;
+        @Nullable private OperationalDatasetTimestamp mActiveTimestamp;
+        private boolean mRouterEligibleForLeader;
+
+        /**
+         * Creates a new {@link Builder} object with given Extended PAN ID.
+         *
+         * @throws IllegalArgumentException if {@code extendedPanId} is {@code null} or the length
+         *     is not {@link ActiveOperationalDataset#LENGTH_EXTENDED_PAN_ID}
+         */
+        public Builder(@NonNull byte[] extendedPanId) {
+            if (extendedPanId == null || extendedPanId.length != LENGTH_EXTENDED_PAN_ID) {
+                throw new IllegalArgumentException(
+                        "extendedPanId is null or length is not "
+                                + LENGTH_EXTENDED_PAN_ID
+                                + ": "
+                                + Arrays.toString(extendedPanId));
+            }
+            mExtendedPanId = extendedPanId.clone();
+            mRouterEligibleForLeader = false;
+        }
+
+        /**
+         * Creates a new {@link Builder} object by copying the data in the given {@code specifier}
+         * object.
+         */
+        public Builder(@NonNull ThreadNetworkSpecifier specifier) {
+            this(specifier.getExtendedPanId());
+            setActiveTimestamp(specifier.getActiveTimestamp());
+            setRouterEligibleForLeader(specifier.isRouterEligibleForLeader());
+        }
+
+        /** Sets the Active Timestamp of the Thread network. */
+        @NonNull
+        public Builder setActiveTimestamp(@Nullable OperationalDatasetTimestamp activeTimestamp) {
+            mActiveTimestamp = activeTimestamp;
+            return this;
+        }
+
+        /**
+         * Sets whether this device should be a leader during attachment when there are no nearby
+         * routers.
+         */
+        @NonNull
+        public Builder setRouterEligibleForLeader(boolean eligible) {
+            mRouterEligibleForLeader = eligible;
+            return this;
+        }
+
+        /** Creates a new {@link ThreadNetworkSpecifier} object from values set so far. */
+        @NonNull
+        public ThreadNetworkSpecifier build() {
+            return new ThreadNetworkSpecifier(this);
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/FeatureFlags.java b/thread/service/java/com/android/server/thread/FeatureFlags.java
new file mode 100644
index 0000000..29bcedd
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/FeatureFlags.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
+ * or implied. See the License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+package com.android.server.thread;
+
+import com.android.net.module.util.DeviceConfigUtils;
+
+public class FeatureFlags {
+    // The namespace for Thread Network feature flags
+    private static final String NAMESPACE_THREAD_NETWORK = "thread_network";
+
+    // The prefix for TREL feature flags
+    private static final String TREL_FEATURE_PREFIX = "TrelFeature__";
+
+    // The feature flag for TREL enabled state
+    private static final String TREL_ENABLED_FLAG = TREL_FEATURE_PREFIX + "enabled";
+
+    private static boolean isFeatureEnabled(String flag, boolean defaultValue) {
+        return DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                NAMESPACE_THREAD_NETWORK, flag, defaultValue);
+    }
+
+    public static boolean isTrelEnabled() {
+        return isFeatureEnabled(TREL_ENABLED_FLAG, false);
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index ba4eeaf..30d5a02 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -19,6 +19,7 @@
 import static android.net.MulticastRoutingConfig.CONFIG_FORWARD_NONE;
 import static android.net.MulticastRoutingConfig.FORWARD_SELECTED;
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
+import static android.net.NetworkCapabilities.TRANSPORT_THREAD;
 import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
 import static android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID;
 import static android.net.thread.ActiveOperationalDataset.LENGTH_MESH_LOCAL_PREFIX_BITS;
@@ -122,8 +123,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.RoutingCoordinatorManager;
 import com.android.net.module.util.SharedLog;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.connectivity.ConnectivityResources;
@@ -151,7 +152,6 @@
 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;
@@ -222,6 +222,7 @@
     private NetworkRequest mUpstreamNetworkRequest;
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
+    private ThreadNetworkCallback mThreadNetworkCallback;
     private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
@@ -315,14 +316,6 @@
                 .build();
     }
 
-    private LocalNetworkConfig newLocalNetworkConfig() {
-        return new LocalNetworkConfig.Builder()
-                .setUpstreamMulticastRoutingConfig(mUpstreamMulticastRoutingConfig)
-                .setDownstreamMulticastRoutingConfig(mDownstreamMulticastRoutingConfig)
-                .setUpstreamSelector(mUpstreamNetworkRequest)
-                .build();
-    }
-
     private void maybeInitializeOtDaemon() {
         if (!shouldEnableThread()) {
             return;
@@ -364,7 +357,8 @@
                 mNsdPublisher,
                 getMeshcopTxtAttributes(mResources.get()),
                 mOtDaemonCallbackProxy,
-                mCountryCodeSupplier.get());
+                mCountryCodeSupplier.get(),
+                FeatureFlags.isTrelEnabled());
         otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
         mHandler.post(mNat64CidrController::maybeUpdateNat64Cidr);
@@ -476,10 +470,16 @@
             throw new IllegalStateException("Failed to create Thread tunnel interface", e);
         }
         mConnectivityManager.registerNetworkProvider(mNetworkProvider);
-        requestUpstreamNetwork();
-        registerThreadNetworkCallback();
         mUserRestricted = isThreadUserRestricted();
         registerUserRestrictionsReceiver();
+
+        if (isBorderRouterMode()) {
+            requestUpstreamNetwork();
+            registerThreadNetworkCallback();
+        } else {
+            cancelRequestUpstreamNetwork();
+            unregisterThreadNetworkCallback();
+        }
         maybeInitializeOtDaemon();
     }
 
@@ -588,7 +588,20 @@
         LOG.i("Set Thread configuration: " + configuration);
 
         final boolean changed = mPersistentSettings.putConfiguration(configuration);
+
+        if (changed) {
+            if (isBorderRouterMode()) {
+                requestUpstreamNetwork();
+                registerThreadNetworkCallback();
+            } else {
+                cancelRequestUpstreamNetwork();
+                unregisterThreadNetworkCallback();
+                disableBorderRouting();
+            }
+        }
+
         receiver.onSuccess();
+
         if (changed) {
             for (IConfigurationReceiver configReceiver : mConfigurationReceivers.keySet()) {
                 try {
@@ -598,6 +611,7 @@
                 }
             }
         }
+
         try {
             getOtDaemon()
                     .setConfiguration(
@@ -612,11 +626,17 @@
     private static OtDaemonConfiguration newOtDaemonConfig(
             @NonNull ThreadConfiguration threadConfig) {
         return new OtDaemonConfiguration.Builder()
+                .setBorderRouterEnabled(threadConfig.isBorderRouterEnabled())
                 .setNat64Enabled(threadConfig.isNat64Enabled())
                 .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
                 .build();
     }
 
+    /** Returns {@code true} if this device is operating as a border router. */
+    private boolean isBorderRouterMode() {
+        return mPersistentSettings.getConfiguration().isBorderRouterEnabled();
+    }
+
     @Override
     public void registerConfigurationCallback(@NonNull IConfigurationReceiver callback) {
         enforceAllPermissionsGranted(permission.THREAD_NETWORK_PRIVILEGED);
@@ -723,7 +743,7 @@
 
     private void requestUpstreamNetwork() {
         if (mUpstreamNetworkCallback != null) {
-            throw new AssertionError("The upstream network request is already there.");
+            return;
         }
         mUpstreamNetworkCallback = new UpstreamNetworkCallback();
         mConnectivityManager.registerNetworkCallback(
@@ -732,7 +752,7 @@
 
     private void cancelRequestUpstreamNetwork() {
         if (mUpstreamNetworkCallback == null) {
-            throw new AssertionError("The upstream network request null.");
+            return;
         }
         mNetworkToLinkProperties.clear();
         mConnectivityManager.unregisterNetworkCallback(mUpstreamNetworkCallback);
@@ -812,16 +832,28 @@
     }
 
     private void registerThreadNetworkCallback() {
-        mConnectivityManager.registerNetworkCallback(
+        if (mThreadNetworkCallback != null) {
+            return;
+        }
+
+        mThreadNetworkCallback = new ThreadNetworkCallback();
+        NetworkRequest request =
                 new NetworkRequest.Builder()
                         // clearCapabilities() is needed to remove forbidden capabilities and UID
                         // requirement.
                         .clearCapabilities()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .addTransportType(TRANSPORT_THREAD)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
-                        .build(),
-                new ThreadNetworkCallback(),
-                mHandler);
+                        .build();
+        mConnectivityManager.registerNetworkCallback(request, mThreadNetworkCallback, mHandler);
+    }
+
+    private void unregisterThreadNetworkCallback() {
+        if (mThreadNetworkCallback == null) {
+            return;
+        }
+        mConnectivityManager.unregisterNetworkCallback(mThreadNetworkCallback);
+        mThreadNetworkCallback = null;
     }
 
     /** Injects a {@link NetworkAgent} for testing. */
@@ -835,27 +867,46 @@
             return mTestNetworkAgent;
         }
 
-        final NetworkCapabilities netCaps =
+        final var netCapsBuilder =
                 new NetworkCapabilities.Builder()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                        .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
+                        .addTransportType(TRANSPORT_THREAD)
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
-                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
-                        .build();
-        final NetworkScore score =
-                new NetworkScore.Builder()
-                        .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build();
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED);
+        final var scoreBuilder = new NetworkScore.Builder();
+
+        if (isBorderRouterMode()) {
+            netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK);
+            scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK);
+        }
+
         return new NetworkAgent(
                 mContext,
                 mHandler.getLooper(),
                 LOG.getTag(),
-                netCaps,
+                netCapsBuilder.build(),
                 getTunIfLinkProperties(),
-                newLocalNetworkConfig(),
-                score,
+                isBorderRouterMode() ? newLocalNetworkConfig() : null,
+                scoreBuilder.build(),
                 new NetworkAgentConfig.Builder().build(),
-                mNetworkProvider) {};
+                mNetworkProvider) {
+
+            // TODO(b/374037595): use NetworkFactory to handle dynamic network requests
+            @Override
+            public void onNetworkUnwanted() {
+                LOG.i("Thread network is unwanted by ConnectivityService");
+                if (!isBorderRouterMode()) {
+                    leave(false /* eraseDataset */, new LoggingOperationReceiver("leave"));
+                }
+            }
+        };
+    }
+
+    private LocalNetworkConfig newLocalNetworkConfig() {
+        return new LocalNetworkConfig.Builder()
+                .setUpstreamMulticastRoutingConfig(mUpstreamMulticastRoutingConfig)
+                .setDownstreamMulticastRoutingConfig(mDownstreamMulticastRoutingConfig)
+                .setUpstreamSelector(mUpstreamNetworkRequest)
+                .build();
     }
 
     private void registerThreadNetwork() {
@@ -901,6 +952,12 @@
             long lifetimeMillis, OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
+        if (!isBorderRouterMode()) {
+            receiver.onError(
+                    ERROR_FAILED_PRECONDITION, "This device is not configured a Border Router");
+            return;
+        }
+
         try {
             getOtDaemon().activateEphemeralKeyMode(lifetimeMillis, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
@@ -920,6 +977,12 @@
     private void deactivateEphemeralKeyModeInternal(OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
+        if (!isBorderRouterMode()) {
+            receiver.onError(
+                    ERROR_FAILED_PRECONDITION, "This device is not configured a Border Router");
+            return;
+        }
+
         try {
             getOtDaemon().deactivateEphemeralKeyMode(newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
@@ -1233,16 +1296,20 @@
 
     @Override
     public void leave(@NonNull IOperationReceiver receiver) {
-        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-
-        mHandler.post(() -> leaveInternal(new OperationReceiverWrapper(receiver)));
+        leave(true /* eraseDataset */, receiver);
     }
 
-    private void leaveInternal(@NonNull OperationReceiverWrapper receiver) {
+    private void leave(boolean eraseDataset, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(() -> leaveInternal(eraseDataset, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void leaveInternal(boolean eraseDataset, @NonNull OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
         try {
-            getOtDaemon().leave(newOtStatusReceiver(receiver));
+            getOtDaemon().leave(eraseDataset, newOtStatusReceiver(receiver));
         } catch (RemoteException | ThreadNetworkException e) {
             LOG.e("otDaemon.leave failed", e);
             receiver.onError(e);
@@ -1339,9 +1406,10 @@
     }
 
     private void setInfraLinkState(InfraLinkState newInfraLinkState) {
-        if (!Objects.equals(mInfraLinkState, newInfraLinkState)) {
-            LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
+        if (Objects.equals(mInfraLinkState, newInfraLinkState)) {
+            return;
         }
+        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
         setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
         setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
         setInfraLinkDnsServers(newInfraLinkState.dnsServers);
@@ -1349,6 +1417,9 @@
     }
 
     private void setInfraLinkInterfaceName(String newInfraLinkInterfaceName) {
+        if (Objects.equals(mInfraLinkState.interfaceName, newInfraLinkInterfaceName)) {
+            return;
+        }
         ParcelFileDescriptor infraIcmp6Socket = null;
         if (newInfraLinkInterfaceName != null) {
             try {
@@ -1369,6 +1440,9 @@
     }
 
     private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
+        if (Objects.equals(newNat64Prefix, mInfraLinkState.nat64Prefix)) {
+            return;
+        }
         try {
             getOtDaemon()
                     .setInfraLinkNat64Prefix(
@@ -1379,6 +1453,9 @@
     }
 
     private void setInfraLinkDnsServers(List<String> newDnsServers) {
+        if (Objects.equals(newDnsServers, mInfraLinkState.dnsServers)) {
+            return;
+        }
         try {
             getOtDaemon()
                     .setInfraLinkDnsServers(
@@ -1388,6 +1465,11 @@
         }
     }
 
+    private void disableBorderRouting() {
+        LOG.i("Disabling border routing");
+        setInfraLinkState(newInfraLinkStateBuilder().build());
+    }
+
     private void handleThreadInterfaceStateChanged(boolean isUp) {
         try {
             mTunIfController.setInterfaceUp(isUp);
@@ -1571,6 +1653,25 @@
         }
     }
 
+    /** An implementation of {@link IOperationReceiver} that simply logs the operation result. */
+    private static class LoggingOperationReceiver extends IOperationReceiver.Stub {
+        private final String mOperation;
+
+        LoggingOperationReceiver(String operation) {
+            mOperation = operation;
+        }
+
+        @Override
+        public void onSuccess() {
+            LOG.i("The operation " + mOperation + " succeeded");
+        }
+
+        @Override
+        public void onError(int errorCode, String errorMessage) {
+            LOG.w("The operation " + mOperation + " failed: " + errorCode + " " + errorMessage);
+        }
+    }
+
     private static class LoggingOtStatusReceiver extends IOtStatusReceiver.Stub {
         private final String mAction;
 
@@ -1693,6 +1794,7 @@
                     // do nothing if the client is dead
                 }
             }
+            mInfraLinkState = newInfraLinkStateBuilder().build();
         }
 
         private void onThreadEnabledChanged(int state, long listenerId) {
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 5d869df..18ab1ca 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -137,6 +137,8 @@
                 return setThreadEnabled(true);
             case "disable":
                 return setThreadEnabled(false);
+            case "config":
+                return handleConfigCommand();
             case "join":
                 return join();
             case "leave":
@@ -149,8 +151,6 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
-            case "config":
-                return handleConfigCommand();
             case "ot-ctl":
                 return handleOtCtlCommand();
             default:
@@ -321,6 +321,7 @@
         final ThreadConfiguration.Builder newConfigBuilder =
                 new ThreadConfiguration.Builder(oldConfig);
         switch (name) {
+            case "br" -> newConfigBuilder.setBorderRouterEnabled(argEnabledOrDisabled(value));
             case "nat64" -> newConfigBuilder.setNat64Enabled(argEnabledOrDisabled(value));
             case "pd" -> newConfigBuilder.setDhcpv6PdEnabled(argEnabledOrDisabled(value));
             default -> throw new IllegalArgumentException("Invalid config name: " + name);
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index fc18ef9..746b587 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -77,6 +77,13 @@
     /** Stores the Thread country code, null if no country code is stored. */
     public static final Key<String> THREAD_COUNTRY_CODE = new Key<>("thread_country_code", null);
 
+    /**
+     * Saves the boolean flag for border router being enabled. The value defaults to {@code true} if
+     * this config is missing.
+     */
+    private static final Key<Boolean> CONFIG_BORDER_ROUTER_ENABLED =
+            new Key<>("config_border_router_enabled", true);
+
     /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
     private static final Key<Boolean> CONFIG_NAT64_ENABLED =
             new Key<>("config_nat64_enabled", false);
@@ -197,6 +204,7 @@
         if (getConfiguration().equals(configuration)) {
             return false;
         }
+        putObject(CONFIG_BORDER_ROUTER_ENABLED.key, configuration.isBorderRouterEnabled());
         putObject(CONFIG_NAT64_ENABLED.key, configuration.isNat64Enabled());
         putObject(CONFIG_DHCP6_PD_ENABLED.key, configuration.isDhcpv6PdEnabled());
         writeToStoreFile();
@@ -206,6 +214,7 @@
     /** Retrieve the {@link ThreadConfiguration} from the persistent settings. */
     public ThreadConfiguration getConfiguration() {
         return new ThreadConfiguration.Builder()
+                .setBorderRouterEnabled(get(CONFIG_BORDER_ROUTER_ENABLED))
                 .setNat64Enabled(get(CONFIG_NAT64_ENABLED))
                 .setDhcpv6PdEnabled(get(CONFIG_DHCP6_PD_ENABLED))
                 .build();
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
index 386412e..e2f0e47 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadConfigurationTest.java
@@ -41,6 +41,7 @@
 public final class ThreadConfigurationTest {
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
+    public final boolean mIsBorderRouterEnabled;
     public final boolean mIsNat64Enabled;
     public final boolean mIsDhcpv6PdEnabled;
 
@@ -48,14 +49,16 @@
     public static Collection configArguments() {
         return Arrays.asList(
                 new Object[][] {
-                    {false, false}, // All disabled
-                    {true, false}, // NAT64 enabled
-                    {false, true}, // DHCP6-PD enabled
-                    {true, true}, // All enabled
+                    {false, false, false}, // All disabled
+                    {false, true, false}, // NAT64 enabled
+                    {false, false, true}, // DHCP6-PD enabled
+                    {true, true, true}, // All enabled
                 });
     }
 
-    public ThreadConfigurationTest(boolean isNat64Enabled, boolean isDhcpv6PdEnabled) {
+    public ThreadConfigurationTest(
+            boolean isBorderRouterEnabled, boolean isNat64Enabled, boolean isDhcpv6PdEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
         mIsNat64Enabled = isNat64Enabled;
         mIsDhcpv6PdEnabled = isDhcpv6PdEnabled;
     }
@@ -64,6 +67,7 @@
     public void parcelable_parcelingIsLossLess() {
         ThreadConfiguration config =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
@@ -74,10 +78,12 @@
     public void builder_correctValuesAreSet() {
         ThreadConfiguration config =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
 
+        assertThat(config.isBorderRouterEnabled()).isEqualTo(mIsBorderRouterEnabled);
         assertThat(config.isNat64Enabled()).isEqualTo(mIsNat64Enabled);
         assertThat(config.isDhcpv6PdEnabled()).isEqualTo(mIsDhcpv6PdEnabled);
     }
@@ -86,6 +92,7 @@
     public void builderConstructor_configsAreEqual() {
         ThreadConfiguration config1 =
                 new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(mIsBorderRouterEnabled)
                         .setNat64Enabled(mIsNat64Enabled)
                         .setDhcpv6PdEnabled(mIsDhcpv6PdEnabled)
                         .build();
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 92227b4..2d487ca 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -922,6 +922,27 @@
 
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
+            throws Exception {
+        setConfigurationAndWait(
+                mController,
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        CompletableFuture<Void> future = new CompletableFuture<>();
+
+        mController.activateEphemeralKeyMode(
+                Duration.ofSeconds(1), mExecutor, newOutcomeReceiver(future));
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS));
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
             throws Exception {
         dropAllPermissions();
@@ -933,6 +954,26 @@
 
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void deactivateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
+            throws Exception {
+        setConfigurationAndWait(
+                mController,
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        CompletableFuture<Void> future = new CompletableFuture<>();
+
+        mController.deactivateEphemeralKeyMode(mExecutor, newOutcomeReceiver(future));
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> future.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS));
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
         CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
         CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
@@ -1151,9 +1192,15 @@
         CompletableFuture<Void> setFuture2 = new CompletableFuture<>();
         ConfigurationListener listener = new ConfigurationListener(mController);
         ThreadConfiguration config1 =
-                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
+                new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(true)
+                        .setNat64Enabled(true)
+                        .build();
         ThreadConfiguration config2 =
-                new ThreadConfiguration.Builder().setNat64Enabled(false).build();
+                new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(false)
+                        .setNat64Enabled(false)
+                        .build();
 
         try {
             runAsShell(
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 8f082a4..798a51e 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -62,3 +62,23 @@
     ],
     compile_multilib: "both",
 }
+
+android_test {
+    name: "ThreadNetworkTrelDisabledTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTestTrelDisabled.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults",
+    ],
+    test_suites: [
+        "mts-tethering",
+        "general-tests",
+    ],
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    compile_multilib: "both",
+}
diff --git a/thread/tests/integration/AndroidTest.xml b/thread/tests/integration/AndroidTest.xml
index 8f98941..08409b4 100644
--- a/thread/tests/integration/AndroidTest.xml
+++ b/thread/tests/integration/AndroidTest.xml
@@ -48,4 +48,10 @@
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="package" value="com.android.thread.tests.integration" />
     </test>
+
+    <!-- Enable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=true"/>
+    </target_preparer>
 </configuration>
diff --git a/thread/tests/integration/AndroidTestTrelDisabled.xml b/thread/tests/integration/AndroidTestTrelDisabled.xml
new file mode 100644
index 0000000..600652a
--- /dev/null
+++ b/thread/tests/integration/AndroidTestTrelDisabled.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2024 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ -->
+
+<configuration description="Config for Thread integration tests with TREL disabled">
+    <option name="test-tag" value="ThreadNetworkTrelDisabledTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <!--
+        Only run tests if the device under test is SDK version 34 (Android 14) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk34ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.DeviceFeatureModuleController">
+        <option name="required-feature" value="android.hardware.thread_network" />
+    </object>
+
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <!-- Install test -->
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadNetworkTrelDisabledTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.thread.tests.integration" />
+    </test>
+
+    <!-- Disable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=false"/>
+    </target_preparer>
+</configuration>
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index f6dd6b9..aeeed65 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -20,6 +20,7 @@
 import static android.net.InetAddresses.parseNumericAddress;
 import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 import static android.net.thread.utils.IntegrationTestUtils.buildIcmpv4EchoReply;
+import static android.net.thread.utils.IntegrationTestUtils.enableThreadAndJoinNetwork;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv4Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
@@ -27,6 +28,7 @@
 import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.isTo;
 import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr;
+import static android.net.thread.utils.IntegrationTestUtils.leaveNetworkAndDisableThread;
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
 import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
 import static android.net.thread.utils.IntegrationTestUtils.sendUdpMessage;
@@ -56,6 +58,7 @@
 import android.net.thread.utils.InfraNetworkDevice;
 import android.net.thread.utils.IntegrationTestUtils;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.TestTunNetworkUtils;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
@@ -73,7 +76,10 @@
 import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
+import org.junit.AfterClass;
 import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -115,7 +121,7 @@
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private final ThreadNetworkControllerWrapper mController =
             ThreadNetworkControllerWrapper.newInstance(mContext);
-    private OtDaemonController mOtCtl;
+    private final OtDaemonController mOtCtl = new OtDaemonController();
     private HandlerThread mHandlerThread;
     private Handler mHandler;
     private TestNetworkTracker mInfraNetworkTracker;
@@ -123,20 +129,24 @@
     private PollPacketReader mInfraNetworkReader;
     private InfraNetworkDevice mInfraDevice;
 
+    @BeforeClass
+    public static void beforeClass() throws Exception {
+        enableThreadAndJoinNetwork(DEFAULT_DATASET);
+    }
+
+    @AfterClass
+    public static void afterClass() throws Exception {
+        leaveNetworkAndDisableThread();
+    }
+
     @Before
     public void setUp() throws Exception {
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        mOtCtl = new OtDaemonController();
-        mOtCtl.factoryReset();
-
         mHandlerThread = new HandlerThread(getClass().getSimpleName());
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
         mFtds = new ArrayList<>();
 
         setUpInfraNetwork();
-        mController.setEnabledAndWait(true);
-        mController.joinAndWait(DEFAULT_DATASET);
 
         // Creates a infra network device.
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
@@ -151,8 +161,7 @@
     @After
     public void tearDown() throws Exception {
         mController.setTestNetworkAsUpstreamAndWait(null);
-        mController.leaveAndWait();
-        tearDownInfraNetwork();
+        TestTunNetworkUtils.tearDownAllInfraNetworks();
 
         mHandlerThread.quitSafely();
         mHandlerThread.join();
@@ -219,19 +228,13 @@
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
         Inet6Address ftdOmr = ftd.getOmrAddress();
-        // Create a new infra network and let Thread prefer it
-        TestNetworkTracker oldInfraNetworkTracker = mInfraNetworkTracker;
-        try {
-            setUpInfraNetwork();
-            mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
-            startInfraDeviceAndWaitForOnLinkAddr();
+        setUpInfraNetwork();
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        startInfraDeviceAndWaitForOnLinkAddr();
 
-            mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
 
-            assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
-        } finally {
-            runAsShell(MANAGE_TEST_NETWORKS, () -> oldInfraNetworkTracker.teardown());
-        }
+        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
     }
 
     @Test
@@ -608,8 +611,6 @@
         subscribeMulticastAddressAndWait(ftd, GROUP_ADDR_SCOPE_5);
         Inet6Address ftdOmr = ftd.getOmrAddress();
 
-        // Destroy infra link and re-create
-        tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         startInfraDeviceAndWaitForOnLinkAddr();
@@ -635,8 +636,6 @@
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
         Inet6Address ftdOmr = ftd.getOmrAddress();
 
-        // Destroy infra link and re-create
-        tearDownInfraNetwork();
         setUpInfraNetwork();
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         startInfraDeviceAndWaitForOnLinkAddr();
@@ -672,10 +671,11 @@
         }
     }
 
+    @Ignore("TODO: b/376573921 - Enable when it's not flaky at all")
     @Test
     public void nat64_withAilNat64Prefix_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded()
             throws Exception {
-        tearDownInfraNetwork();
+        TestTunNetworkUtils.tearDownInfraNetwork(mInfraNetworkTracker);
         LinkProperties lp = new LinkProperties();
         // NAT64 feature requires the infra network to have an IPv4 default route.
         lp.addRoute(
@@ -693,7 +693,7 @@
                         RouteInfo.RTN_UNICAST,
                         1500 /* mtu */));
         lp.setNat64Prefix(AIL_NAT64_PREFIX);
-        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController, lp);
+        mInfraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(mContext, mController, lp);
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
@@ -709,16 +709,12 @@
     }
 
     private void setUpInfraNetwork() throws Exception {
-        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController);
-    }
-
-    private void tearDownInfraNetwork() {
-        IntegrationTestUtils.tearDownInfraNetwork(mInfraNetworkTracker);
+        mInfraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(mContext, mController);
     }
 
     private void startInfraDeviceAndWaitForOnLinkAddr() {
         mInfraDevice =
-                IntegrationTestUtils.startInfraDeviceAndWaitForOnLinkAddr(mInfraNetworkReader);
+                TestTunNetworkUtils.startInfraDeviceAndWaitForOnLinkAddr(mInfraNetworkReader);
     }
 
     private void assertInfraLinkMemberOfGroup(Inet6Address address) throws Exception {
diff --git a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
new file mode 100644
index 0000000..3c9aa07
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread
+
+import android.content.Context
+import android.net.DnsResolver.CLASS_IN
+import android.net.DnsResolver.TYPE_A
+import android.net.DnsResolver.TYPE_AAAA
+import android.net.InetAddresses.parseNumericAddress
+import android.net.thread.utils.FullThreadDevice
+import android.net.thread.utils.InfraNetworkDevice
+import android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET
+import android.net.thread.utils.IntegrationTestUtils.enableThreadAndJoinNetwork
+import android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr
+import android.net.thread.utils.IntegrationTestUtils.leaveNetworkAndDisableThread
+import android.net.thread.utils.IntegrationTestUtils.newPacketReader
+import android.net.thread.utils.IntegrationTestUtils.waitFor
+import android.net.thread.utils.OtDaemonController
+import android.net.thread.utils.TestDnsServer
+import android.net.thread.utils.TestTunNetworkUtils
+import android.net.thread.utils.TestUdpEchoServer
+import android.net.thread.utils.ThreadFeatureCheckerRule
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature
+import android.net.thread.utils.ThreadNetworkControllerWrapper
+import android.os.Handler
+import android.os.HandlerThread
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.DnsPacket.ANSECTION
+import com.android.testutils.PollPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.google.common.truth.Truth.assertThat
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.time.Duration
+import org.junit.After
+import org.junit.AfterClass
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/** Integration test cases for Thread Internet Access features. */
+@RunWith(AndroidJUnit4::class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
+@LargeTest
+class InternetAccessTest {
+    companion object {
+        private val TAG = BorderRoutingTest::class.java.simpleName
+        private val NUM_FTD = 1
+        private val DNS_SERVER_ADDR = parseNumericAddress("8.8.8.8") as Inet4Address
+        private val UDP_ECHO_SERVER_ADDRESS =
+            InetSocketAddress(parseNumericAddress("1.2.3.4"), 12345)
+        private val ANSWER_RECORDS =
+            listOf(
+                DnsPacket.DnsRecord.makeAOrAAAARecord(
+                    ANSECTION,
+                    "google.com",
+                    CLASS_IN,
+                    30 /* ttl */,
+                    parseNumericAddress("1.2.3.4"),
+                ),
+                DnsPacket.DnsRecord.makeAOrAAAARecord(
+                    ANSECTION,
+                    "google.com",
+                    CLASS_IN,
+                    30 /* ttl */,
+                    parseNumericAddress("2001::234"),
+                ),
+            )
+
+        @BeforeClass
+        @JvmStatic
+        fun beforeClass() {
+            enableThreadAndJoinNetwork(DEFAULT_DATASET)
+        }
+
+        @AfterClass
+        @JvmStatic
+        fun afterClass() {
+            leaveNetworkAndDisableThread()
+        }
+    }
+
+    @get:Rule val threadRule = ThreadFeatureCheckerRule()
+
+    private val context: Context = ApplicationProvider.getApplicationContext()
+    private val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context))
+    private lateinit var otCtl: OtDaemonController
+    private lateinit var handlerThread: HandlerThread
+    private lateinit var handler: Handler
+    private lateinit var infraNetworkTracker: TestNetworkTracker
+    private lateinit var ftds: ArrayList<FullThreadDevice>
+    private lateinit var infraNetworkReader: PollPacketReader
+    private lateinit var infraDevice: InfraNetworkDevice
+    private lateinit var dnsServer: TestDnsServer
+    private lateinit var udpEchoServer: TestUdpEchoServer
+
+    @Before
+    @Throws(Exception::class)
+    fun setUp() {
+        otCtl = OtDaemonController()
+
+        handlerThread = HandlerThread(javaClass.simpleName)
+        handlerThread.start()
+        handler = Handler(handlerThread.looper)
+        ftds = ArrayList()
+
+        infraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(context, controller)
+
+        // Create an infra network device.
+        infraNetworkReader = newPacketReader(infraNetworkTracker.testIface, handler)
+        infraDevice = TestTunNetworkUtils.startInfraDeviceAndWaitForOnLinkAddr(infraNetworkReader)
+
+        // Create a DNS server
+        dnsServer = TestDnsServer(infraNetworkReader, DNS_SERVER_ADDR, ANSWER_RECORDS)
+
+        // Create a UDP echo server
+        udpEchoServer = TestUdpEchoServer(infraNetworkReader, UDP_ECHO_SERVER_ADDRESS)
+
+        // Create Ftds
+        for (i in 0 until NUM_FTD) {
+            ftds.add(FullThreadDevice(15 + i /* node ID */))
+        }
+    }
+
+    @After
+    @Throws(Exception::class)
+    fun tearDown() {
+        controller.setTestNetworkAsUpstreamAndWait(null)
+        TestTunNetworkUtils.tearDownAllInfraNetworks()
+
+        dnsServer.stop()
+        udpEchoServer.stop()
+
+        handlerThread.quitSafely()
+        handlerThread.join()
+
+        ftds.forEach { it.destroy() }
+        ftds.clear()
+    }
+
+    @Test
+    fun nat64Enabled_threadDeviceResolvesHost_hostIsResolved() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        dnsServer.start()
+
+        val ipv4Addresses =
+            ftd.resolveHost("google.com", TYPE_A).map { extractIpv4AddressFromMappedAddress(it) }
+        assertThat(ipv4Addresses).isEqualTo(listOf(parseNumericAddress("1.2.3.4")))
+        val ipv6Addresses = ftd.resolveHost("google.com", TYPE_AAAA)
+        assertThat(ipv6Addresses).isEqualTo(listOf(parseNumericAddress("2001::234")))
+    }
+
+    @Test
+    fun nat64Disabled_threadDeviceResolvesHost_hostIsNotResolved() {
+        controller.setNat64EnabledAndWait(false)
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        dnsServer.start()
+
+        assertThat(ftd.resolveHost("google.com", TYPE_A)).isEmpty()
+        assertThat(ftd.resolveHost("google.com", TYPE_AAAA)).isEmpty()
+    }
+
+    @Test
+    fun nat64Enabled_threadDeviceSendsUdpToEchoServer_replyIsReceived() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        udpEchoServer.start()
+
+        ftd.udpOpen()
+        ftd.udpSend("Hello,Thread", UDP_ECHO_SERVER_ADDRESS.address, UDP_ECHO_SERVER_ADDRESS.port)
+        val reply = ftd.udpReceive()
+        assertThat(reply).isEqualTo("Hello,Thread")
+    }
+
+    @Test
+    fun nat64Enabled_afterInfraNetworkSwitch_threadDeviceSendsUdpToEchoServer_replyIsReceived() {
+        controller.setNat64EnabledAndWait(true)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        infraNetworkTracker = TestTunNetworkUtils.setUpInfraNetwork(context, controller)
+        infraNetworkReader = newPacketReader(infraNetworkTracker.testIface, handler)
+        udpEchoServer = TestUdpEchoServer(infraNetworkReader, UDP_ECHO_SERVER_ADDRESS)
+        val ftd = ftds[0]
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
+        waitFor({ otCtl.hasNat64PrefixInNetdata() }, Duration.ofSeconds(10))
+        udpEchoServer.start()
+
+        ftd.udpOpen()
+        ftd.udpSend("Hello,Thread", UDP_ECHO_SERVER_ADDRESS.address, UDP_ECHO_SERVER_ADDRESS.port)
+        val reply = ftd.udpReceive()
+        assertThat(reply).isEqualTo("Hello,Thread")
+    }
+
+    private fun extractIpv4AddressFromMappedAddress(address: InetAddress): Inet4Address {
+        return InetAddress.getByAddress(address.address.slice(12 until 16).toByteArray())
+            as Inet4Address
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 2afca5f..6c2a9bb 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -30,6 +30,8 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -50,6 +52,9 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.HexDump;
+
 import com.google.common.truth.Correspondence;
 
 import org.junit.After;
@@ -64,6 +69,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Random;
 import java.util.concurrent.CompletableFuture;
@@ -441,6 +447,40 @@
                 .containsExactly("key1", bytes(0x01, 0x02), "key2", bytes(3));
     }
 
+    @Test
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
+    // are decoupled from this test.
+    public void trelFeatureFlagEnabled_trelServicePublished() throws Exception {
+        assumeTrue(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_trel._udp");
+        assertThat(discoveredService).isNotNull();
+        // Resolve service with the current TREL port, otherwise it may return stale service from
+        // a previous infra link setup.
+        NsdServiceInfo trelService =
+                resolveServiceUntil(
+                        mNsdManager, discoveredService, s -> s.getPort() == mOtCtl.getTrelPort());
+
+        Map<String, byte[]> txtMap = trelService.getAttributes();
+        assertThat(HexDump.toHexString(txtMap.get("xa")).toLowerCase(Locale.ROOT))
+                .isEqualTo(mOtCtl.getExtendedAddr().toLowerCase(Locale.ROOT));
+        assertThat(HexDump.toHexString(txtMap.get("xp")).toLowerCase(Locale.ROOT))
+                .isEqualTo(mOtCtl.getExtendedPanId().toLowerCase(Locale.ROOT));
+    }
+
+    @Test
+    // TODO: move this case out of ServiceDiscoveryTest when the service discovery utilities
+    // are decoupled from this test.
+    public void trelFeatureFlagDisabled_trelServiceNotPublished() throws Exception {
+        assumeFalse(
+                DeviceConfigUtils.getDeviceConfigPropertyBoolean(
+                        "thread_network", "TrelFeature__enabled", false));
+
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_trel._udp"));
+    }
+
     private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
             throws InterruptedException, ExecutionException, TimeoutException {
         mNsdManager.registerService(serviceInfo, PROTOCOL_DNS_SD, listener);
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 61b6eac..d41550b 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -16,36 +16,47 @@
 
 package android.net.thread;
 
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
 import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_CONFIG;
 import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.getPrefixesFromNetData;
 import static android.net.thread.utils.IntegrationTestUtils.getThreadNetwork;
 import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
+import static android.net.thread.utils.ThreadNetworkControllerWrapper.JOIN_TIMEOUT;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static java.util.concurrent.TimeUnit.SECONDS;
+
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
+import android.net.thread.utils.ThreadStateListener;
 import android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -66,6 +77,7 @@
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
@@ -83,6 +95,8 @@
     // The maximum time for changes to be propagated to netdata.
     private static final Duration NET_DATA_UPDATE_TIMEOUT = Duration.ofSeconds(1);
 
+    private static final Duration NETWORK_CALLBACK_TIMEOUT = Duration.ofSeconds(10);
+
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -93,6 +107,8 @@
                                     + "B9D351B40C0402A0FFF8");
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
+    private static final ThreadConfiguration DEFAULT_CONFIG =
+            new ThreadConfiguration.Builder().build();
 
     private static final Inet6Address GROUP_ADDR_ALL_ROUTERS =
             (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
@@ -124,8 +140,11 @@
 
     @After
     public void tearDown() throws Exception {
+        ThreadStateListener.stopAllListeners();
+
         mController.setTestNetworkAsUpstreamAndWait(null);
         mController.leaveAndWait();
+        mController.setConfigurationAndWait(DEFAULT_CONFIG);
 
         mFtd.destroy();
         mExecutor.shutdownNow();
@@ -253,6 +272,20 @@
     }
 
     @Test
+    public void joinNetwork_joinTheSameNetworkTwice_neverDetached() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mController.waitForRole(DEVICE_ROLE_LEADER, JOIN_TIMEOUT);
+
+        var listener = ThreadStateListener.startListener(mController.get());
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        assertThat(
+                        listener.pollForAnyRoleOf(
+                                List.of(DEVICE_ROLE_DETACHED, DEVICE_ROLE_STOPPED), JOIN_TIMEOUT))
+                .isNull();
+    }
+
+    @Test
     public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
@@ -327,6 +360,44 @@
                 .isFalse();
     }
 
+    @Test
+    public void setConfiguration_disableBorderRouter_noBrfunctionsEnabled() throws Exception {
+        NetworkRequest request =
+                new NetworkRequest.Builder()
+                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .build();
+        startFtdLeader(mFtd, DEFAULT_DATASET);
+
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        mController.joinAndWait(DEFAULT_DATASET);
+        NetworkCapabilities caps = registerNetworkCallbackAndWait(request);
+
+        assertThat(caps.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)).isFalse();
+        assertThat(mOtCtl.getBorderRoutingState()).ignoringCase().isEqualTo("disabled");
+        assertThat(mOtCtl.getSrpServerState()).ignoringCase().isNotEqualTo("disabled");
+        // TODO: b/376217403 - enables / disables Border Agent at runtime
+    }
+
+    private NetworkCapabilities registerNetworkCallbackAndWait(NetworkRequest request)
+            throws Exception {
+        CompletableFuture<Network> networkFuture = new CompletableFuture<>();
+        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+        ConnectivityManager.NetworkCallback callback =
+                new ConnectivityManager.NetworkCallback() {
+                    @Override
+                    public void onAvailable(Network network) {
+                        networkFuture.complete(network);
+                    }
+                };
+
+        runAsShell(ACCESS_NETWORK_STATE, () -> cm.registerNetworkCallback(request, callback));
+
+        assertThat(networkFuture.get(NETWORK_CALLBACK_TIMEOUT.getSeconds(), SECONDS)).isNotNull();
+        return runAsShell(
+                ACCESS_NETWORK_STATE, () -> cm.getNetworkCapabilities(networkFuture.get()));
+    }
+
     // TODO (b/323300829): add more tests for integration with linux platform and
     // ConnectivityService
 
@@ -341,6 +412,14 @@
         ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
     }
 
+    /** Starts a Thread FTD device as a leader. */
+    private void startFtdLeader(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("leader"), Duration.ofSeconds(8));
+    }
+
     /**
      * Starts a UDP echo server and replies to the first UDP message.
      *
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 32e3b95..2f0ab34 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -189,7 +189,7 @@
 
         final String result = runThreadCommand("config");
 
-        assertThat(result).contains("Nat64Enabled=true");
+        assertThat(result).contains("nat64Enabled=true");
     }
 
     @Test
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 083a841..209eed6 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -15,6 +15,8 @@
  */
 package android.net.thread.utils;
 
+import static android.net.DnsResolver.TYPE_A;
+import static android.net.DnsResolver.TYPE_AAAA;
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 
@@ -232,8 +234,8 @@
         return matcher.group(4);
     }
 
-    /** Sends a UDP message to given IPv6 address and port. */
-    public void udpSend(String message, Inet6Address serverAddr, int serverPort) {
+    /** Sends a UDP message to given IP address and port. */
+    public void udpSend(String message, InetAddress serverAddr, int serverPort) {
         executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message);
     }
 
@@ -354,6 +356,31 @@
         executeCommand("dns config " + address);
     }
 
+    /** Resolves the {@code queryType} record of the {@code hostname} via DNS. */
+    public List<InetAddress> resolveHost(String hostname, int queryType) {
+        // CLI output:
+        // DNS response for hostname.com. - fd12::abc1 TTL:50 fd12::abc2 TTL:50 fd12::abc3 TTL:50
+
+        String command;
+        switch (queryType) {
+            case TYPE_A -> command = "resolve4";
+            case TYPE_AAAA -> command = "resolve";
+            default -> throw new IllegalArgumentException("Invalid query type: " + queryType);
+        }
+        final List<InetAddress> addresses = new ArrayList<>();
+        String line;
+        try {
+            line = executeCommand("dns " + command + " " + hostname).get(0);
+        } catch (IllegalStateException e) {
+            return addresses;
+        }
+        final String[] addressTtlPairs = line.split("-")[1].strip().split(" ");
+        for (int i = 0; i < addressTtlPairs.length; i += 2) {
+            addresses.add(InetAddresses.parseNumericAddress(addressTtlPairs[i]));
+        }
+        return addresses;
+    }
+
     /** Returns the first browsed service instance of {@code serviceType}. */
     public NsdServiceInfo browseService(String serviceType) {
         // CLI output:
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index dc2a9c9..07d0390 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -590,6 +590,27 @@
         return ftd.omrAddress
     }
 
+    /** Enables Thread and joins the specified Thread network. */
+    @JvmStatic
+    fun enableThreadAndJoinNetwork(dataset: ActiveOperationalDataset) {
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        OtDaemonController().factoryReset();
+
+        val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
+        val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+        controller.setEnabledAndWait(true);
+        controller.joinAndWait(dataset);
+    }
+
+    /** Leaves the Thread network and disables Thread. */
+    @JvmStatic
+    fun leaveNetworkAndDisableThread() {
+        val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
+        val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+        controller.leaveAndWait();
+        controller.setEnabledAndWait(false);
+    }
+
     private open class DefaultDiscoveryListener : NsdManager.DiscoveryListener {
         override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {}
         override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {}
@@ -628,57 +649,6 @@
         )
     }
 
-    private fun defaultLinkProperties(): LinkProperties {
-        val lp = LinkProperties()
-        // TODO: use a fake DNS server
-        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
-        // NAT64 feature requires the infra network to have an IPv4 default route.
-        lp.addRoute(
-            RouteInfo(
-                IpPrefix("0.0.0.0/0") /* destination */,
-                null /* gateway */,
-                null /* iface */,
-                RouteInfo.RTN_UNICAST, 1500 /* mtu */
-            )
-        )
-        return lp
-    }
-
-    @JvmStatic
-    @JvmOverloads
-    fun startInfraDeviceAndWaitForOnLinkAddr(
-            pollPacketReader: PollPacketReader,
-            macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6")
-    ): InfraNetworkDevice {
-        val infraDevice = InfraNetworkDevice(macAddress, pollPacketReader)
-        infraDevice.runSlaac(Duration.ofSeconds(60))
-        requireNotNull(infraDevice.ipv6Addr)
-        return infraDevice
-    }
-
-    @JvmStatic
-    @JvmOverloads
-    @Throws(java.lang.Exception::class)
-    fun setUpInfraNetwork(
-        context: Context,
-        controller: ThreadNetworkControllerWrapper,
-        lp: LinkProperties = defaultLinkProperties()
-    ): TestNetworkTracker {
-        val infraNetworkTracker: TestNetworkTracker =
-            runAsShell(
-                MANAGE_TEST_NETWORKS,
-                supplier = { initTestNetwork(context, lp, setupTimeoutMs = 5000) })
-        val infraNetworkName: String = infraNetworkTracker.testIface.getInterfaceName()
-        controller.setTestNetworkAsUpstreamAndWait(infraNetworkName)
-
-        return infraNetworkTracker
-    }
-
-    @JvmStatic
-    fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
-        runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
-    }
-
     /**
      * Stop the ot-daemon by shell command.
      */
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
index 046d9bf..9fbfa45 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
@@ -54,6 +54,16 @@
         SystemClock.sleep(500);
     }
 
+    /** Returns the output string of the "ot-ctl br state" command. */
+    public String getBorderRoutingState() {
+        return executeCommandAndParse("br state").getFirst();
+    }
+
+    /** Returns the output string of the "ot-ctl srp server state" command. */
+    public String getSrpServerState() {
+        return executeCommandAndParse("srp server state").getFirst();
+    }
+
     /** Returns the list of IPv6 addresses on ot-daemon. */
     public List<Inet6Address> getAddresses() {
         return executeCommandAndParse("ipaddr").stream()
@@ -134,6 +144,18 @@
         executeCommand("netdata register");
     }
 
+    public int getTrelPort() {
+        return Integer.parseInt(executeCommandAndParse("trel port").get(0));
+    }
+
+    public String getExtendedAddr() {
+        return executeCommandAndParse("extaddr").get(0);
+    }
+
+    public String getExtendedPanId() {
+        return executeCommandAndParse("extpanid").get(0);
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
new file mode 100644
index 0000000..f97c0f2
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.system.OsConstants.IPPROTO_IP
+import android.system.OsConstants.IPPROTO_UDP
+import com.android.net.module.util.DnsPacket
+import com.android.net.module.util.PacketBuilder
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+/**
+ * A class that simulates a DNS server.
+ *
+ * <p>The server responds to DNS requests with the given {@code answerRecords}.
+ *
+ * @param packetReader the packet reader to poll DNS requests from
+ * @param serverAddress the address of the DNS server
+ * @param answerRecords the records to respond to the DNS requests
+ */
+class TestDnsServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetAddress,
+    private val serverAnswers: List<DnsPacket.DnsRecord>,
+) : TestUdpServer(packetReader, InetSocketAddress(serverAddress, DNS_UDP_PORT)) {
+    companion object {
+        private val TAG = TestDnsServer::class.java.simpleName
+        private const val DNS_UDP_PORT = 53
+    }
+
+    private class TestDnsPacket : DnsPacket {
+
+        constructor(buf: ByteArray) : super(buf)
+
+        constructor(
+            header: DnsHeader,
+            qd: List<DnsRecord>,
+            an: List<DnsRecord>,
+        ) : super(header, qd, an) {}
+
+        val header = super.mHeader
+        val records = super.mRecords
+    }
+
+    override fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer? {
+        val requestDnsPacket = TestDnsPacket(requestUdpPayload)
+        val requestDnsHeader = requestDnsPacket.header
+
+        val answerRecords =
+            buildDnsAnswerRecords(requestDnsPacket.records[DnsPacket.QDSECTION], serverAnswers)
+        // TODO: return NXDOMAIN if no answer is found.
+        val responseFlags = 1 shl 15 // QR bit
+        val responseDnsHeader =
+            DnsPacket.DnsHeader(
+                requestDnsHeader.id,
+                responseFlags,
+                requestDnsPacket.records[DnsPacket.QDSECTION].size,
+                answerRecords.size,
+            )
+        val responseDnsPacket =
+            TestDnsPacket(
+                responseDnsHeader,
+                requestDnsPacket.records[DnsPacket.QDSECTION],
+                answerRecords,
+            )
+
+        val buf =
+            PacketBuilder.allocate(
+                false /* hasEther */,
+                IPPROTO_IP,
+                IPPROTO_UDP,
+                responseDnsPacket.bytes.size,
+            )
+
+        val packetBuilder = PacketBuilder(buf)
+        packetBuilder.writeIpv4Header(
+            requestIpv4Header.tos,
+            requestIpv4Header.id,
+            requestIpv4Header.flagsAndFragmentOffset,
+            0x40 /* ttl */,
+            IPPROTO_UDP.toByte(),
+            requestIpv4Header.dstIp, /* srcIp */
+            requestIpv4Header.srcIp, /* dstIp */
+        )
+        packetBuilder.writeUdpHeader(
+            requestUdpHeader.dstPort.toShort() /* srcPort */,
+            requestUdpHeader.srcPort.toShort(), /* dstPort */
+        )
+        buf.put(responseDnsPacket.bytes)
+
+        return packetBuilder.finalizePacket()
+    }
+
+    private fun buildDnsAnswerRecords(
+        questions: List<DnsPacket.DnsRecord>,
+        serverAnswers: List<DnsPacket.DnsRecord>,
+    ): List<DnsPacket.DnsRecord> {
+        val answers = ArrayList<DnsPacket.DnsRecord>()
+        for (answer in serverAnswers) {
+            if (
+                questions.any {
+                    answer.dName.equals(it.dName, ignoreCase = true) && answer.nsType == it.nsType
+                }
+            ) {
+                answers.add(answer)
+            }
+        }
+        return answers
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt b/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
new file mode 100644
index 0000000..1667980
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.Manifest.permission.MANAGE_TEST_NETWORKS
+import android.content.Context
+import android.net.InetAddresses.parseNumericAddress
+import android.net.IpPrefix
+import android.net.LinkProperties
+import android.net.MacAddress
+import android.net.RouteInfo
+import com.android.testutils.PollPacketReader
+import com.android.testutils.TestNetworkTracker
+import com.android.testutils.initTestNetwork
+import com.android.testutils.runAsShell
+import java.time.Duration
+
+object TestTunNetworkUtils {
+    private val networkTrackers = mutableListOf<TestNetworkTracker>()
+
+    @JvmStatic
+    @JvmOverloads
+    fun setUpInfraNetwork(
+        context: Context,
+        controller: ThreadNetworkControllerWrapper,
+        lp: LinkProperties = defaultLinkProperties(),
+    ): TestNetworkTracker {
+        val infraNetworkTracker: TestNetworkTracker =
+            runAsShell(
+                MANAGE_TEST_NETWORKS,
+                supplier = { initTestNetwork(context, lp, setupTimeoutMs = 5000) },
+            )
+        val infraNetworkName: String = infraNetworkTracker.testIface.getInterfaceName()
+        controller.setTestNetworkAsUpstreamAndWait(infraNetworkName)
+        networkTrackers.add(infraNetworkTracker)
+
+        return infraNetworkTracker
+    }
+
+    @JvmStatic
+    fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
+        runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
+    }
+
+    @JvmStatic
+    fun tearDownAllInfraNetworks() {
+        networkTrackers.forEach { tearDownInfraNetwork(it) }
+        networkTrackers.clear()
+    }
+
+    @JvmStatic
+    @JvmOverloads
+    fun startInfraDeviceAndWaitForOnLinkAddr(
+        pollPacketReader: PollPacketReader,
+        macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6"),
+    ): InfraNetworkDevice {
+        val infraDevice = InfraNetworkDevice(macAddress, pollPacketReader)
+        infraDevice.runSlaac(Duration.ofSeconds(60))
+        requireNotNull(infraDevice.ipv6Addr)
+        return infraDevice
+    }
+
+    private fun defaultLinkProperties(): LinkProperties {
+        val lp = LinkProperties()
+        // TODO: use a fake DNS server
+        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+            RouteInfo(
+                IpPrefix("0.0.0.0/0") /* destination */,
+                null /* gateway */,
+                null /* iface */,
+                RouteInfo.RTN_UNICAST,
+                1500, /* mtu */
+            )
+        )
+        return lp
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
new file mode 100644
index 0000000..9fcd6a4
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.system.OsConstants.IPPROTO_IP
+import android.system.OsConstants.IPPROTO_UDP
+import com.android.net.module.util.PacketBuilder
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+
+/**
+ * A class that simulates a UDP echo server that replies to incoming UDP message with the same
+ * payload.
+ *
+ * @param packetReader the packet reader to poll UDP requests from
+ * @param serverAddress the address and port of the UDP server
+ */
+class TestUdpEchoServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetSocketAddress,
+) : TestUdpServer(packetReader, serverAddress) {
+    companion object {
+        private val TAG = TestUdpEchoServer::class.java.simpleName
+    }
+
+    override fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer? {
+        val buf =
+            PacketBuilder.allocate(
+                false /* hasEther */,
+                IPPROTO_IP,
+                IPPROTO_UDP,
+                requestUdpPayload.size,
+            )
+
+        val packetBuilder = PacketBuilder(buf)
+        packetBuilder.writeIpv4Header(
+            requestIpv4Header.tos,
+            requestIpv4Header.id,
+            requestIpv4Header.flagsAndFragmentOffset,
+            0x40 /* ttl */,
+            IPPROTO_UDP.toByte(),
+            requestIpv4Header.dstIp, /* srcIp */
+            requestIpv4Header.srcIp, /* dstIp */
+        )
+        packetBuilder.writeUdpHeader(
+            requestUdpHeader.dstPort.toShort() /* srcPort */,
+            requestUdpHeader.srcPort.toShort(), /* dstPort */
+        )
+        buf.put(requestUdpPayload)
+
+        return packetBuilder.finalizePacket()
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt b/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
new file mode 100644
index 0000000..fb0942e
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils
+
+import android.net.thread.utils.IntegrationTestUtils.pollForPacket
+import com.android.net.module.util.Struct
+import com.android.net.module.util.structs.Ipv4Header
+import com.android.net.module.util.structs.UdpHeader
+import com.android.testutils.PollPacketReader
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import kotlin.concurrent.thread
+
+/**
+ * A class that simulates a UDP server that replies to incoming UDP messages.
+ *
+ * @param packetReader the packet reader to poll UDP requests from
+ * @param serverAddress the address and port of the UDP server
+ */
+abstract class TestUdpServer(
+    private val packetReader: PollPacketReader,
+    private val serverAddress: InetSocketAddress,
+) {
+    private val TAG = TestUdpServer::class.java.simpleName
+    private var workerThread: Thread? = null
+
+    /**
+     * Starts the UDP server to respond to UDP messages.
+     *
+     * <p> The server polls the UDP messages from the {@code packetReader} and responds with a
+     * message built by {@code buildResponse}. The server will automatically stop when it fails to
+     * poll a UDP request within the timeout (3000 ms, as defined in IntegrationTestUtils).
+     */
+    fun start() {
+        workerThread = thread {
+            var requestPacket: ByteArray
+            while (true) {
+                requestPacket = pollForUdpPacket() ?: break
+                val buf = ByteBuffer.wrap(requestPacket)
+                packetReader.sendResponse(buildResponse(buf) ?: break)
+            }
+        }
+    }
+
+    /** Stops the UDP server. */
+    fun stop() {
+        workerThread?.join()
+    }
+
+    /**
+     * Builds the UDP response for the given UDP request.
+     *
+     * @param ipv4Header the IPv4 header of the UDP request
+     * @param udpHeader the UDP header of the UDP request
+     * @param udpPayload the payload of the UDP request
+     * @return the UDP response
+     */
+    abstract fun buildResponse(
+        requestIpv4Header: Ipv4Header,
+        requestUdpHeader: UdpHeader,
+        requestUdpPayload: ByteArray,
+    ): ByteBuffer?
+
+    private fun pollForUdpPacket(): ByteArray? {
+        val filter =
+            fun(packet: ByteArray): Boolean {
+                val buf = ByteBuffer.wrap(packet)
+                val ipv4Header = Struct.parse(Ipv4Header::class.java, buf) ?: return false
+                val udpHeader = Struct.parse(UdpHeader::class.java, buf) ?: return false
+                return ipv4Header.dstIp == serverAddress.address &&
+                    udpHeader.dstPort == serverAddress.port
+            }
+        return pollForPacket(packetReader, filter)
+    }
+
+    private fun buildResponse(requestPacket: ByteBuffer): ByteBuffer? {
+        val requestIpv4Header = Struct.parse(Ipv4Header::class.java, requestPacket) ?: return null
+        val requestUdpHeader = Struct.parse(UdpHeader::class.java, requestPacket) ?: return null
+        val remainingRequestPacket = ByteArray(requestPacket.remaining())
+        requestPacket.get(remainingRequestPacket)
+
+        return buildResponse(requestIpv4Header, requestUdpHeader, remainingRequestPacket)
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
index 4354702..b6114f3 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -37,6 +37,7 @@
 import android.os.OutcomeReceiver;
 
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
@@ -53,6 +54,9 @@
 
     private final ThreadNetworkController mController;
 
+    private final List<Integer> mDeviceRoleUpdates = new ArrayList<>();
+    @Nullable private StateCallback mStateCallback;
+
     /**
      * Returns a new {@link ThreadNetworkControllerWrapper} instance or {@code null} if Thread
      * feature is not supported on this device.
@@ -71,6 +75,15 @@
     }
 
     /**
+     * Returns the underlying {@link ThreadNetworkController} object or {@code null} if the current
+     * platform doesn't support it.
+     */
+    @Nullable
+    public ThreadNetworkController get() {
+        return mController;
+    }
+
+    /**
      * Returns the Thread enabled state.
      *
      * <p>The value can be one of {@code ThreadNetworkController#STATE_*}.
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index c6a24ea..53b1eca 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -35,6 +35,7 @@
     static_libs: [
         "androidx.test.rules",
         "frameworks-base-testutils",
+        "framework-configinfrastructure.stubs.module_lib",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
         "framework-location.stubs.module_lib",
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java
new file mode 100644
index 0000000..c83cb7a
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkSpecifierTest.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.test.filters.SmallTest;
+
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+
+/** Tests for {@link ThreadNetworkSpecifier}. */
+@SmallTest
+@RunWith(Parameterized.class)
+public final class ThreadNetworkSpecifierTest {
+    public final byte[] mExtendedPanId;
+    public final OperationalDatasetTimestamp mActiveTimestamp;
+    public final boolean mRouterEligibleForLeader;
+
+    @Parameterized.Parameters
+    public static Collection specifierArguments() {
+        var timestampNow = OperationalDatasetTimestamp.fromInstant(Instant.now());
+        return Arrays.asList(
+                new Object[][] {
+                    {new byte[] {0, 1, 2, 3, 4, 5, 6, 7}, null, false},
+                    {new byte[] {1, 1, 1, 1, 2, 2, 2, 2}, timestampNow, true},
+                    {new byte[] {1, 1, 1, 1, 2, 2, 2, 2}, timestampNow, false},
+                });
+    }
+
+    public ThreadNetworkSpecifierTest(
+            byte[] extendedPanId,
+            OperationalDatasetTimestamp activeTimestamp,
+            boolean routerEligibleForLeader) {
+        mExtendedPanId = extendedPanId.clone();
+        mActiveTimestamp = activeTimestamp;
+        mRouterEligibleForLeader = routerEligibleForLeader;
+    }
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        ThreadNetworkSpecifier specifier =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+        assertParcelingIsLossless(specifier);
+    }
+
+    @Test
+    public void builder_correctValuesAreSet() {
+        ThreadNetworkSpecifier specifier =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+
+        assertThat(specifier.getExtendedPanId()).isEqualTo(mExtendedPanId);
+        assertThat(specifier.getActiveTimestamp()).isEqualTo(mActiveTimestamp);
+        assertThat(specifier.isRouterEligibleForLeader()).isEqualTo(mRouterEligibleForLeader);
+    }
+
+    @Test
+    public void builderConstructor_specifiersAreEqual() {
+        ThreadNetworkSpecifier specifier1 =
+                new ThreadNetworkSpecifier.Builder(mExtendedPanId)
+                        .setActiveTimestamp(mActiveTimestamp)
+                        .setRouterEligibleForLeader(mRouterEligibleForLeader)
+                        .build();
+
+        ThreadNetworkSpecifier specifier2 = new ThreadNetworkSpecifier.Builder(specifier1).build();
+
+        assertThat(specifier1).isEqualTo(specifier2);
+    }
+
+    @Test
+    public void equalsTester() {
+        var timestampNow = OperationalDatasetTimestamp.fromInstant(Instant.now());
+        new EqualsTester()
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(timestampNow)
+                                .setRouterEligibleForLeader(true)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(timestampNow)
+                                .setRouterEligibleForLeader(true)
+                                .build())
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {0, 1, 2, 3, 4, 5, 6, 7})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build())
+                .addEqualityGroup(
+                        new ThreadNetworkSpecifier.Builder(new byte[] {1, 1, 1, 1, 2, 2, 2, 2})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build(),
+                        new ThreadNetworkSpecifier.Builder(new byte[] {1, 1, 1, 1, 2, 2, 2, 2})
+                                .setActiveTimestamp(null)
+                                .setRouterEligibleForLeader(false)
+                                .build())
+                .testEquals();
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index e188491..9af0b53 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -17,6 +17,7 @@
 package com.android.server.thread;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
@@ -34,6 +35,7 @@
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
@@ -85,6 +87,7 @@
 import android.os.SystemClock;
 import android.os.UserManager;
 import android.os.test.TestLooper;
+import android.provider.DeviceConfig;
 import android.util.AtomicFile;
 
 import androidx.test.annotation.UiThreadTest;
@@ -102,6 +105,7 @@
 import com.android.server.thread.openthread.OtDaemonConfiguration;
 import com.android.server.thread.openthread.testing.FakeOtDaemon;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -258,6 +262,13 @@
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                () -> DeviceConfig.deleteProperty("thread_network", "TrelFeature__enabled"));
+    }
+
     @Test
     public void initialize_tunInterfaceAndNsdPublisherSetToOtDaemon() throws Exception {
         when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
@@ -324,6 +335,33 @@
     }
 
     @Test
+    public void initialize_trelFeatureDisabled_trelDisabledAtOtDaemon() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                () ->
+                        DeviceConfig.setProperty(
+                                "thread_network", "TrelFeature__enabled", "false", false));
+
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        assertThat(mFakeOtDaemon.isTrelEnabled()).isFalse();
+    }
+
+    @Test
+    public void initialize_trelFeatureEnabled_setTrelEnabledAtOtDamon() throws Exception {
+        runAsShell(
+                WRITE_DEVICE_CONFIG,
+                () ->
+                        DeviceConfig.setProperty(
+                                "thread_network", "TrelFeature__enabled", "true", false));
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        assertThat(mFakeOtDaemon.isTrelEnabled()).isTrue();
+    }
+
+    @Test
     public void getMeshcopTxtAttributes_emptyVendorName_accepted() {
         when(mResources.getString(eq(R.string.config_thread_vendor_name))).thenReturn("");
 
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 c0e99d7..640b0f1 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -359,7 +359,7 @@
         runShellCommand("config");
 
         verify(mErrorWriter, never()).println();
-        verify(mOutputWriter, times(1)).println(contains("Nat64Enabled=true"));
+        verify(mOutputWriter, times(1)).println(contains("nat64Enabled=true"));
     }
 
     @Test
diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java b/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java
new file mode 100644
index 0000000..21eb7d9
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadStateListener.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.utils;
+
+import static android.Manifest.permission.ACCESS_NETWORK_STATE;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.annotation.Nullable;
+import android.net.thread.ThreadNetworkController;
+import android.net.thread.ThreadNetworkController.StateCallback;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.ArrayTrackRecord;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A listener for sequential Thread state updates.
+ *
+ * <p>This is a wrapper around {@link ThreadNetworkController#registerStateCallback} to make
+ * synchronized access to Thread state updates easier.
+ */
+@VisibleForTesting
+public final class ThreadStateListener {
+    private static final List<ThreadStateListener> sListeners = new ArrayList<>();
+    private final ArrayTrackRecord<Integer> mDeviceRoleUpdates = new ArrayTrackRecord<>();
+    private final ArrayTrackRecord<Integer>.ReadHead mReadHead = mDeviceRoleUpdates.newReadHead();
+    private final ThreadNetworkController mController;
+    private final StateCallback mCallback =
+            new ThreadNetworkController.StateCallback() {
+                @Override
+                public void onDeviceRoleChanged(int newRole) {
+                    mDeviceRoleUpdates.add(newRole);
+                }
+                // Add more state update trackers here
+            };
+
+    /** Creates a new {@link ThreadStateListener} object and starts listening for state updates. */
+    public static ThreadStateListener startListener(ThreadNetworkController controller) {
+        var listener = new ThreadStateListener(controller);
+        sListeners.add(listener);
+        listener.start();
+        return listener;
+    }
+
+    /** Stops all listeners created by {@link #startListener}. */
+    public static void stopAllListeners() {
+        for (var listener : sListeners) {
+            listener.stop();
+        }
+        sListeners.clear();
+    }
+
+    private ThreadStateListener(ThreadNetworkController controller) {
+        mController = controller;
+    }
+
+    private void start() {
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                () -> mController.registerStateCallback(directExecutor(), mCallback));
+    }
+
+    private void stop() {
+        runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+    }
+
+    /**
+     * Polls for any role in {@code roles} starting after call to {@link #startListener}.
+     *
+     * <p>Returns the matched device role or {@code null} if timeout.
+     */
+    @Nullable
+    public Integer pollForAnyRoleOf(List<Integer> roles, Duration timeout) {
+        return mReadHead.poll(timeout.toMillis(), newRole -> (roles.contains(newRole)));
+    }
+}