Merge "Collect diagnostics without storage permission" into main
diff --git a/OWNERS_core_networking_xts b/OWNERS_core_networking_xts
index 9e4e4a1..60ca885 100644
--- a/OWNERS_core_networking_xts
+++ b/OWNERS_core_networking_xts
@@ -10,3 +10,5 @@
 # In addition to cherry-picks, flaky test fixes and no-op refactors, also for
 # NsdManager tests
 reminv@google.com #{LAST_RESORT_SUGGESTION}
+# Only for APF firmware tests (to verify correct behaviour of the wifi APF interpreter)
+yuyanghuang@google.com #{LAST_RESORT_SUGGESTION}
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 19dd492..531489d 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -47,15 +47,6 @@
 // as the above target may have different "enabled" values
 // depending on the branch
 
-apex_defaults {
-    name: "CronetInTetheringApexDefaults",
-    jni_libs: [
-        "cronet_aml_components_cronet_android_cronet",
-        "//external/cronet/third_party/boringssl:libcrypto",
-        "//external/cronet/third_party/boringssl:libssl",
-    ],
-}
-
 apex {
     name: "com.android.tethering",
     defaults: [
@@ -102,15 +93,14 @@
         "dscpPolicy.o",
         "netd.o",
         "offload.o",
-        "offload@mainline.o",
         "test.o",
-        "test@mainline.o",
     ],
     apps: [
         "ServiceConnectivityResources",
     ],
     prebuilts: [
         "current_sdkinfo",
+        "netbpfload.31rc",
         "netbpfload.33rc",
         "netbpfload.35rc",
         "ot-daemon.34rc",
diff --git a/Tethering/apex/canned_fs_config b/Tethering/apex/canned_fs_config
index 1f5fcfa..edc5515 100644
--- a/Tethering/apex/canned_fs_config
+++ b/Tethering/apex/canned_fs_config
@@ -1,3 +1,3 @@
-/bin/for-system 0 1000 0750
+/bin/for-system 1029 1000 0750
 /bin/for-system/clatd 1029 1029 06755
 /bin/netbpfload 0 0 0750
diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index e2498e4..d2a8c13 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -111,6 +111,7 @@
         "sdk_module-lib_current_framework-wifi",
     ],
     static_libs: [
+        "modules-utils-build",
         "com.android.net.flags-aconfig-java",
     ],
     aidl: {
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 0ac97f0..a8a471d 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -37,11 +37,13 @@
 import android.os.Process;
 import android.os.RemoteException;
 import android.os.ResultReceiver;
+import android.text.TextUtils;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.flags.Flags;
 
 import java.lang.annotation.Retention;
@@ -338,6 +340,11 @@
      * @hide
      */
     public static final int TETHER_ERROR_BLUETOOTH_SERVICE_PENDING = 19;
+    /**
+     * Never used outside Tethering.java.
+     * @hide
+     */
+    public static final int TETHER_ERROR_SOFT_AP_CALLBACK_PENDING = 20;
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -664,7 +671,7 @@
     }
 
     private void unsupportedAfterV() {
-        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        if (SdkLevel.isAtLeastB()) {
             throw new UnsupportedOperationException("Not supported after SDK version "
                     + Build.VERSION_CODES.VANILLA_ICE_CREAM);
         }
@@ -1257,6 +1264,44 @@
             return sj.toString();
         }
 
+        @SuppressLint("UnflaggedApi")
+        private static boolean supportsInterfaceName(int tetheringType) {
+            // TODO: Check the interface name for TETHERING_WIFI and TETHERING_WIFI_P2P once
+            //       they're actually used.
+            // Suppress lint for TETHERING_VIRTUAL since this method is only used internally.
+            return tetheringType == TETHERING_VIRTUAL;
+        }
+
+        private static boolean supportsConcurrentConnectivityScopes(int tetheringType) {
+            // Currently, only WIFI supports simultaneous local and global connectivity.
+            // This can't happen for REQUEST_TYPE_EXPLICIT requests, because
+            // TetheringRequest.Builder will not allow building an explicit TetheringRequest
+            // with TETHERING_WIFI and CONNECTIVITY_SCOPE_LOCAL, but when local-only hotspot
+            // is running, there is a REQUEST_TYPE_IMPLICIT request in the serving request list.
+            return tetheringType == TETHERING_WIFI;
+        }
+
+        /**
+         * Returns true if the other TetheringRequest "fuzzy" matches this one. This is used
+         * internally to match tracked requests with external requests from API calls, and to reject
+         * additional requests that the link layer has no capacity for.
+         * @hide
+         */
+        public boolean fuzzyMatches(final TetheringRequest other) {
+            if (other == null) return false;
+            final int type = getTetheringType();
+            if (type != other.getTetheringType()) return false;
+            if (supportsInterfaceName(type)
+                    && !TextUtils.equals(getInterfaceName(), other.getInterfaceName())) {
+                return false;
+            }
+            if (supportsConcurrentConnectivityScopes(type)
+                    && getConnectivityScope() != other.getConnectivityScope()) {
+                return false;
+            }
+            return true;
+        }
+
         /**
          * @hide
          */
@@ -1278,6 +1323,8 @@
         public boolean equalsIgnoreUidPackage(TetheringRequest otherRequest) {
             TetheringRequestParcel parcel = getParcel();
             TetheringRequestParcel otherParcel = otherRequest.getParcel();
+            // Note: Changes here should also be reflected in fuzzyMatches(TetheringRequest) when
+            //       appropriate.
             return parcel.requestType == otherParcel.requestType
                     && parcel.tetheringType == otherParcel.tetheringType
                     && Objects.equals(parcel.localIPv4Address, otherParcel.localIPv4Address)
diff --git a/Tethering/res/values-iw/strings.xml b/Tethering/res/values-iw/strings.xml
index f7fb4d5..b1c0a9c 100644
--- a/Tethering/res/values-iw/strings.xml
+++ b/Tethering/res/values-iw/strings.xml
@@ -17,7 +17,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="tethered_notification_title" msgid="5350162111436634622">"שיתוף האינטרנט או הנקודה לשיתוף אינטרנט פעילים"</string>
-    <string name="tethered_notification_message" msgid="2338023450330652098">"יש להקיש כדי להגדיר."</string>
+    <string name="tethered_notification_message" msgid="2338023450330652098">"יש ללחוץ כדי להגדיר."</string>
     <string name="disable_tether_notification_title" msgid="3183576627492925522">"שיתוף האינטרנט בין מכשירים מושבת"</string>
     <string name="disable_tether_notification_message" msgid="6655882039707534929">"לפרטים, יש לפנות לאדמין"</string>
     <string name="notification_channel_tethering_status" msgid="7030733422705019001">"סטטוס של נקודה לשיתוף אינטרנט ושיתוף אינטרנט בין מכשירים"</string>
diff --git a/Tethering/res/values-nb/strings.xml b/Tethering/res/values-nb/strings.xml
index e9024c0..fe91b82 100644
--- a/Tethering/res/values-nb/strings.xml
+++ b/Tethering/res/values-nb/strings.xml
@@ -17,7 +17,7 @@
 <resources xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="tethered_notification_title" msgid="5350162111436634622">"Internettdeling eller wifi-sone er aktiv"</string>
-    <string name="tethered_notification_message" msgid="2338023450330652098">"Trykk for å konfigurere."</string>
+    <string name="tethered_notification_message" msgid="2338023450330652098">"Konfigurer."</string>
     <string name="disable_tether_notification_title" msgid="3183576627492925522">"Internettdeling er slått av"</string>
     <string name="disable_tether_notification_message" msgid="6655882039707534929">"Kontakt administratoren din for å få mer informasjon"</string>
     <string name="notification_channel_tethering_status" msgid="7030733422705019001">"Status for wifi-sone og internettdeling"</string>
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 609d759..70a3442 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -415,12 +415,6 @@
         return mIpv4PrefixRequest;
     }
 
-    /** The TetheringRequest the IpServer started with. */
-    @Nullable
-    public TetheringRequest getTetheringRequest() {
-        return mTetheringRequest;
-    }
-
     /**
      * Get the latest list of DHCP leases that was reported. Must be called on the IpServer looper
      * thread.
@@ -852,16 +846,13 @@
         }
     }
 
-    private void removeRoutesFromNetworkAndLinkProperties(int netId,
-            @NonNull final List<RouteInfo> toBeRemoved) {
+    private void removeRoutesFromNetwork(int netId, @NonNull final List<RouteInfo> toBeRemoved) {
         final int removalFailures = NetdUtils.removeRoutesFromNetwork(
                 mNetd, netId, toBeRemoved);
         if (removalFailures > 0) {
             mLog.e("Failed to remove " + removalFailures
                     + " IPv6 routes from network " + netId + ".");
         }
-
-        for (RouteInfo route : toBeRemoved) mLinkProperties.removeRoute(route);
     }
 
     private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
@@ -888,7 +879,7 @@
         }
     }
 
-    private void addRoutesToNetworkAndLinkProperties(int netId,
+    private void addRoutesToNetwork(int netId,
             @NonNull final List<RouteInfo> toBeAdded) {
         // It's safe to call addInterfaceToNetwork() even if
         // the interface is already in the network.
@@ -901,16 +892,16 @@
             mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
             return;
         }
-
-        for (RouteInfo route : toBeAdded) mLinkProperties.addRoute(route);
     }
 
     private void configureLocalIPv6Routes(
             ArraySet<IpPrefix> deprecatedPrefixes, ArraySet<IpPrefix> newPrefixes) {
         // [1] Remove the routes that are deprecated.
         if (!deprecatedPrefixes.isEmpty()) {
-            removeRoutesFromNetworkAndLinkProperties(LOCAL_NET_ID,
-                    getLocalRoutesFor(mIfaceName, deprecatedPrefixes));
+            final List<RouteInfo> routesToBeRemoved =
+                    getLocalRoutesFor(mIfaceName, deprecatedPrefixes);
+            removeRoutesFromNetwork(LOCAL_NET_ID, routesToBeRemoved);
+            for (RouteInfo route : routesToBeRemoved) mLinkProperties.removeRoute(route);
         }
 
         // [2] Add only the routes that have not previously been added.
@@ -921,8 +912,10 @@
             }
 
             if (!addedPrefixes.isEmpty()) {
-                addRoutesToNetworkAndLinkProperties(LOCAL_NET_ID,
-                        getLocalRoutesFor(mIfaceName, addedPrefixes));
+                final List<RouteInfo> routesToBeAdded =
+                        getLocalRoutesFor(mIfaceName, addedPrefixes);
+                addRoutesToNetwork(LOCAL_NET_ID, routesToBeAdded);
+                for (RouteInfo route : routesToBeAdded) mLinkProperties.addRoute(route);
             }
         }
     }
@@ -1126,8 +1119,17 @@
             }
 
             try {
-                NetdUtils.tetherInterface(mNetd, LOCAL_NET_ID, mIfaceName,
-                        asIpPrefix(mIpv4Address));
+                // Enable IPv6, disable accepting RA, etc. See TetherController::tetherInterface()
+                // for more detail.
+                mNetd.tetherInterfaceAdd(mIfaceName);
+                NetdUtils.networkAddInterface(mNetd, LOCAL_NET_ID, mIfaceName,
+                        20 /* maxAttempts */, 50 /* pollingIntervalMs */);
+                // Activate a route to dest and IPv6 link local.
+                NetdUtils.modifyRoute(mNetd, NetdUtils.ModifyOperation.ADD, LOCAL_NET_ID,
+                        new RouteInfo(asIpPrefix(mIpv4Address), null, mIfaceName, RTN_UNICAST));
+                NetdUtils.modifyRoute(mNetd, NetdUtils.ModifyOperation.ADD, LOCAL_NET_ID,
+                        new RouteInfo(new IpPrefix("fe80::/64"), null, mIfaceName,
+                                RTN_UNICAST));
             } catch (RemoteException | ServiceSpecificException | IllegalStateException e) {
                 mLog.e("Error Tethering", e);
                 mLastError = TETHER_ERROR_TETHER_IFACE_ERROR;
@@ -1148,8 +1150,13 @@
             // all in sequence.
             stopIPv6();
 
+            // Reset interface for tethering.
             try {
-                NetdUtils.untetherInterface(mNetd, LOCAL_NET_ID, mIfaceName);
+                try {
+                    mNetd.tetherInterfaceRemove(mIfaceName);
+                } finally {
+                    mNetd.networkRemoveInterface(LOCAL_NET_ID, mIfaceName);
+                }
             } catch (RemoteException | ServiceSpecificException e) {
                 mLastError = TETHER_ERROR_UNTETHER_IFACE_ERROR;
                 mLog.e("Failed to untether interface: " + e);
@@ -1227,13 +1234,16 @@
             }
 
             // Remove deprecated routes from downstream network.
-            removeRoutesFromNetworkAndLinkProperties(LOCAL_NET_ID,
-                    List.of(getDirectConnectedRoute(deprecatedLinkAddress)));
+            final List<RouteInfo> routesToBeRemoved =
+                    List.of(getDirectConnectedRoute(deprecatedLinkAddress));
+            removeRoutesFromNetwork(LOCAL_NET_ID, routesToBeRemoved);
+            for (RouteInfo route : routesToBeRemoved) mLinkProperties.removeRoute(route);
             mLinkProperties.removeLinkAddress(deprecatedLinkAddress);
 
             // Add new routes to downstream network.
-            addRoutesToNetworkAndLinkProperties(LOCAL_NET_ID,
-                    List.of(getDirectConnectedRoute(mIpv4Address)));
+            final List<RouteInfo> routesToBeAdded = List.of(getDirectConnectedRoute(mIpv4Address));
+            addRoutesToNetwork(LOCAL_NET_ID, routesToBeAdded);
+            for (RouteInfo route : routesToBeAdded) mLinkProperties.addRoute(route);
             mLinkProperties.addLinkAddress(mIpv4Address);
 
             // Update local DNS caching server with new IPv4 address, otherwise, dnsmasq doesn't
diff --git a/Tethering/src/com/android/networkstack/tethering/RequestTracker.java b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
new file mode 100644
index 0000000..c91ff58
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack.tethering;
+
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
+
+import android.net.TetheringManager.TetheringRequest;
+import android.net.ip.IpServer;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Helper class to keep track of tethering requests.
+ * The intended usage of this class is
+ * 1) Add a pending request with {@link #addPendingRequest(TetheringRequest)} before asking the link
+ *    layer to start.
+ * 2) When the link layer is up, use {@link #getOrCreatePendingRequest(int)} to get a request to
+ *    start IP serving with.
+ * 3) Remove pending request with {@link #removePendingRequest(TetheringRequest)}.
+ * Note: This class is not thread-safe.
+ */
+public class RequestTracker {
+    private static final String TAG = RequestTracker.class.getSimpleName();
+
+    @NonNull
+    private final boolean mUseFuzzyMatching;
+
+    public RequestTracker(boolean useFuzzyMatching) {
+        mUseFuzzyMatching = useFuzzyMatching;
+    }
+
+    public enum AddResult {
+        /**
+         * Request was successfully added
+         */
+        SUCCESS,
+        /**
+         * Failure indicating that the request could not be added due to a request of the same type
+         * with conflicting parameters already pending. If so, we must stop tethering for the
+         * pending request before trying to add the result again. Only returned on V-.
+         */
+        FAILURE_DUPLICATE_REQUEST_RESTART,
+        /**
+         * Failure indicating that the request could not be added due to a fuzzy-matched request
+         * already pending or serving. Only returned on B+.
+         */
+        FAILURE_DUPLICATE_REQUEST_ERROR,
+    }
+
+    /**
+     * List of pending requests added by {@link #addPendingRequest(TetheringRequest)}
+     * There can be only one per type, since we remove every request of the
+     * same type when we add a request.
+     */
+    private final List<TetheringRequest> mPendingRequests = new ArrayList<>();
+    /**
+     * List of serving requests added by
+     * {@link #promoteRequestToServing(IpServer, TetheringRequest)}.
+     */
+    private final Map<IpServer, TetheringRequest> mServingRequests = new ArrayMap<>();
+
+    @VisibleForTesting
+    List<TetheringRequest> getPendingTetheringRequests() {
+        return new ArrayList<>(mPendingRequests);
+    }
+
+    /**
+     * Adds a pending request or fails with FAILURE_CONFLICTING_REQUEST_FAIL if the request
+     * fuzzy-matches an existing request (either pending or serving).
+     */
+    public AddResult addPendingRequestFuzzyMatched(@NonNull final TetheringRequest newRequest) {
+        List<TetheringRequest> existingRequests = new ArrayList<>();
+        existingRequests.addAll(mServingRequests.values());
+        existingRequests.addAll(mPendingRequests);
+        for (TetheringRequest request : existingRequests) {
+            if (request.fuzzyMatches(newRequest)) {
+                Log.i(TAG, "Cannot add pending request due to existing fuzzy-matched "
+                        + "request: " + request);
+                return AddResult.FAILURE_DUPLICATE_REQUEST_ERROR;
+            }
+        }
+
+        mPendingRequests.add(newRequest);
+        return AddResult.SUCCESS;
+    }
+
+    /**
+     * Add a pending request and listener. The request should be added before asking the link layer
+     * to start, and should be retrieved with {@link #getNextPendingRequest(int)} once the link
+     * layer comes up. The result of the add operation will be returned as an AddResult code.
+     */
+    public AddResult addPendingRequest(@NonNull final TetheringRequest newRequest) {
+        if (mUseFuzzyMatching) {
+            return addPendingRequestFuzzyMatched(newRequest);
+        }
+
+        // Check the existing requests to see if it is OK to add the new request.
+        for (TetheringRequest existingRequest : mPendingRequests) {
+            if (existingRequest.getTetheringType() != newRequest.getTetheringType()) {
+                continue;
+            }
+
+            // Can't add request if there's a request of the same type with different
+            // parameters.
+            if (!existingRequest.equalsIgnoreUidPackage(newRequest)) {
+                return AddResult.FAILURE_DUPLICATE_REQUEST_RESTART;
+            }
+        }
+
+        // Remove the existing pending request of the same type. We already filter out for
+        // conflicting parameters above, so these would have been equivalent anyway (except for
+        // UID).
+        removeAllPendingRequests(newRequest.getTetheringType());
+        mPendingRequests.add(newRequest);
+        return AddResult.SUCCESS;
+    }
+
+    /**
+     * Gets the next pending TetheringRequest of a given type, or creates a placeholder request if
+     * there are none.
+     * Note: There are edge cases where the pending request is absent and we must temporarily
+     * synthesize a placeholder request, such as if stopTethering was called before link
+     * layer went up, or if the link layer goes up without us poking it (e.g. adb shell
+     * cmd wifi start-softap). These placeholder requests only specify the tethering type
+     * and the default connectivity scope.
+     */
+    @NonNull
+    public TetheringRequest getOrCreatePendingRequest(int type) {
+        TetheringRequest pending = getNextPendingRequest(type);
+        if (pending != null) return pending;
+
+        Log.w(TAG, "No pending TetheringRequest for type " + type + " found, creating a"
+                + " placeholder request");
+        return createPlaceholderRequest(type);
+    }
+
+    /**
+     * Same as {@link #getOrCreatePendingRequest(int)} but returns {@code null} if there's no
+     * pending request found.
+     *
+     * @param type Tethering type of the pending request
+     * @return pending request or {@code null} if there are none.
+     */
+    @Nullable
+    public TetheringRequest getNextPendingRequest(int type) {
+        for (TetheringRequest request : mPendingRequests) {
+            if (request.getTetheringType() == type) return request;
+        }
+        return null;
+    }
+
+    /**
+     * Removes all pending requests of the given tethering type.
+     *
+     * @param type Tethering type
+     */
+    public void removeAllPendingRequests(final int type) {
+        mPendingRequests.removeIf(r -> r.getTetheringType() == type);
+    }
+
+    /**
+     * Removes a specific pending request.
+     *
+     * Note: For V-, this will be the same as removeAllPendingRequests to align with historical
+     * behavior.
+     *
+     * @param request Request to be removed
+     */
+    public void removePendingRequest(@NonNull TetheringRequest request) {
+        if (!mUseFuzzyMatching) {
+            // Remove all requests of the same type to match the historical behavior.
+            removeAllPendingRequests(request.getTetheringType());
+            return;
+        }
+
+        mPendingRequests.removeIf(r -> r.equals(request));
+    }
+
+    /**
+     * Removes a tethering request from the pending list and promotes it to serving with the
+     * IpServer that is using it.
+     * Note: If mUseFuzzyMatching is false, then the request will be removed from the pending list,
+     * but it will not be added to serving list.
+     */
+    public void promoteRequestToServing(@NonNull final IpServer ipServer,
+            @NonNull final TetheringRequest tetheringRequest) {
+        removePendingRequest(tetheringRequest);
+        if (!mUseFuzzyMatching) return;
+        mServingRequests.put(ipServer, tetheringRequest);
+    }
+
+
+    /**
+     * Returns the serving request tied to the given IpServer, or null if there is none.
+     * Note: If mUseFuzzyMatching is false, then this will always return null.
+     */
+    @Nullable
+    public TetheringRequest getServingRequest(@NonNull final IpServer ipServer) {
+        return mServingRequests.get(ipServer);
+    }
+
+    /**
+     * Removes the serving request tied to the given IpServer.
+     * Note: If mUseFuzzyMatching is false, then this is a no-op since serving requests are unused
+     * for that configuration.
+     */
+    public void removeServingRequest(@NonNull final IpServer ipServer) {
+        mServingRequests.remove(ipServer);
+    }
+
+    /**
+     * Removes all serving requests of the given tethering type.
+     *
+     * @param type Tethering type
+     */
+    public void removeAllServingRequests(final int type) {
+        mServingRequests.entrySet().removeIf(e -> e.getValue().getTetheringType() == type);
+    }
+
+    @VisibleForTesting
+    List<TetheringRequest> getServingTetheringRequests() {
+        return new ArrayList<>(mServingRequests.values());
+    }
+
+    /**
+     * Returns an existing (pending or serving) request that fuzzy matches the given request.
+     * Optionally specify matchUid to only return requests with the same uid.
+     */
+    public TetheringRequest findFuzzyMatchedRequest(
+            @NonNull final TetheringRequest tetheringRequest, boolean matchUid) {
+        List<TetheringRequest> allRequests = new ArrayList<>();
+        allRequests.addAll(getPendingTetheringRequests());
+        allRequests.addAll(mServingRequests.values());
+        for (TetheringRequest request : allRequests) {
+            if (!request.fuzzyMatches(tetheringRequest)) continue;
+            if (matchUid && tetheringRequest.getUid() != request.getUid()) continue;
+            return request;
+        }
+        return null;
+    }
+}
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index b50831d..0cf008b 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -29,7 +29,6 @@
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
 import static android.net.TetheringManager.ACTION_TETHER_STATE_CHANGED;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
-import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
 import static android.net.TetheringManager.EXTRA_ACTIVE_LOCAL_ONLY;
 import static android.net.TetheringManager.EXTRA_ACTIVE_TETHER;
 import static android.net.TetheringManager.EXTRA_AVAILABLE_TETHER;
@@ -44,9 +43,11 @@
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
 import static android.net.TetheringManager.TETHERING_WIGIG;
 import static android.net.TetheringManager.TETHER_ERROR_BLUETOOTH_SERVICE_PENDING;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_INTERNAL_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
+import static android.net.TetheringManager.TETHER_ERROR_SOFT_AP_CALLBACK_PENDING;
 import static android.net.TetheringManager.TETHER_ERROR_UNAVAIL_IFACE;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_IFACE;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
@@ -68,6 +69,8 @@
 import static android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED;
 import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
 
+import static com.android.networkstack.tethering.RequestTracker.AddResult.FAILURE_DUPLICATE_REQUEST_ERROR;
+import static com.android.networkstack.tethering.RequestTracker.AddResult.FAILURE_DUPLICATE_REQUEST_RESTART;
 import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_FORCE_USB_FUNCTIONS;
 import static com.android.networkstack.tethering.TetheringNotificationUpdater.DOWNSTREAM_NONE;
 import static com.android.networkstack.tethering.UpstreamNetworkMonitor.isCellular;
@@ -78,6 +81,9 @@
 import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_LEGACY_TETHER_WITH_TYPE_WIFI_SUCCESS;
 import static com.android.networkstack.tethering.metrics.TetheringStatsLog.CORE_NETWORKING_TERRIBLE_ERROR_OCCURRED__ERROR_TYPE__TYPE_TETHER_WITH_PLACEHOLDER_REQUEST;
 import static com.android.networkstack.tethering.util.TetheringMessageBase.BASE_MAIN_SM;
+import static com.android.networkstack.tethering.util.TetheringUtils.createImplicitLocalOnlyTetheringRequest;
+import static com.android.networkstack.tethering.util.TetheringUtils.createLegacyGlobalScopeTetheringRequest;
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
 
 import android.app.usage.NetworkStatsManager;
 import android.bluetooth.BluetoothAdapter;
@@ -109,13 +115,13 @@
 import android.net.TetheringManager.TetheringRequest;
 import android.net.Uri;
 import android.net.ip.IpServer;
+import android.net.wifi.SoftApState;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.p2p.WifiP2pGroup;
 import android.net.wifi.p2p.WifiP2pInfo;
 import android.net.wifi.p2p.WifiP2pManager;
 import android.os.Binder;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
@@ -241,11 +247,7 @@
     private final SharedLog mLog = new SharedLog(TAG);
     private final RemoteCallbackList<ITetheringEventCallback> mTetheringEventCallbacks =
             new RemoteCallbackList<>();
-    // Currently active tethering requests per tethering type. Only one of each type can be
-    // requested at a time. After a tethering type is requested, the map keeps tethering parameters
-    // to be used after the interface comes up asynchronously.
-    private final SparseArray<TetheringRequest> mPendingTetheringRequests =
-            new SparseArray<>();
+    private final RequestTracker mRequestTracker;
 
     private final Context mContext;
     private final ArrayMap<String, TetherState> mTetherStates;
@@ -292,7 +294,10 @@
     private SettingsObserver mSettingsObserver;
     private BluetoothPan mBluetoothPan;
     private PanServiceListener mBluetoothPanListener;
-    private final ArrayList<IIntResultListener> mPendingPanRequestListeners;
+    // Pending listener for starting Bluetooth tethering before the PAN service is connected. Once
+    // the service is connected, the bluetooth iface will be requested and the listener will be
+    // called.
+    private IIntResultListener mPendingPanRequestListener;
     // AIDL doesn't support Set<Integer>. Maintain a int bitmap here. When the bitmap is passed to
     // TetheringManager, TetheringManager would convert it to a set of Integer types.
     // mSupportedTypeBitmap should always be updated inside tethering internal thread but it may be
@@ -308,11 +313,7 @@
         mLooper = mDeps.makeTetheringLooper();
         mNotificationUpdater = mDeps.makeNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.makeTetheringMetrics(mContext);
-
-        // This is intended to ensrure that if something calls startTethering(bluetooth) just after
-        // bluetooth is enabled. Before onServiceConnected is called, store the calls into this
-        // list and handle them as soon as onServiceConnected is called.
-        mPendingPanRequestListeners = new ArrayList<>();
+        mRequestTracker = new RequestTracker(isTetheringWithSoftApConfigEnabled());
 
         mTetherStates = new ArrayMap<>();
         mConnectedClientsTracker = new ConnectedClientsTracker();
@@ -638,7 +639,7 @@
         // TODO: fix the teardown path to stop depending on interface state notifications.
         // These are not necessary since most/all link layers have their own teardown
         // notifications, and can race with those notifications.
-        if (enabled && Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        if (enabled && SdkLevel.isAtLeastB()) {
             return;
         }
 
@@ -704,14 +705,20 @@
             final IIntResultListener listener) {
         mHandler.post(() -> {
             final int type = request.getTetheringType();
-            final TetheringRequest unfinishedRequest = mPendingTetheringRequests.get(type);
-            // If tethering is already enabled with a different request,
-            // disable before re-enabling.
-            if (unfinishedRequest != null && !unfinishedRequest.equalsIgnoreUidPackage(request)) {
-                enableTetheringInternal(false /* disabled */, unfinishedRequest, null);
-                mEntitlementMgr.stopProvisioningIfNeeded(type);
+            RequestTracker.AddResult result = mRequestTracker.addPendingRequest(request);
+            // If tethering is already pending with a conflicting request, stop tethering before
+            // starting.
+            if (result == FAILURE_DUPLICATE_REQUEST_RESTART) {
+                stopTetheringInternal(type); // Also removes the request from the tracker.
+                mRequestTracker.addPendingRequest(request);
+            } else if (result == FAILURE_DUPLICATE_REQUEST_ERROR) {
+                // Reject any fuzzy matched request.
+                // TODO: Add a CTS test to verify back-to-back start/stop calls succeed. This must
+                // be for a non-Wifi type, since Wifi will reject the start calls if it hasn't
+                // brought down the SoftAP yet.
+                sendTetherResult(listener, TETHER_ERROR_DUPLICATE_REQUEST);
+                return;
             }
-            mPendingTetheringRequests.put(type, request);
 
             if (request.isExemptFromEntitlementCheck()) {
                 mEntitlementMgr.setExemptedDownstreamType(type);
@@ -730,41 +737,28 @@
         });
     }
 
-    private boolean isTetheringTypePendingOrServing(final int type) {
-        for (int i = 0; i < mPendingTetheringRequests.size(); i++) {
-            if (mPendingTetheringRequests.valueAt(i).getTetheringType() == type) return true;
-        }
-        for (TetherState state : mTetherStates.values()) {
-            // TODO: isCurrentlyServing only starts returning true once the IpServer has processed
-            // the CMD_TETHER_REQUESTED. Ensure that we consider the request to be serving even when
-            // that has not happened yet.
-            if (state.isCurrentlyServing() && state.ipServer.interfaceType() == type) return true;
-        }
-        return false;
-    }
-
     void stopTetheringRequest(@NonNull final TetheringRequest request,
             @NonNull final IIntResultListener listener) {
         if (!isTetheringWithSoftApConfigEnabled()) return;
+        final boolean hasNetworkSettings = hasCallingPermission(NETWORK_SETTINGS);
         mHandler.post(() -> {
-            final int type = request.getTetheringType();
-            if (isTetheringTypePendingOrServing(type)) {
+            if (mRequestTracker.findFuzzyMatchedRequest(request, !hasNetworkSettings) != null) {
+                final int type = request.getTetheringType();
                 stopTetheringInternal(type);
-                try {
-                    listener.onResult(TETHER_ERROR_NO_ERROR);
-                } catch (RemoteException ignored) { }
+                // TODO: We should send the success result after the waiting for tethering to
+                //       actually stop.
+                sendTetherResult(listener, TETHER_ERROR_NO_ERROR);
                 return;
             }
 
             // Request doesn't match any active requests, ignore.
-            try {
-                listener.onResult(TETHER_ERROR_UNKNOWN_REQUEST);
-            } catch (RemoteException ignored) { }
+            sendTetherResult(listener, TETHER_ERROR_UNKNOWN_REQUEST);
         });
     }
 
     void stopTetheringInternal(int type) {
-        mPendingTetheringRequests.remove(type);
+        mRequestTracker.removeAllPendingRequests(type);
+        mRequestTracker.removeAllServingRequests(type);
 
         // Using a placeholder here is ok since none of the disable APIs use the request for
         // anything. We simply need the tethering type to know which link layer to poke for removal.
@@ -783,7 +777,7 @@
         final int result;
         switch (type) {
             case TETHERING_WIFI:
-                result = setWifiTethering(enable);
+                result = setWifiTethering(enable, request, listener);
                 break;
             case TETHERING_USB:
                 result = setUsbTethering(enable);
@@ -808,27 +802,35 @@
         // The result of Bluetooth tethering will be sent after the pan service connects.
         if (result == TETHER_ERROR_BLUETOOTH_SERVICE_PENDING) return;
 
-        sendTetherResult(listener, result, type);
+        // The result of Wifi tethering will be sent after the SoftApCallback result.
+        if (result == TETHER_ERROR_SOFT_AP_CALLBACK_PENDING) return;
+
+        sendTetherResultAndRemoveOnError(request, listener, result);
     }
 
-    private void sendTetherResult(final IIntResultListener listener, final int result,
-            final int type) {
+    private void sendTetherResult(final IIntResultListener listener, final int result) {
         if (listener != null) {
             try {
                 listener.onResult(result);
-            } catch (RemoteException e) { }
+            } catch (RemoteException e) {
+            }
         }
+    }
 
-        // If changing tethering fail, remove corresponding request
-        // no matter who trigger the start/stop.
+    private void sendTetherResultAndRemoveOnError(TetheringRequest request,
+            final IIntResultListener listener, final int result) {
+        sendTetherResult(listener, result);
+
         if (result != TETHER_ERROR_NO_ERROR) {
-            mPendingTetheringRequests.remove(type);
+            mRequestTracker.removePendingRequest(request);
+            final int type = request.getTetheringType();
             mTetheringMetrics.updateErrorCode(type, result);
             mTetheringMetrics.sendReport(type);
         }
     }
 
-    private int setWifiTethering(final boolean enable) {
+    private int setWifiTethering(final boolean enable, TetheringRequest request,
+            IIntResultListener listener) {
         final long ident = Binder.clearCallingIdentity();
         try {
             final WifiManager mgr = getWifiManager();
@@ -836,8 +838,34 @@
                 mLog.e("setWifiTethering: failed to get WifiManager!");
                 return TETHER_ERROR_SERVICE_UNAVAIL;
             }
-            if ((enable && mgr.startTetheredHotspot(null /* use existing softap config */))
-                    || (!enable && mgr.stopSoftAp())) {
+            final boolean success;
+            if (enable) {
+                if (isTetheringWithSoftApConfigEnabled()) {
+                    // Notes:
+                    // - A call to startTetheredHotspot can only succeed if the SoftAp is idle. If
+                    //   the SoftAp is running or is being disabled, the call will fail.
+                    // - If a call to startTetheredHotspot fails, the callback is immediately called
+                    //   with WIFI_AP_STATE_FAILED and a null interface.
+                    // - If a call to startTetheredHotspot succeeds, the passed-in callback is the
+                    //   only callback that will receive future WIFI_AP_STATE_ENABLED and
+                    //   WIFI_AP_STATE_DISABLED events in the future, until another call to
+                    //   startTetheredHotspot succeeds, at which point the old callback will stop
+                    //   receiving any events.
+                    // - Wifi may decide to restart the hotspot at any time (such as for a CC
+                    //   change), and if it does so, it will send WIFI_AP_STATE_DISABLED and then
+                    //   either WIFI_AP_STATE_ENABLED or (if restarting fails) WIFI_AP_STATE_FAILED.
+                    mgr.startTetheredHotspot(request, mExecutor,
+                            new StartTetheringSoftApCallback(listener));
+                    // Result isn't used since we get the real result via
+                    // StartTetheringSoftApCallback.
+                    return TETHER_ERROR_SOFT_AP_CALLBACK_PENDING;
+                }
+                success = mgr.startTetheredHotspot(null);
+            } else {
+                success = mgr.stopSoftAp();
+            }
+
+            if (success) {
                 return TETHER_ERROR_NO_ERROR;
             }
         } finally {
@@ -866,15 +894,20 @@
         if (!enable) {
             // The service is not connected. If disabling tethering, there's no point starting
             // the service just to stop tethering since tethering is not started. Just remove
-            // any pending requests to enable tethering, and notify them that they have failed.
-            for (IIntResultListener pendingListener : mPendingPanRequestListeners) {
-                sendTetherResult(pendingListener, TETHER_ERROR_SERVICE_UNAVAIL,
-                        TETHERING_BLUETOOTH);
+            // any pending request to enable tethering, and notify them that they have failed.
+            if (mPendingPanRequestListener != null) {
+                sendTetherResult(mPendingPanRequestListener, TETHER_ERROR_SERVICE_UNAVAIL);
             }
-            mPendingPanRequestListeners.clear();
+            mPendingPanRequestListener = null;
             return TETHER_ERROR_NO_ERROR;
         }
-        mPendingPanRequestListeners.add(listener);
+
+        // Only allow one pending request at a time.
+        if (mPendingPanRequestListener != null) {
+            return TETHER_ERROR_SERVICE_UNAVAIL;
+        }
+
+        mPendingPanRequestListener = listener;
 
         // Bluetooth tethering is not a popular feature. To avoid bind to bluetooth pan service all
         // the time but user never use bluetooth tethering. mBluetoothPanListener is created first
@@ -901,12 +934,15 @@
                 mBluetoothPan = (BluetoothPan) proxy;
                 mIsConnected = true;
 
-                for (IIntResultListener pendingListener : mPendingPanRequestListeners) {
+                if (mPendingPanRequestListener != null) {
                     final int result = setBluetoothTetheringSettings(mBluetoothPan,
                             true /* enable */);
-                    sendTetherResult(pendingListener, result, TETHERING_BLUETOOTH);
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            result);
                 }
-                mPendingPanRequestListeners.clear();
+                mPendingPanRequestListener = null;
             });
         }
 
@@ -917,11 +953,13 @@
                 // reachable before next onServiceConnected.
                 mIsConnected = false;
 
-                for (IIntResultListener pendingListener : mPendingPanRequestListeners) {
-                    sendTetherResult(pendingListener, TETHER_ERROR_SERVICE_UNAVAIL,
-                            TETHERING_BLUETOOTH);
+                if (mPendingPanRequestListener != null) {
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            TETHER_ERROR_SERVICE_UNAVAIL);
                 }
-                mPendingPanRequestListeners.clear();
+                mPendingPanRequestListener = null;
                 mBluetoothIfaceRequest = null;
                 mBluetoothCallback = null;
                 maybeDisableBluetoothIpServing();
@@ -991,7 +1029,7 @@
             if (this != mBluetoothCallback) return;
 
             final TetheringRequest request =
-                    getOrCreatePendingTetheringRequest(TETHERING_BLUETOOTH);
+                    mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH);
             enableIpServing(request, iface);
             mConfiguredBluetoothIface = iface;
         }
@@ -1048,7 +1086,8 @@
                 return;
             }
 
-            final TetheringRequest request = getOrCreatePendingTetheringRequest(TETHERING_ETHERNET);
+            final TetheringRequest request = mRequestTracker.getOrCreatePendingRequest(
+                    TETHERING_ETHERNET);
             enableIpServing(request, iface);
             mConfiguredEthernetIface = iface;
         }
@@ -1080,63 +1119,8 @@
         return TETHER_ERROR_NO_ERROR;
     }
 
-    /**
-     * Create a legacy tethering request for calls to the legacy tether() API, which doesn't take an
-     * explicit request. These are always CONNECTIVITY_SCOPE_GLOBAL, per historical behavior.
-     */
-    private TetheringRequest createLegacyGlobalScopeTetheringRequest(int type) {
-        final TetheringRequest request = new TetheringRequest.Builder(type).build();
-        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_LEGACY;
-        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
-        return request;
-    }
-
-    /**
-     * Create a local-only implicit tethering request. This is used for Wifi local-only hotspot and
-     * Wifi P2P, which start tethering based on the WIFI_(AP/P2P)_STATE_CHANGED broadcasts.
-     */
-    @NonNull
-    private TetheringRequest createImplicitLocalOnlyTetheringRequest(int type) {
-        final TetheringRequest request = new TetheringRequest.Builder(type).build();
-        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_IMPLICIT;
-        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_LOCAL;
-        return request;
-    }
-
-    /**
-     * Create a placeholder request. This is used in case we try to find a pending request but there
-     * is none (e.g. stopTethering removed a pending request), or for cases where we only have the
-     * tethering type (e.g. stopTethering(int)).
-     */
-    @NonNull
-    private TetheringRequest createPlaceholderRequest(int type) {
-        final TetheringRequest request = new TetheringRequest.Builder(type).build();
-        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_LEGACY;
-        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
-        return request;
-    }
-
-    /**
-     * Gets the TetheringRequest that #startTethering was called with but is waiting for the link
-     * layer event to indicate the interface is available to tether.
-     * Note: There are edge cases where the pending request is absent and we must temporarily
-     *       synthesize a placeholder request, such as if stopTethering was called before link layer
-     *       went up, or if the link layer goes up without us poking it (e.g. adb shell cmd wifi
-     *       start-softap). These placeholder requests only specify the tethering type and the
-     *       default connectivity scope.
-     */
-    @NonNull
-    private TetheringRequest getOrCreatePendingTetheringRequest(int type) {
-        TetheringRequest pending = mPendingTetheringRequests.get(type);
-        if (pending != null) return pending;
-
-        Log.w(TAG, "No pending TetheringRequest for type " + type + " found, creating a placeholder"
-                + " request");
-        return createPlaceholderRequest(type);
-    }
-
     private void handleLegacyTether(String iface, final IIntResultListener listener) {
-        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        if (SdkLevel.isAtLeastB()) {
             // After V, the TetheringManager and ConnectivityManager tether and untether methods
             // throw UnsupportedOperationException, so this cannot happen in normal use. Ensure
             // that this code cannot run even if callers use raw binder calls or other
@@ -1147,12 +1131,13 @@
         final int type = ifaceNameToType(iface);
         if (type == TETHERING_INVALID) {
             Log.e(TAG, "Ignoring call to legacy tether for unknown iface " + iface);
-            try {
-                listener.onResult(TETHER_ERROR_UNKNOWN_IFACE);
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, TETHER_ERROR_UNKNOWN_IFACE);
         }
 
-        final TetheringRequest request = createLegacyGlobalScopeTetheringRequest(type);
+        TetheringRequest request = mRequestTracker.getNextPendingRequest(type);
+        if (request == null) {
+            request = createLegacyGlobalScopeTetheringRequest(type);
+        }
         int result = tetherInternal(request, iface);
         switch (type) {
             case TETHERING_WIFI:
@@ -1183,9 +1168,7 @@
                 // Do nothing
                 break;
         }
-        try {
-            listener.onResult(result);
-        } catch (RemoteException e) { }
+        sendTetherResult(listener, result);
     }
 
     /**
@@ -1218,11 +1201,9 @@
             Log.e(TAG, "Tried to Tether an unavailable iface: " + iface + ", ignoring");
             return TETHER_ERROR_UNAVAIL_IFACE;
         }
+        mRequestTracker.promoteRequestToServing(tetherState.ipServer, request);
         // NOTE: If a CMD_TETHER_REQUESTED message is already in the IpServer's queue but not yet
         // processed, this will be a no-op and it will not return an error.
-        //
-        // This code cannot race with untether() because they both run on the handler thread.
-        mPendingTetheringRequests.remove(request.getTetheringType());
         tetherState.ipServer.enable(request);
         if (request.getRequestType() == REQUEST_TYPE_PLACEHOLDER) {
             TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
@@ -1234,7 +1215,7 @@
     }
 
     void legacyUntether(String iface, final IIntResultListener listener) {
-        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+        if (SdkLevel.isAtLeastB()) {
             // After V, the TetheringManager and ConnectivityManager tether and untether methods
             // throw UnsupportedOperationException, so this cannot happen in normal use. Ensure
             // that this code cannot run even if callers use raw binder calls or other
@@ -1242,10 +1223,7 @@
             return;
         }
         mHandler.post(() -> {
-            try {
-                listener.onResult(legacyUntetherInternal(iface));
-            } catch (RemoteException e) {
-            }
+            sendTetherResult(listener, legacyUntetherInternal(iface));
         });
     }
 
@@ -1260,7 +1238,7 @@
             Log.e(TAG, "Tried to untether an inactive iface :" + iface + ", ignoring");
             return TETHER_ERROR_UNAVAIL_IFACE;
         }
-        tetherState.ipServer.unwanted();
+        ensureIpServerUnwanted(tetherState.ipServer);
         return TETHER_ERROR_NO_ERROR;
     }
 
@@ -1340,7 +1318,12 @@
             final TetherState tetherState = mTetherStates.valueAt(i);
             final int type = tetherState.ipServer.interfaceType();
             final String iface = mTetherStates.keyAt(i);
-            final TetheringRequest request = tetherState.ipServer.getTetheringRequest();
+            // Note: serving requests are only populated on B+. B+ also uses the sync state
+            // machine by default. This ensures that the serving request is (correctly) populated
+            // after the IpServer enters the available state and before it enters the serving
+            // state.
+            final TetheringRequest request =
+                    mRequestTracker.getServingRequest(tetherState.ipServer);
             final boolean includeSoftApConfig = request != null && cookie != null
                     && (cookie.uid == request.getUid() || cookie.hasSystemPrivilege);
             final TetheringInterface tetheringIface = new TetheringInterface(type, iface,
@@ -1507,6 +1490,9 @@
             final String ifname = intent.getStringExtra(EXTRA_WIFI_AP_INTERFACE_NAME);
             final int ipmode = intent.getIntExtra(EXTRA_WIFI_AP_MODE, IFACE_IP_MODE_UNSPECIFIED);
 
+            // In B+, Tethered AP is handled by StartTetheringSoftApCallback.
+            if (isTetheringWithSoftApConfigEnabled() && ipmode == IFACE_IP_MODE_TETHERED) return;
+
             switch (curState) {
                 case WifiManager.WIFI_AP_STATE_ENABLING:
                     // We can see this state on the way to both enabled and failure states.
@@ -1587,9 +1573,61 @@
         }
     }
 
+    class StartTetheringSoftApCallback implements SoftApCallback {
+
+        @Nullable
+        IIntResultListener mPendingListener;
+
+        StartTetheringSoftApCallback(IIntResultListener pendingListener) {
+            mPendingListener = pendingListener;
+        }
+
+        @Override
+        public void onStateChanged(SoftApState softApState) {
+            final int state = softApState.getState();
+            final String iface = softApState.getIface();
+            final TetheringRequest request = softApState.getTetheringRequest();
+            switch (softApState.getState()) {
+                case WifiManager.WIFI_AP_STATE_ENABLED:
+                    enableIpServing(request, iface);
+                    // If stopTethering has already been called, IP serving will still be started,
+                    // but as soon as the wifi code processes the stop, WIFI_AP_STATE_DISABLED will
+                    // be sent and tethering will be stopped again.
+                    sendTetherResultAndRemoveOnError(request, mPendingListener,
+                            TETHER_ERROR_NO_ERROR);
+                    mPendingListener = null;
+                    break;
+                case WifiManager.WIFI_AP_STATE_FAILED:
+                    // TODO: if a call to startTethering happens just after a call to stopTethering,
+                    // the start will fail because hotspot is still being disabled. This likely
+                    // cannot be fixed in tethering code but must be fixed in WiFi.
+                    sendTetherResultAndRemoveOnError(request, mPendingListener,
+                            TETHER_ERROR_INTERNAL_ERROR);
+                    mPendingListener = null;
+                    break;
+                case WifiManager.WIFI_AP_STATE_DISABLED:
+                    // TODO(b/403164072): SoftAP may restart due to CC change, in which we'll get
+                    // DISABLED -> ENABLED (or FAILED). Before the transition back to ENABLED is
+                    // complete, it is possible that a new Wifi request is accepted since there's no
+                    // active request to fuzzy-match it, which will unexpectedly cause Wifi to
+                    // overwrite this SoftApCallback. This should be fixed in Wifi to disallow any
+                    // new calls to startTetheredHotspot while SoftAP is restarting.
+                    disableWifiIpServing(iface, state);
+                    break;
+                default:
+                    break;
+            }
+        }
+    }
+
     @VisibleForTesting
-    SparseArray<TetheringRequest> getPendingTetheringRequests() {
-        return mPendingTetheringRequests;
+    List<TetheringRequest> getPendingTetheringRequests() {
+        return mRequestTracker.getPendingTetheringRequests();
+    }
+
+    @VisibleForTesting
+    List<TetheringRequest> getServingTetheringRequests() {
+        return mRequestTracker.getServingTetheringRequests();
     }
 
     @VisibleForTesting
@@ -1649,10 +1687,6 @@
         }
     }
 
-    final TetheringRequest getPendingTetheringRequest(int type) {
-        return mPendingTetheringRequests.get(type, null);
-    }
-
     private void enableIpServing(@NonNull TetheringRequest request, String ifname) {
         enableIpServing(request, ifname, false /* isNcm */);
     }
@@ -1666,7 +1700,7 @@
 
     private void disableWifiIpServingCommon(int tetheringType, String ifname) {
         if (!TextUtils.isEmpty(ifname) && mTetherStates.containsKey(ifname)) {
-            mTetherStates.get(ifname).ipServer.unwanted();
+            ensureIpServerUnwanted(mTetherStates.get(ifname).ipServer);
             return;
         }
 
@@ -1683,7 +1717,7 @@
         for (int i = 0; i < mTetherStates.size(); i++) {
             final IpServer ipServer = mTetherStates.valueAt(i).ipServer;
             if (ipServer.interfaceType() == tetheringType) {
-                ipServer.unwanted();
+                ensureIpServerUnwanted(ipServer);
                 return;
             }
         }
@@ -1740,7 +1774,7 @@
         switch (wifiIpMode) {
             case IFACE_IP_MODE_TETHERED:
                 type = maybeInferWifiTetheringType(ifname);
-                request = getOrCreatePendingTetheringRequest(type);
+                request = mRequestTracker.getOrCreatePendingRequest(type);
                 // Wifi requests will always have CONNECTIVITY_SCOPE_GLOBAL, because
                 // TetheringRequest.Builder will not allow callers to set CONNECTIVITY_SCOPE_LOCAL
                 // for TETHERING_WIFI. However, if maybeInferWifiTetheringType returns a non-Wifi
@@ -1795,7 +1829,7 @@
             return;
         }
 
-        final TetheringRequest request = getOrCreatePendingTetheringRequest(tetheringType);
+        final TetheringRequest request = mRequestTracker.getOrCreatePendingRequest(tetheringType);
         if (ifaces != null) {
             for (String iface : ifaces) {
                 if (ifaceNameToType(iface) == tetheringType) {
@@ -1830,9 +1864,7 @@
 
     void setUsbTethering(boolean enable, IIntResultListener listener) {
         mHandler.post(() -> {
-            try {
-                listener.onResult(setUsbTethering(enable));
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, setUsbTethering(enable));
         });
     }
 
@@ -2959,6 +2991,9 @@
                 tetherState.lastState = state;
                 tetherState.lastError = lastError;
             } else {
+                // Note: Even if an IpServer exists for this iface, it may be different from "who"
+                // if a new IpServer fills the gap before the IpServer.STATE_UNAVAILABLE transition.
+                // TODO: remove this comment once the sync state machine is enabled everywhere.
                 if (DBG) Log.d(TAG, "got notification from stale iface " + iface);
             }
 
@@ -2973,7 +3008,19 @@
             int which;
             switch (state) {
                 case IpServer.STATE_UNAVAILABLE:
+                    which = TetherMainSM.EVENT_IFACE_SERVING_STATE_INACTIVE;
+                    break;
                 case IpServer.STATE_AVAILABLE:
+                    if (lastError != TETHER_ERROR_NO_ERROR) {
+                        // IpServer transitioned from an enabled state (STATE_TETHERED or
+                        // STATE_LOCAL_ONLY) back to STATE_AVAILABLE due to an error, so make sure
+                        // we remove the serving request from RequestTracker.
+                        // TODO: don't continue to use IpServers after they have hit an error, and
+                        // instead move them to STATE_UNAVAILABLE. This code can then
+                        // unconditionally remove the serving request whenever the IpServer enters
+                        // STATE_UNAVAILABLE.
+                        mRequestTracker.removeServingRequest(who);
+                    }
                     which = TetherMainSM.EVENT_IFACE_SERVING_STATE_INACTIVE;
                     break;
                 case IpServer.STATE_TETHERED:
@@ -3069,11 +3116,18 @@
         final TetherState tetherState = mTetherStates.get(iface);
         if (tetherState == null) return;
 
+        mRequestTracker.removeServingRequest(tetherState.ipServer);
         tetherState.ipServer.stop();
         mLog.i("removing IpServer for: " + iface);
         mTetherStates.remove(iface);
     }
 
+    private void ensureIpServerUnwanted(final IpServer ipServer) {
+        mLog.i("unrequesting IpServer: " + ipServer);
+        mRequestTracker.removeServingRequest(ipServer);
+        ipServer.unwanted();
+    }
+
     private static String[] copy(String[] strarray) {
         return Arrays.copyOf(strarray, strarray.length);
     }
@@ -3081,9 +3135,7 @@
     void setPreferTestNetworks(final boolean prefer, IIntResultListener listener) {
         mHandler.post(() -> {
             mUpstreamNetworkMonitor.setPreferTestNetworks(prefer);
-            try {
-                listener.onResult(TETHER_ERROR_NO_ERROR);
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, TETHER_ERROR_NO_ERROR);
         });
     }
 
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index b3e9c1b..3c91a1b 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -130,9 +130,6 @@
     public static final String TETHER_ENABLE_WEAR_TETHERING =
             "tether_enable_wear_tethering";
 
-    public static final String TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION =
-            "tether_force_random_prefix_base_selection";
-
     public static final String TETHER_ENABLE_SYNC_SM = "tether_enable_sync_sm";
 
     /**
@@ -142,7 +139,7 @@
     public static final int DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS = 5000;
 
     /** A flag for using synchronous or asynchronous state machine. */
-    public static boolean USE_SYNC_SM = false;
+    public static boolean USE_SYNC_SM = true;
 
     /**
      * A feature flag to control whether the active sessions metrics should be enabled.
@@ -195,6 +192,10 @@
             return DeviceConfigUtils.isTetheringFeatureEnabled(context, name);
         }
 
+        boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(context, name);
+        }
+
         boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                 boolean defaultValue) {
             return DeviceConfig.getBoolean(namespace, name, defaultValue);
@@ -394,7 +395,7 @@
      * use the async state machine.
      */
     public void readEnableSyncSM(final Context ctx) {
-        USE_SYNC_SM = mDeps.isFeatureEnabled(ctx, TETHER_ENABLE_SYNC_SM);
+        USE_SYNC_SM = mDeps.isFeatureNotChickenedOut(ctx, TETHER_ENABLE_SYNC_SM);
     }
 
     /** Does the dumping.*/
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 8e17085..00a7f09 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -37,7 +37,6 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.modules.utils.build.SdkLevel;
-import com.android.net.flags.Flags;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
@@ -211,10 +210,9 @@
     }
 
     /**
-     * Wrapper for tethering_with_soft_ap_config feature flag.
+     * Returns true if the tethering with soft ap config feature is enabled.
      */
     public boolean isTetheringWithSoftApConfigEnabled() {
-        return Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM
-                && Flags.tetheringWithSoftApConfig();
+        return SdkLevel.isAtLeastB();
     }
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index b553f46..f501a50 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -173,7 +173,21 @@
         @Override
         public void requestLatestTetheringEntitlementResult(int type, ResultReceiver receiver,
                 boolean showEntitlementUi, String callerPkg, String callingAttributionTag) {
-            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, receiver)) return;
+            // Wrap the app-provided ResultReceiver in an IIntResultListener in order to call
+            // checkAndNotifyCommonError with it.
+            IIntResultListener listener = new IIntResultListener() {
+                @Override
+                public void onResult(int i) {
+                    receiver.send(i, null);
+                }
+
+                @Override
+                public IBinder asBinder() {
+                    throw new UnsupportedOperationException("asBinder unexpectedly called on"
+                            + " internal-only listener");
+                }
+            };
+            if (checkAndNotifyCommonError(callerPkg, callingAttributionTag, listener)) return;
 
             mTethering.requestLatestTetheringEntitlementResult(type, receiver, showEntitlementUi);
         }
@@ -277,27 +291,6 @@
             return false;
         }
 
-        private boolean checkAndNotifyCommonError(final String callerPkg,
-                final String callingAttributionTag, final ResultReceiver receiver) {
-            if (!checkPackageNameMatchesUid(getBinderCallingUid(), callerPkg)) {
-                Log.e(TAG, "Package name " + callerPkg + " does not match UID "
-                        + getBinderCallingUid());
-                receiver.send(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION, null);
-                return true;
-            }
-            if (!hasTetherChangePermission(callerPkg, callingAttributionTag,
-                    false /* onlyAllowPrivileged */)) {
-                receiver.send(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION, null);
-                return true;
-            }
-            if (!mTethering.isTetheringSupported() || !mTethering.isTetheringAllowed()) {
-                receiver.send(TETHER_ERROR_UNSUPPORTED, null);
-                return true;
-            }
-
-            return false;
-        }
-
         private boolean hasNetworkSettingsPermission() {
             return checkCallingOrSelfPermission(NETWORK_SETTINGS);
         }
diff --git a/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java b/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
index 76c2f0d..79e6e16 100644
--- a/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
+++ b/Tethering/src/com/android/networkstack/tethering/util/TetheringUtils.java
@@ -15,7 +15,11 @@
  */
 package com.android.networkstack.tethering.util;
 
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
+import static android.net.TetheringManager.CONNECTIVITY_SCOPE_LOCAL;
+
 import android.net.TetherStatsParcel;
+import android.net.TetheringManager.TetheringRequest;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -166,4 +170,41 @@
             return null;
         }
     }
+
+    /**
+     * Create a legacy tethering request for calls to the legacy tether() API, which doesn't take an
+     * explicit request. These are always CONNECTIVITY_SCOPE_GLOBAL, per historical behavior.
+     */
+    @NonNull
+    public static TetheringRequest createLegacyGlobalScopeTetheringRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_LEGACY;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
+        return request;
+    }
+
+    /**
+     * Create a local-only implicit tethering request. This is used for Wifi local-only hotspot and
+     * Wifi P2P, which start tethering based on the WIFI_(AP/P2P)_STATE_CHANGED broadcasts.
+     */
+    @NonNull
+    public static TetheringRequest createImplicitLocalOnlyTetheringRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_IMPLICIT;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_LOCAL;
+        return request;
+    }
+
+    /**
+     * Create a placeholder request. This is used in case we try to find a pending request but there
+     * is none (e.g. stopTethering removed a pending request), or for cases where we only have the
+     * tethering type (e.g. stopTethering(int)).
+     */
+    @NonNull
+    public static TetheringRequest createPlaceholderRequest(int type) {
+        final TetheringRequest request = new TetheringRequest.Builder(type).build();
+        request.getParcel().requestType = TetheringRequest.REQUEST_TYPE_PLACEHOLDER;
+        request.getParcel().connectivityScope = CONNECTIVITY_SCOPE_GLOBAL;
+        return request;
+    }
 }
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 5c8d347..3a5728e 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -64,6 +64,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BpfDump;
 import com.android.net.module.util.Ipv6Utils;
 import com.android.net.module.util.Struct;
@@ -89,6 +90,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.File;
 import java.io.FileDescriptor;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -1051,6 +1053,16 @@
         assertEquals(0, statsValue.txErrors);
     }
 
+    // on S/Sv2 without a new enough DnsResolver apex, NetBpfLoad does not
+    // get triggered, and thus no mainline programs get loaded.
+    private boolean isNetBpfLoadEnabled() {
+        if (SdkLevel.isAtLeastT()) return true;
+        if (!SdkLevel.isAtLeastS()) return false;
+
+        File f = new File("/apex/com.android.resolv/NetBpfLoad-S.flag");
+        return f.isFile();
+    }
+
     /**
      * BPF offload IPv4 UDP tethering test. Verify that UDP tethered packets are offloaded by BPF.
      * Minimum test requirement:
@@ -1065,6 +1077,7 @@
     public void testTetherBpfOffloadUdpV4() throws Exception {
         assumeTrue("Tethering config disabled BPF offload", isTetherConfigBpfOffloadEnabled());
         assumeKernelSupportBpfOffloadUdpV4();
+        assumeTrue("Mainline NetBpfLoad not available", isNetBpfLoadEnabled());
 
         runUdp4Test();
     }
diff --git a/Tethering/tests/unit/Android.bp b/Tethering/tests/unit/Android.bp
index d0d23ac..c282618 100644
--- a/Tethering/tests/unit/Android.bp
+++ b/Tethering/tests/unit/Android.bp
@@ -51,12 +51,15 @@
         "src/**/*.kt",
     ],
     static_libs: [
+        // Include mockito extended first so it takes precedence, as other libraries like
+        // TetheringCommonTests bundle non-extended mockito.
+        // TODO: use non-extended mockito in tethering tests instead
+        "mockito-target-extended-minus-junit4",
         "TetheringCommonTests",
         "androidx.test.rules",
         "frameworks-base-testutils",
-        "mockito-target-extended-minus-junit4",
-        "net-tests-utils",
         "testables",
+        "truth",
     ],
     // TODO(b/147200698) change sdk_version to module-current and
     // remove framework-minus-apex, ext, and framework-res
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
index 087be26..c97fa3d 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/FakeTetheringConfiguration.java
@@ -33,6 +33,11 @@
             }
 
             @Override
+            boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+                return true;
+            }
+
+            @Override
             boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                     boolean defaultValue) {
                 return defaultValue;
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
index f9e3a6a..ada88fb 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -26,7 +26,6 @@
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
 import static android.net.ip.IpServer.CMD_NOTIFY_PREFIX_CONFLICT;
 
-import static com.android.net.module.util.PrivateAddressCoordinator.TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 
 import static org.junit.Assert.assertEquals;
@@ -51,6 +50,7 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.ip.IpServer;
+import android.os.Build;
 import android.os.IBinder;
 
 import androidx.test.filters.SmallTest;
@@ -58,8 +58,10 @@
 
 import com.android.net.module.util.IIpv4PrefixRequest;
 import com.android.net.module.util.PrivateAddressCoordinator;
+import com.android.testutils.DevSdkIgnoreRule;
 
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
@@ -71,6 +73,9 @@
 @RunWith(AndroidJUnit4.class)
 @SmallTest
 public final class PrivateAddressCoordinatorTest {
+    @Rule
+    public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+
     private static final String TEST_IFNAME = "test0";
 
     @Mock private IpServer mHotspotIpServer;
@@ -231,11 +236,9 @@
         assertEquals(usbAddress, newUsbAddress);
 
         final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.88.23/16"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
+                hotspotAddress, null, makeNetworkCapabilities(TRANSPORT_WIFI));
         updateUpstreamPrefix(wifiUpstream);
         verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        verify(mUsbIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
     }
 
     private UpstreamNetworkState buildUpstreamNetworkState(final Network network,
@@ -323,10 +326,9 @@
         assertFalse(localHotspotPrefix.containsPrefix(hotspotPrefix));
     }
 
+    @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     @Test
     public void testStartedPrefixRange() throws Exception {
-        when(mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)).thenReturn(true);
-
         startedPrefixBaseTest("192.168.0.0/16", 0);
 
         startedPrefixBaseTest("192.168.0.0/16", 1);
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
new file mode 100644
index 0000000..086f2d2
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack.tethering;
+
+import static android.net.TetheringManager.TETHERING_USB;
+import static android.net.TetheringManager.TETHERING_WIFI;
+import static android.net.TetheringManager.TETHERING_VIRTUAL;
+
+import static com.android.networkstack.tethering.util.TetheringUtils.createPlaceholderRequest;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+
+import android.net.TetheringManager.TetheringRequest;
+import android.net.ip.IpServer;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.networkstack.tethering.RequestTracker.AddResult;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RequestTrackerTest {
+    private RequestTracker mRequestTracker;
+
+    @Test
+    public void testNoRequestsAdded_noPendingRequests() {
+        mRequestTracker = new RequestTracker(false);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testAddRequest_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+
+        final AddResult result = mRequestTracker.addPendingRequest(request);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_equalRequestExists_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final TetheringRequest equalRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final AddResult result = mRequestTracker.addPendingRequest(equalRequest);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_equalButDifferentUidRequest_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request.setUid(1000);
+        request.setPackageName("package");
+        final TetheringRequest differentUid = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        differentUid.setUid(2000);
+        differentUid.setPackageName("package2");
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(differentUid);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(differentUid);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(differentUid);
+    }
+
+    @Test
+    public void testAddRequest_conflictingPendingRequest_returnsFailureConflictingRequestRestart() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_RESTART);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_noExistingRequestsFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+
+        final AddResult result = mRequestTracker.addPendingRequest(request);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_conflictingPendingRequestFuzzyMatching_returnsFailure() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testAddRequest_conflictingServingRequestFuzzyMatching_returnsFailure() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        final TetheringRequest conflictingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setExemptFromEntitlementCheck(true).build();
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
+
+        assertThat(result).isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testAddRequest_nonMatchingPendingRequestFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest nonFuzzyMatched = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.addPendingRequest(request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(nonFuzzyMatched);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        // Next request is still the first, but verify RequestTracker contains the second request by
+        // seeing if it rejects anything matching the second request
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(request);
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(nonFuzzyMatched))
+                .isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+    }
+
+    @Test
+    public void testAddRequest_nonMatchingServingRequestFuzzyMatching_returnsSuccess() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest nonFuzzyMatched = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        final AddResult result = mRequestTracker.addPendingRequest(nonFuzzyMatched);
+
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(nonFuzzyMatched);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(nonFuzzyMatched);
+    }
+
+    @Test
+    public void testRemovePendingRequest_removesAllPendingRequestsOfType() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request1 = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request1.setUid(1000);
+        request1.setPackageName("package");
+        mRequestTracker.addPendingRequest(request1);
+        final TetheringRequest request2 = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        request2.setUid(2000);
+        request2.setPackageName("package2");
+
+        mRequestTracker.removePendingRequest(request2);
+
+        // Verify request1 isn't pending even though we tried to remove a different request
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemovePendingRequest_fuzzyMatching_onlyTheEqualRequestIsRemoved() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request1 = new TetheringRequest.Builder(TETHERING_VIRTUAL).build();
+        final TetheringRequest request2 = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                .setInterfaceName("iface")
+                .build();
+        mRequestTracker.addPendingRequest(request1);
+        mRequestTracker.addPendingRequest(request2);
+
+        mRequestTracker.removePendingRequest(request2);
+
+        // Verify request1 is still pending.
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_VIRTUAL)).isEqualTo(request1);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_VIRTUAL))
+                .isEqualTo(request1);
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(request1))
+                .isEqualTo(AddResult.FAILURE_DUPLICATE_REQUEST_ERROR);
+        // Verify we've removed request2 by checking if it can be added back without
+        // FAILURE_CONFLICTING_REQUEST_FAIL.
+        assertThat(mRequestTracker.addPendingRequestFuzzyMatched(request2))
+                .isEqualTo(AddResult.SUCCESS);
+    }
+
+    @Test
+    public void testRemoveAllPendingRequests_noPendingRequestsLeft() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest firstRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        firstRequest.setUid(1000);
+        firstRequest.setPackageName("package");
+        mRequestTracker.addPendingRequest(firstRequest);
+        final TetheringRequest secondRequest = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        secondRequest.setUid(2000);
+        secondRequest.setPackageName("package2");
+        mRequestTracker.addPendingRequest(secondRequest);
+
+        mRequestTracker.removeAllPendingRequests(TETHERING_WIFI);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemoveAllPendingRequests_differentTypeExists_doesNotRemoveDifferentType() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest differentType = new TetheringRequest.Builder(TETHERING_USB).build();
+        mRequestTracker.addPendingRequest(differentType);
+
+        mRequestTracker.removeAllPendingRequests(TETHERING_WIFI);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_USB)).isEqualTo(differentType);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_USB))
+                .isEqualTo(differentType);
+    }
+
+    @Test
+    public void testPromoteRequestToServing_requestIsntPendingAnymore() {
+        mRequestTracker = new RequestTracker(false);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testPromoteRequestToServing_fuzzyMatching_requestIsntPendingAnymore() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
+                .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
+    }
+
+    @Test
+    public void testRemoveServingRequest_fuzzyMatching_requestCanBeAddedAgain() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+        IpServer ipServer = mock(IpServer.class);
+        mRequestTracker.promoteRequestToServing(ipServer, request);
+
+        mRequestTracker.removeServingRequest(ipServer);
+
+        AddResult result = mRequestTracker.addPendingRequest(request);
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+
+    @Test
+    public void testRemoveAllServingRequests_fuzzyMatching_requestCanBeAddedAgain() {
+        mRequestTracker = new RequestTracker(true);
+        final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
+        mRequestTracker.addPendingRequest(request);
+        mRequestTracker.promoteRequestToServing(mock(IpServer.class), request);
+
+        mRequestTracker.removeAllServingRequests(TETHERING_WIFI);
+
+        AddResult result = mRequestTracker.addPendingRequest(request);
+        assertThat(result).isEqualTo(AddResult.SUCCESS);
+        assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isEqualTo(request);
+        assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI)).isEqualTo(request);
+    }
+}
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
index dd51c7a..0159573 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringConfigurationTest.java
@@ -160,6 +160,11 @@
         }
 
         @Override
+        boolean isFeatureNotChickenedOut(@NonNull Context context, @NonNull String name) {
+            return isMockFlagEnabled(name, true /* defaultEnabled */);
+        }
+
+        @Override
         boolean getDeviceConfigBoolean(@NonNull String namespace, @NonNull String name,
                 boolean defaultValue) {
             // Flags should use isFeatureEnabled instead of getBoolean; see comments in
@@ -767,9 +772,9 @@
 
     @Test
     public void testEnableSyncSMFlag() throws Exception {
-        // Test default disabled
+        // Test default enabled
         setTetherEnableSyncSMFlagEnabled(null);
-        assertEnableSyncSM(false);
+        assertEnableSyncSM(true);
 
         setTetherEnableSyncSMFlagEnabled(true);
         assertEnableSyncSM(true);
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 e1c2db9..dc3cbd2 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -50,15 +50,18 @@
 import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_IFACE_CFG_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_INTERNAL_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
 import static android.net.TetheringManager.TETHER_ERROR_SERVICE_UNAVAIL;
 import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_IFACE;
+import static android.net.TetheringManager.TETHER_ERROR_UNKNOWN_REQUEST;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_FAILED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STARTED;
 import static android.net.TetheringManager.TETHER_HARDWARE_OFFLOAD_STOPPED;
 import static android.net.dhcp.IDhcpServer.STATUS_SUCCESS;
+import static android.net.dhcp.IDhcpServer.STATUS_UNKNOWN_ERROR;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_INTERFACE_NAME;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_MODE;
 import static android.net.wifi.WifiManager.EXTRA_WIFI_AP_STATE;
@@ -66,6 +69,7 @@
 import static android.net.wifi.WifiManager.IFACE_IP_MODE_TETHERED;
 import static android.net.wifi.WifiManager.WIFI_AP_STATE_DISABLED;
 import static android.net.wifi.WifiManager.WIFI_AP_STATE_ENABLED;
+import static android.net.wifi.WifiManager.WIFI_AP_STATE_FAILED;
 import static android.system.OsConstants.RT_SCOPE_UNIVERSE;
 import static android.telephony.SubscriptionManager.INVALID_SUBSCRIPTION_ID;
 
@@ -101,6 +105,7 @@
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
@@ -165,6 +170,7 @@
 import android.net.ip.IpServer;
 import android.net.ip.RouterAdvertisementDaemon;
 import android.net.wifi.SoftApConfiguration;
+import android.net.wifi.SoftApState;
 import android.net.wifi.WifiClient;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.SoftApCallback;
@@ -237,6 +243,7 @@
 import java.util.List;
 import java.util.Set;
 import java.util.Vector;
+import java.util.concurrent.Executor;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
@@ -307,8 +314,7 @@
     @Mock private TetheringMetrics mTetheringMetrics;
     @Mock private PrivateAddressCoordinator.Dependencies mPrivateAddressCoordinatorDependencies;
 
-    private final MockIpServerDependencies mIpServerDependencies =
-            spy(new MockIpServerDependencies());
+    private MockIpServerDependencies mIpServerDependencies;
     private final MockTetheringDependencies mTetheringDependencies =
             new MockTetheringDependencies();
 
@@ -339,6 +345,7 @@
     private TestConnectivityManager mCm;
     private boolean mForceEthernetServiceUnavailable = false;
     private int mBinderCallingUid = TEST_CALLER_UID;
+    private boolean mTetheringWithSoftApConfigEnabled = SdkLevel.isAtLeastB();
 
     private class TestContext extends BroadcastInterceptingContext {
         TestContext(Context base) {
@@ -395,6 +402,9 @@
     }
 
     public class MockIpServerDependencies extends IpServer.Dependencies {
+
+        private int mOnDhcpServerCreatedResult = STATUS_SUCCESS;
+
         @Override
         public DadProxy getDadProxy(
                 Handler handler, InterfaceParams ifParams) {
@@ -436,7 +446,7 @@
                 DhcpServerCallbacks cb) {
             new Thread(() -> {
                 try {
-                    cb.onDhcpServerCreated(STATUS_SUCCESS, mDhcpServer);
+                    cb.onDhcpServerCreated(mOnDhcpServerCreatedResult, mDhcpServer);
                 } catch (RemoteException e) {
                     fail(e.getMessage());
                 }
@@ -447,6 +457,10 @@
                 IpNeighborMonitor.NeighborEventConsumer c) {
             return mIpNeighborMonitor;
         }
+
+        public void setOnDhcpServerCreatedResult(final int result) {
+            mOnDhcpServerCreatedResult = result;
+        }
     }
 
     public class MockTetheringDependencies extends TetheringDependencies {
@@ -572,6 +586,11 @@
         public int getBinderCallingUid() {
             return mBinderCallingUid;
         }
+
+        @Override
+        public boolean isTetheringWithSoftApConfigEnabled() {
+            return mTetheringWithSoftApConfigEnabled;
+        }
     }
 
     private static LinkProperties buildUpstreamLinkProperties(String interfaceName,
@@ -707,6 +726,9 @@
 
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
+        mIpServerDependencies = spy(new MockIpServerDependencies());
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
+        mTetheringWithSoftApConfigEnabled = SdkLevel.isAtLeastB();
     }
 
     // In order to interact with syncSM from the test, tethering must be created in test thread.
@@ -823,6 +845,38 @@
         mLooper.dispatchAll();
     }
 
+    private void sendStartTetheringSoftApCallback(int state, TetheringRequest request,
+            String ifname) {
+        ArgumentCaptor<SoftApCallback> callbackCaptor =
+                ArgumentCaptor.forClass(SoftApCallback.class);
+        verify(mWifiManager, atLeastOnce()).startTetheredHotspot(any(TetheringRequest.class),
+                any(Executor.class), callbackCaptor.capture());
+        SoftApState softApState = mock(SoftApState.class);
+        when(softApState.getState()).thenReturn(state);
+        when(softApState.getTetheringRequest()).thenReturn(request);
+        when(softApState.getIface()).thenReturn(ifname);
+        callbackCaptor.getValue().onStateChanged(softApState);
+        mLooper.dispatchAll();
+    }
+
+    private void verifyWifiTetheringRequested() {
+        if (mTetheringDependencies.isTetheringWithSoftApConfigEnabled()) {
+            verify(mWifiManager).startTetheredHotspot(any(), any(), any());
+        } else {
+            verify(mWifiManager).startTetheredHotspot(null);
+        }
+        verify(mWifiManager, never()).stopSoftAp();
+        verifyNoMoreInteractions(mWifiManager);
+    }
+
+    private void sendSoftApEvent(int state, TetheringRequest request, String ifname) {
+        if (mTetheringDependencies.isTetheringWithSoftApConfigEnabled()) {
+            sendStartTetheringSoftApCallback(state, request, ifname);
+        } else {
+            sendWifiApStateChanged(state, ifname, IFACE_IP_MODE_TETHERED);
+        }
+    }
+
     private static final String[] P2P_RECEIVER_PERMISSIONS_FOR_BROADCAST = {
             android.Manifest.permission.ACCESS_FINE_LOCATION,
             android.Manifest.permission.ACCESS_WIFI_STATE
@@ -964,7 +1018,7 @@
         mLooper.dispatchAll();
 
         assertEquals(1, mTethering.getPendingTetheringRequests().size());
-        assertEquals(request, mTethering.getPendingTetheringRequests().get(TETHERING_USB));
+        assertTrue(mTethering.getPendingTetheringRequests().get(0).equals(request));
 
         if (mTethering.getTetheringConfiguration().isUsingNcm()) {
             verify(mUsbManager).setCurrentFunctions(UsbManager.FUNCTION_NCM);
@@ -1943,14 +1997,11 @@
     @Test
     public void failingWifiTetheringLegacyApBroadcast() throws Exception {
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
 
         // Emulate externally-visible WifiManager effects, causing the
@@ -1979,15 +2030,15 @@
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void workingWifiTetheringEnrichedApBroadcast() throws Exception {
+        // B+ uses SoftApCallback instead of WIFI_AP_STATE_CHANGED for tethered hotspot.
+        mTetheringWithSoftApConfigEnabled = false;
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
 
         // Emulate pressing the WiFi tethering button.
         mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
                 null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
 
         // Emulate externally-visible WifiManager effects, causing the
@@ -2025,19 +2076,133 @@
         verifyStopHotpot();
     }
 
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationSuccess() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+        verifyNoMoreInteractions(mNetd);
+        verify(startResultListener, never()).onResult(anyInt());
+        // Emulate Wifi iface enabled
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+
+        verifyStartHotspot();
+        verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_ACTIVE_TETHER);
+        verify(startResultListener).onResult(TETHER_ERROR_NO_ERROR);
+    }
+
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationFailure() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+        verify(startResultListener, never()).onResult(anyInt());
+        // Emulate Wifi iface failure
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_FAILED, request, TEST_WLAN_IFNAME);
+
+        verify(startResultListener).onResult(TETHER_ERROR_INTERNAL_ERROR);
+    }
+
+    @Test
+    public void startWifiTetheringWithSoftApConfigurationRestartAfterStarting() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                .build();
+        final TetheringInterface wifiIface = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME);
+        final TetheringInterface wifiIfaceWithConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, softApConfig);
+
+        // 1. Register one callback before running any tethering.
+        mTethering.registerTetheringEventCallback(callback);
+        mLooper.dispatchAll();
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        // Emulate pressing the WiFi tethering button.
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig)
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+
+        // Wifi success
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+        verifyStartHotspot();
+        TetherStatesParcel tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.tetheredList, new TetheringInterface[] {wifiIfaceWithConfig});
+        verify(startResultListener).onResult(TETHER_ERROR_NO_ERROR);
+
+        // Restart Wifi
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, request, TEST_WLAN_IFNAME);
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
+
+        // Verify we go from TETHERED -> AVAILABLE -> TETHERED with the same config.
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
+        tetherState = callback.pollTetherStatesChanged();
+        assertArrayEquals(tetherState.tetheredList, new TetheringInterface[] {wifiIfaceWithConfig});
+    }
+
+    @Test
+    public void startWifiApBroadcastDoesNotStartIpServing() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Call startTethering for wifi
+        TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(new SoftApConfiguration.Builder()
+                        .setWifiSsid(WifiSsid.fromBytes("SSID".getBytes(StandardCharsets.UTF_8)))
+                        .build())
+                .build();
+        IIntResultListener startResultListener = mock(IIntResultListener.class);
+        mTethering.startTethering(request, TEST_CALLER_PKG, startResultListener);
+        mLooper.dispatchAll();
+
+        // WIFI_AP_STATE_CHANGED broadcast should be ignored since we should only be using
+        // SoftApCallback for tethered AP.
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        verify(mNetd, never()).tetherStartWithConfiguration(any());
+        verify(mNotificationUpdater, never()).onDownstreamChanged(DOWNSTREAM_NONE);
+        verify(mWifiManager, never()).updateInterfaceIpState(
+                TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_UNSPECIFIED);
+        assertTrue(mTethering.getServingTetheringRequests().isEmpty());
+    }
+
     // TODO: Test with and without interfaceStatusChanged().
     @Test
     public void failureEnablingIpForwarding() throws Exception {
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(any(SoftApConfiguration.class))).thenReturn(true);
         doThrow(new RemoteException()).when(mNetd).ipfwdEnableForwarding(TETHERING_NAME);
 
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager, times(1)).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
         verifyNoMoreInteractions(mNetd);
         verify(mTetheringMetrics).createBuilder(eq(TETHERING_WIFI), anyString());
 
@@ -2045,7 +2210,7 @@
         // per-interface state machine to start up, and telling us that
         // tethering mode is to be started.
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         // We verify get/set called three times here: twice for setup and once during
         // teardown because all events happen over the course of the single
@@ -2079,9 +2244,9 @@
                 TEST_WLAN_IFNAME, WifiManager.IFACE_IP_MODE_CONFIGURATION_ERROR);
 
         verify(mTetheringMetrics, times(0)).maybeUpdateUpstreamType(any());
-        verify(mTetheringMetrics, times(2)).updateErrorCode(eq(TETHERING_WIFI),
+        verify(mTetheringMetrics, times(1)).updateErrorCode(eq(TETHERING_WIFI),
                 eq(TETHER_ERROR_INTERNAL_ERROR));
-        verify(mTetheringMetrics, times(2)).sendReport(eq(TETHERING_WIFI));
+        verify(mTetheringMetrics, times(1)).sendReport(eq(TETHERING_WIFI));
 
         verifyNoMoreInteractions(mWifiManager);
         verifyNoMoreInteractions(mNetd);
@@ -2373,9 +2538,9 @@
         // 2. Enable wifi tethering.
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
 
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG,
                 null);
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
@@ -2383,7 +2548,7 @@
             // Starting in B, ignore the interfaceStatusChanged
             callback.assertNoStateChangeCallback();
         }
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
         mLooper.dispatchAll();
         tetherState = callback.pollTetherStatesChanged();
         assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
@@ -2411,8 +2576,7 @@
         if (isAtLeastT()) {
             // After T, tethering doesn't support WIFI_AP_STATE_DISABLED with null interface name.
             callback2.assertNoStateChangeCallback();
-            sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
-                    IFACE_IP_MODE_TETHERED);
+            sendSoftApEvent(WIFI_AP_STATE_DISABLED, request, TEST_WLAN_IFNAME);
         }
         tetherState = callback2.pollTetherStatesChanged();
         assertArrayEquals(tetherState.availableList, new TetheringInterface[] {wifiIface});
@@ -2424,7 +2588,7 @@
 
     @Test
     public void testSoftApConfigInTetheringEventCallback() throws Exception {
-        assumeTrue(SdkLevel.isAtLeastV());
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
         when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
                 .thenReturn(PERMISSION_DENIED);
         when(mContext.checkCallingOrSelfPermission(NETWORK_STACK))
@@ -2476,19 +2640,17 @@
         callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
 
         // Enable wifi tethering
         mBinderCallingUid = TEST_CALLER_UID;
         mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
         mLooper.dispatchAll();
-        if (SdkLevel.isAtLeastB()) {
-            // Starting in B, ignore the interfaceStatusChanged
-            callback.assertNoStateChangeCallback();
-        }
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
-        mLooper.dispatchAll();
+        // Netd "up" event should not trigger a state change callback in B+.
+        callback.assertNoStateChangeCallback();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
         // Verify we see  Available -> Tethered states
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 callback.pollTetherStatesChanged().availableList);
@@ -2506,20 +2668,15 @@
         callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STARTED);
 
         // Disable wifi tethering
-        mLooper.dispatchAll();
         mTethering.stopTethering(TETHERING_WIFI);
-        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED);
-        if (isAtLeastT()) {
-            // After T, tethering doesn't support WIFI_AP_STATE_DISABLED with null interface name.
-            callback.assertNoStateChangeCallback();
-            sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
-                    IFACE_IP_MODE_TETHERED);
-        }
-        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 callback.pollTetherStatesChanged().availableList);
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 differentCallback.pollTetherStatesChanged().availableList);
-        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 settingsCallback.pollTetherStatesChanged().availableList);
         mLooper.dispatchAll();
         callback.expectUpstreamChanged(NULL_NETWORK);
@@ -2528,6 +2685,340 @@
     }
 
     @Test
+    public void testFuzzyMatchedWifiCannotBeAdded() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        TestTetheringEventCallback callback = new TestTetheringEventCallback();
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringInterface wifiIfaceWithoutConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, null);
+        final TetheringInterface wifiIfaceWithConfig = new TetheringInterface(
+                TETHERING_WIFI, TEST_WLAN_IFNAME, softApConfig);
+
+        // Register callback before running any tethering.
+        mTethering.registerTetheringEventCallback(callback);
+        mLooper.dispatchAll();
+        callback.expectTetheredClientChanged(Collections.emptyList());
+        callback.expectUpstreamChanged(NULL_NETWORK);
+        callback.expectConfigurationChanged(
+                mTethering.getTetheringConfiguration().toStableParcelable());
+        assertTetherStatesNotNullButEmpty(callback.pollTetherStatesChanged());
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STOPPED);
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Start wifi tethering but don't trigger the link layer event yet.
+        mBinderCallingUid = TEST_CALLER_UID;
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        successListener.assertDoesNotHaveResult();
+
+        // Try starting wifi tethering with various fuzzy-matching requests and verify we get
+        // TETHER_ERROR_DUPLICATE_REQUEST.
+
+        // Different static IP addresses
+        final TetheringRequest differentIpAddr = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig)
+                .setStaticIpv4Addresses(new LinkAddress("192.168.0.123/24"),
+                        new LinkAddress("192.168.0.42/24"))
+                .build();
+        differentIpAddr.setUid(TEST_CALLER_UID);
+        ResultListener differentIpAddrListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentIpAddr, TEST_CALLER_PKG, differentIpAddrListener);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
+        differentIpAddrListener.assertHasResult();
+
+        // Different UID
+        final TetheringRequest differentUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        differentUid.setUid(TEST_CALLER_UID + 1);
+        ResultListener differentUidListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentUid, TEST_CALLER_PKG, differentUidListener);
+        mLooper.dispatchAll();
+        differentUidListener.assertHasResult();
+        verifyWifiTetheringRequested();
+
+        // Mock the link layer event to start IP serving and verify
+        // 1) The original request's result listener is called.
+        // 2) We still get TETHER_ERROR_DUPLICATE_REQUEST for new requests.
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
+                callback.pollTetherStatesChanged().availableList);
+        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+                callback.pollTetherStatesChanged().tetheredList);
+        callback.expectUpstreamChanged(upstreamState.network);
+        callback.expectOffloadStatusChanged(TETHER_HARDWARE_OFFLOAD_STARTED);
+        differentIpAddrListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        differentUidListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(differentIpAddr, TEST_CALLER_PKG, differentIpAddrListener);
+        mTethering.startTethering(differentUid, TEST_CALLER_PKG, differentUidListener);
+        mLooper.dispatchAll();
+        differentIpAddrListener.assertHasResult();
+        differentUidListener.assertHasResult();
+        verify(mWifiManager, times(1)).startTetheredHotspot(any(), any(), any());
+        verify(mWifiManager, never()).stopSoftAp();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerStopped() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Trigger Netd callback to stop the IpServer
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, false);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerUnwanted() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Trigger wifi ap state change to tell IpServer it's unwanted.
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_DISABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest,
+                TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterIpServerError() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Set up the DHCP server to fail creation.
+        mIpServerDependencies.setOnDhcpServerCreatedResult(STATUS_UNKNOWN_ERROR);
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // We should be able to request the same Wifi again since the DHCP server transitioned the
+        // IpServer back to InitialState.
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterStoppingPendingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering but keep the request pending by not sending the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        ArgumentCaptor<SoftApCallback> callbackCaptor =
+                ArgumentCaptor.forClass(SoftApCallback.class);
+        verify(mWifiManager, atLeastOnce()).startTetheredHotspot(any(TetheringRequest.class),
+                any(Executor.class), callbackCaptor.capture());
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Stop Wifi tethering.
+        mTethering.stopTethering(TETHERING_WIFI);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+
+        // Mock the first request going up and then down from the stop request.
+        SoftApState softApState = mock(SoftApState.class);
+        when(softApState.getState()).thenReturn(WIFI_AP_STATE_ENABLED);
+        when(softApState.getTetheringRequest()).thenReturn(tetheringRequest);
+        when(softApState.getIface()).thenReturn(TEST_WLAN_IFNAME);
+        callbackCaptor.getValue().onStateChanged(softApState);
+        mLooper.dispatchAll();
+        successListener.assertHasResult();
+
+        // Mock the second request going up
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testFuzzyMatchedWifiCanBeAddedAfterStoppingServingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+
+        // Start wifi tethering and mock the ap state change.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder().setWifiSsid(
+                WifiSsid.fromBytes("SoftApConfig".getBytes(StandardCharsets.UTF_8))).build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        ResultListener successListener = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener.assertHasResult();
+
+        // Starting wifi again will cause TETHER_ERROR_DUPLICATE_REQUEST
+        ResultListener failureListener = new ResultListener(TETHER_ERROR_DUPLICATE_REQUEST);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, failureListener);
+        mLooper.dispatchAll();
+        failureListener.assertHasResult();
+
+        // Stop Wifi tethering.
+        mTethering.stopTethering(TETHERING_WIFI);
+
+        // We should be able to request the same Wifi again
+        ResultListener successListener2 = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, successListener2);
+        mLooper.dispatchAll();
+        sendStartTetheringSoftApCallback(WIFI_AP_STATE_ENABLED, tetheringRequest, TEST_WLAN_IFNAME);
+        successListener2.assertHasResult();
+    }
+
+    @Test
+    public void testStopTetheringWithMatchingRequest() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS)).thenReturn(PERMISSION_DENIED);
+        initTetheringOnTestThread();
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Enable wifi tethering.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig")
+                .build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+
+        // Stop tethering with non-matching config. Should fail with TETHER_ERROR_UNKNOWN_REQUEST.
+        SoftApConfiguration softApConfig2 = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig2")
+                .build();
+        IIntResultListener differentConfigListener = mock(IIntResultListener.class);
+        mTethering.stopTetheringRequest(new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig2).build(), differentConfigListener);
+        mLooper.dispatchAll();
+        verify(differentConfigListener).onResult(eq(TETHER_ERROR_UNKNOWN_REQUEST));
+        verify(mWifiManager, never()).stopSoftAp();
+
+        // Stop tethering with non-matching UID. Should fail with TETHER_ERROR_UNKNOWN_REQUEST.
+        final TetheringRequest nonMatchingUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        IIntResultListener nonMatchingUidListener = mock(IIntResultListener.class);
+        nonMatchingUid.setUid(TEST_CALLER_UID_2);
+        mTethering.stopTetheringRequest(nonMatchingUid, nonMatchingUidListener);
+        mLooper.dispatchAll();
+        verify(nonMatchingUidListener).onResult(eq(TETHER_ERROR_UNKNOWN_REQUEST));
+        verify(mWifiManager, never()).stopSoftAp();
+
+        // Stop tethering with matching request. Should succeed now.
+        IIntResultListener matchingListener = mock(IIntResultListener.class);
+        mTethering.stopTetheringRequest(tetheringRequest, matchingListener);
+        mLooper.dispatchAll();
+        verify(matchingListener).onResult(eq(TETHER_ERROR_NO_ERROR));
+        verify(mWifiManager).stopSoftAp();
+    }
+
+    @Test
+    public void testStopTetheringWithSettingsPermission() throws Exception {
+        assumeTrue(mTetheringDependencies.isTetheringWithSoftApConfigEnabled());
+        initTetheringOnTestThread();
+        UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
+        initTetheringUpstream(upstreamState);
+
+        // Enable wifi tethering.
+        SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                .setSsid("SoftApConfig")
+                .build();
+        final TetheringRequest tetheringRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        tetheringRequest.setUid(TEST_CALLER_UID);
+        mTethering.startTethering(tetheringRequest, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+
+        // Stop tethering with non-matching UID and Settings permission. Should succeed.
+        final TetheringRequest nonMatchingUid = new TetheringRequest.Builder(TETHERING_WIFI)
+                .setSoftApConfiguration(softApConfig).build();
+        IIntResultListener nonMatchingUidListener = mock(IIntResultListener.class);
+        nonMatchingUid.setUid(TEST_CALLER_UID_2);
+        when(mContext.checkCallingOrSelfPermission(NETWORK_SETTINGS))
+                .thenReturn(PERMISSION_GRANTED);
+        mTethering.stopTetheringRequest(nonMatchingUid, nonMatchingUidListener);
+        mLooper.dispatchAll();
+        verify(nonMatchingUidListener).onResult(eq(TETHER_ERROR_NO_ERROR));
+        verify(mWifiManager).stopSoftAp();
+    }
+
+    @Test
     public void testReportFailCallbackIfOffloadNotSupported() throws Exception {
         initTetheringOnTestThread();
         final UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
@@ -2793,7 +3284,8 @@
     }
 
     @Test
-    public void testMultipleStartTethering() throws Exception {
+    public void testMultipleStartTetheringLegacy() throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
         initTetheringOnTestThread();
         final LinkAddress serverLinkAddr = new LinkAddress("192.168.20.1/24");
         final LinkAddress clientLinkAddr = new LinkAddress("192.168.20.42/24");
@@ -2879,6 +3371,43 @@
     }
 
     @Test
+    @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRequestStaticIpLegacyTether() throws Exception {
+        initTetheringOnTestThread();
+
+        // Call startTethering with static ip
+        final LinkAddress serverLinkAddr = new LinkAddress("192.168.0.123/24");
+        final LinkAddress clientLinkAddr = new LinkAddress("192.168.0.42/24");
+        final String serverAddr = "192.168.0.123";
+        final int clientAddrParceled = 0xc0a8002a;
+        final ArgumentCaptor<DhcpServingParamsParcel> dhcpParamsCaptor =
+                ArgumentCaptor.forClass(DhcpServingParamsParcel.class);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI,
+                        serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL, null),
+                TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
+        mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
+
+        // Call legacyTether on the interface before the link layer event comes back.
+        // This happens, for example, in pre-T bluetooth tethering: Settings calls startTethering,
+        // and then the bluetooth code calls the tether() API.
+        final ResultListener tetherResult = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.legacyTether(TEST_WLAN_IFNAME, tetherResult);
+        mLooper.dispatchAll();
+        tetherResult.assertHasResult();
+
+        // Verify that the static ip set in startTethering is used
+        verify(mNetd).interfaceSetCfg(argThat(cfg -> serverAddr.equals(cfg.ipv4Addr)));
+        verify(mIpServerDependencies, times(1)).makeDhcpServer(any(), dhcpParamsCaptor.capture(),
+                any());
+        final DhcpServingParamsParcel params = dhcpParamsCaptor.getValue();
+        assertEquals(serverAddr, intToInet4AddressHTH(params.serverAddr).getHostAddress());
+        assertEquals(24, params.serverAddrPrefixLength);
+        assertEquals(clientAddrParceled, params.singleClientAddr);
+    }
+
+    @Test
     public void testUpstreamNetworkChanged() throws Exception {
         initTetheringOnTestThread();
         final InOrder inOrder = inOrder(mNotificationUpdater);
@@ -3016,26 +3545,38 @@
         mLooper.dispatchAll();
         verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
         reset(mEntitleMgr);
+    }
 
-        // If one app enables tethering without provisioning check first, then another app enables
-        // tethering of the same type but does not disable the provisioning check.
+    @Test
+    public void testNonExemptRequestAddedAfterExemptRequestOfSameType() throws Exception {
+        // Note: When fuzzy-matching is enabled, it is not possible yet to have two concurrent
+        // requests of the same type that are subject to carrier entitlement due to fuzzy-matching.
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
         setupForRequiredProvisioning();
+        final TetheringRequest wifiExemptRequest =
+                createTetheringRequest(TETHERING_WIFI, null, null, true,
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
         verify(mEntitleMgr, never()).startProvisioningIfNeeded(TETHERING_WIFI, false);
         verify(mEntitleMgr).setExemptedDownstreamType(TETHERING_WIFI);
         assertTrue(mEntitleMgr.isCellularUpstreamPermitted());
         reset(mEntitleMgr);
+
         setupForRequiredProvisioning();
+        final TetheringRequest wifiNotExemptRequest =
+                createTetheringRequest(TETHERING_WIFI, null, null, false,
+                        CONNECTIVITY_SCOPE_GLOBAL, null);
         mTethering.startTethering(wifiNotExemptRequest, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
+        verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
         verify(mEntitleMgr).startProvisioningIfNeeded(TETHERING_WIFI, false);
         verify(mEntitleMgr, never()).setExemptedDownstreamType(TETHERING_WIFI);
         assertFalse(mEntitleMgr.isCellularUpstreamPermitted());
         mTethering.stopTethering(TETHERING_WIFI);
         mLooper.dispatchAll();
-        verify(mEntitleMgr).stopProvisioningIfNeeded(TETHERING_WIFI);
-        reset(mEntitleMgr);
+        verify(mEntitleMgr, times(2)).stopProvisioningIfNeeded(TETHERING_WIFI);
     }
 
     private void setupForRequiredProvisioning() {
@@ -3217,8 +3758,11 @@
         reset(mDhcpServer);
 
         // Run wifi tethering.
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS)).startWithCallbacks(
                 any(), dhcpEventCbsCaptor.capture());
         eventCallbacks = dhcpEventCbsCaptor.getValue();
@@ -3281,8 +3825,12 @@
         });
         callback.expectTetheredClientChanged(Collections.emptyList());
 
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
+        verifyWifiTetheringRequested();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
         final ArgumentCaptor<IDhcpEventCallbacks> dhcpEventCbsCaptor =
                  ArgumentCaptor.forClass(IDhcpEventCallbacks.class);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS)).startWithCallbacks(
@@ -3533,6 +4081,33 @@
         failedEnable.assertHasResult();
     }
 
+    @Test
+    public void testStartBluetoothTetheringFailsWhenTheresAnExistingRequestWaitingForPanService()
+            throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
+
+        mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
+        final ResultListener firstResult = new ResultListener(TETHER_ERROR_NO_ERROR);
+        mTethering.startTethering(createTetheringRequest(TETHERING_BLUETOOTH),
+                TEST_CALLER_PKG, firstResult);
+        mLooper.dispatchAll();
+        firstResult.assertDoesNotHaveResult();
+
+        // Second request should fail.
+        final ResultListener secondResult = new ResultListener(TETHER_ERROR_SERVICE_UNAVAIL);
+        mTethering.startTethering(createTetheringRequest(TETHERING_BLUETOOTH),
+                TEST_CALLER_PKG, secondResult);
+        mLooper.dispatchAll();
+        secondResult.assertHasResult();
+        firstResult.assertDoesNotHaveResult();
+
+        // Bind to PAN service should succeed for first listener only. If the second result is
+        // called with TETHER_ERROR_NO_ERROR, ResultListener will fail an assertion.
+        verifySetBluetoothTethering(true /* enable */, true /* bindToPanService */);
+        firstResult.assertHasResult();
+    }
+
     private void mockBluetoothSettings(boolean bluetoothOn, boolean tetheringOn) {
         when(mBluetoothAdapter.isEnabled()).thenReturn(bluetoothOn);
         when(mBluetoothPan.isTetheringOn()).thenReturn(tetheringOn);
@@ -3580,7 +4155,7 @@
     private ServiceListener verifySetBluetoothTethering(final boolean enable,
             final boolean bindToPanService) throws Exception {
         ServiceListener listener = null;
-        verify(mBluetoothAdapter).isEnabled();
+        verify(mBluetoothAdapter, atLeastOnce()).isEnabled();
         if (bindToPanService) {
             final ArgumentCaptor<ServiceListener> listenerCaptor =
                     ArgumentCaptor.forClass(ServiceListener.class);
@@ -3786,8 +4361,11 @@
     @Test
     public void testIpv4AddressForSapAndLohsConcurrency() throws Exception {
         initTetheringOnTestThread();
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
+        mLooper.dispatchAll();
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         ArgumentCaptor<InterfaceConfigurationParcel> ifaceConfigCaptor =
                 ArgumentCaptor.forClass(InterfaceConfigurationParcel.class);
@@ -3808,6 +4386,21 @@
     }
 
     @Test
+    public void testFailStartTetheredHotspotWithoutRequest() throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
+        initTetheringOnTestThread();
+        when(mWifiManager.startTetheredHotspot(null)).thenReturn(false);
+
+        ResultListener result = new ResultListener(TETHER_ERROR_INTERNAL_ERROR);
+        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG, result);
+        mLooper.dispatchAll();
+        verify(mWifiManager).startTetheredHotspot(null);
+        verifyNoMoreInteractions(mWifiManager);
+        result.assertHasResult();
+        assertTrue(mTethering.getPendingTetheringRequests().isEmpty());
+    }
+
+    @Test
     public void testWifiTetheringWhenP2pActive() throws Exception {
         initTetheringOnTestThread();
         // Enable wifi P2P.
@@ -3820,16 +4413,14 @@
         verify(mUpstreamNetworkMonitor, never()).setTryCell(true);
         assertEquals(TETHER_ERROR_NO_ERROR, mTethering.getLastErrorForTest(TEST_P2P_IFNAME));
 
-        when(mWifiManager.startTetheredHotspot(any())).thenReturn(true);
         // Emulate pressing the WiFi tethering button.
-        mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
-                null);
+        TetheringRequest request = createTetheringRequest(TETHERING_WIFI);
+        mTethering.startTethering(request, TEST_CALLER_PKG, null);
         mLooper.dispatchAll();
-        verify(mWifiManager).startTetheredHotspot(null);
-        verifyNoMoreInteractions(mWifiManager);
+        verifyWifiTetheringRequested();
 
         mTethering.interfaceStatusChanged(TEST_WLAN_IFNAME, true);
-        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        sendSoftApEvent(WIFI_AP_STATE_ENABLED, request, TEST_WLAN_IFNAME);
 
         verifyTetheringBroadcast(TEST_WLAN_IFNAME, EXTRA_AVAILABLE_TETHER);
         verify(mWifiManager).updateInterfaceIpState(
diff --git a/bpf/dns_helper/DnsBpfHelper.cpp b/bpf/dns_helper/DnsBpfHelper.cpp
index 0719ade..cf2fa2b 100644
--- a/bpf/dns_helper/DnsBpfHelper.cpp
+++ b/bpf/dns_helper/DnsBpfHelper.cpp
@@ -32,12 +32,44 @@
     }                                                                                              \
   } while (0)
 
+// copied from BpfHandler.cpp
+static bool mainlineNetBpfLoadDone() {
+  return !access("/sys/fs/bpf/netd_shared/mainline_done", F_OK);
+}
+
+// copied from BpfHandler.cpp
+static inline void waitForNetProgsLoaded() {
+  // infinite loop until success with 5/10/20/40/60/60/60... delay
+  for (int delay = 5;; delay *= 2) {
+    if (delay > 60) delay = 60;
+    if (base::WaitForProperty("init.svc.mdnsd_netbpfload", "stopped", std::chrono::seconds(delay))
+      && mainlineNetBpfLoadDone()) return;
+    LOG(WARNING) << "Waited " << delay << "s for init.svc.mdnsd_netbpfload=stopped, still waiting.";
+  }
+}
+
 base::Result<void> DnsBpfHelper::init() {
-  if (!android::modules::sdklevel::IsAtLeastT()) {
-    LOG(ERROR) << __func__ << ": Unsupported before Android T.";
+  if (!android::modules::sdklevel::IsAtLeastS()) {
+    LOG(ERROR) << __func__ << ": Unsupported before Android S.";
     return base::Error(EOPNOTSUPP);
   }
 
+  if (!android::modules::sdklevel::IsAtLeastT()) {
+    LOG(INFO) << "performing Android S mainline NetBpfload magic!";
+    if (!mainlineNetBpfLoadDone()) {
+      // We're on S/Sv2 & it's the first time netd is starting up (unless crashlooping)
+      if (!base::SetProperty("ctl.start", "mdnsd_netbpfload")) {
+        LOG(ERROR) << "Failed to set property ctl.start=mdnsd_netbpfload, see dmesg for reason.";
+        return base::Error(ENOEXEC);
+      }
+
+      LOG(INFO) << "Waiting for Networking BPF programs";
+      waitForNetProgsLoaded();
+      LOG(INFO) << "Networking BPF programs are loaded";
+    }
+    return {};
+  }
+
   RETURN_IF_RESULT_NOT_OK(mConfigurationMap.init(CONFIGURATION_MAP_PATH));
   RETURN_IF_RESULT_NOT_OK(mUidOwnerMap.init(UID_OWNER_MAP_PATH));
   RETURN_IF_RESULT_NOT_OK(mDataSaverEnabledMap.init(DATA_SAVER_ENABLED_MAP_PATH));
diff --git a/bpf/headers/include/bpf_helpers.h b/bpf/headers/include/bpf_helpers.h
index 6a0e5a8..9d6b6f6 100644
--- a/bpf/headers/include/bpf_helpers.h
+++ b/bpf/headers/include/bpf_helpers.h
@@ -46,12 +46,12 @@
 #define BPFLOADER_U_QPR2_VERSION 41u
 #define BPFLOADER_PLATFORM_VERSION BPFLOADER_U_QPR2_VERSION
 
-// Android Mainline - this bpfloader should eventually go back to T (or even S)
+// Android Mainline BpfLoader when running on Android S (sdk=31)
 // Note: this value (and the following +1u's) are hardcoded in NetBpfLoad.cpp
-#define BPFLOADER_MAINLINE_VERSION 42u
+#define BPFLOADER_MAINLINE_S_VERSION 42u
 
 // Android Mainline BpfLoader when running on Android T (sdk=33)
-#define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_VERSION + 1u)
+#define BPFLOADER_MAINLINE_T_VERSION (BPFLOADER_MAINLINE_S_VERSION + 1u)
 
 // Android Mainline BpfLoader when running on Android U (sdk=34)
 #define BPFLOADER_MAINLINE_U_VERSION (BPFLOADER_MAINLINE_T_VERSION + 1u)
@@ -112,7 +112,7 @@
     unsigned int _bpfloader_max_ver SECTION("bpfloader_max_ver") = BPFLOADER_MAX_VER;              \
     size_t _size_of_bpf_map_def SECTION("size_of_bpf_map_def") = sizeof(struct bpf_map_def);       \
     size_t _size_of_bpf_prog_def SECTION("size_of_bpf_prog_def") = sizeof(struct bpf_prog_def);    \
-    unsigned _btf_min_bpfloader_ver SECTION("btf_min_bpfloader_ver") = BPFLOADER_MAINLINE_VERSION; \
+    unsigned _btf_min_bpfloader_ver SECTION("btf_min_bpfloader_ver") = BPFLOADER_MAINLINE_S_VERSION; \
     unsigned _btf_user_min_bpfloader_ver SECTION("btf_user_min_bpfloader_ver") = 0xFFFFFFFFu;      \
     char _license[] SECTION("license") = (NAME)
 
diff --git a/bpf/headers/include/bpf_map_def.h b/bpf/headers/include/bpf_map_def.h
index e95ca5f..2e5afca 100644
--- a/bpf/headers/include/bpf_map_def.h
+++ b/bpf/headers/include/bpf_map_def.h
@@ -163,7 +163,7 @@
     enum bpf_map_type type;
     unsigned int key_size;
     unsigned int value_size;
-    int max_entries;  // negative means BPF_F_NO_PREALLOC, but *might* not work with S
+    unsigned int max_entries;
     unsigned int map_flags;
 
     // The following are not supported by the Android bpfloader:
diff --git a/bpf/loader/Android.bp b/bpf/loader/Android.bp
index b08913a..345e92b 100644
--- a/bpf/loader/Android.bp
+++ b/bpf/loader/Android.bp
@@ -42,6 +42,7 @@
     shared_libs: [
         "libbase",
         "liblog",
+        "libbpf",
     ],
     srcs: ["NetBpfLoad.cpp"],
     apex_available: [
@@ -56,11 +57,22 @@
     installable: false,
 }
 
-// Versioned netbpfload init rc: init system will process it only on api T/33+ devices
+// Versioned netbpfload init rc: init system will process it only on api R/30 S/31 Sv2/32 devices
 // Note: R[30] S[31] Sv2[32] T[33] U[34] V[35])
 //
 // For details of versioned rc files see:
 // https://android.googlesource.com/platform/system/core/+/HEAD/init/README.md#versioned-rc-files-within-apexs
+//
+// However, .Xrc versioning doesn't work on S, so we use unversioned, and thus *do* trigger on R,
+// luckily nothing ever uses the new service on R, so you can think of it as being S/Sv2 only
+prebuilt_etc {
+    name: "netbpfload.31rc",
+    src: "netbpfload.31rc",
+    filename: "netbpfload.rc", // intentional: .31rc wouldn't take effect on S
+    installable: false,
+}
+
+// Versioned netbpfload init rc: init system will process it only on api T/33+ devices
 prebuilt_etc {
     name: "netbpfload.33rc",
     src: "netbpfload.33rc",
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 9486e75..b9ef766 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -17,6 +17,7 @@
 #define LOG_TAG "NetBpfLoad"
 
 #include <arpa/inet.h>
+#include <bpf/libbpf.h>
 #include <dirent.h>
 #include <elf.h>
 #include <errno.h>
@@ -60,7 +61,7 @@
 #include "bpf_map_def.h"
 
 // The following matches bpf_helpers.h, which is only for inclusion in bpf code
-#define BPFLOADER_MAINLINE_VERSION 42u
+#define BPFLOADER_MAINLINE_S_VERSION 42u
 #define BPFLOADER_MAINLINE_25Q2_VERSION 47u
 
 using android::base::EndsWith;
@@ -122,6 +123,7 @@
 struct Location {
     const char* const dir = "";
     const char* const prefix = "";
+    const bool t_plus = true;
 };
 
 // Returns the build type string (from ro.build.type).
@@ -1187,7 +1189,7 @@
     ret = readCodeSections(elfFile, cs);
     // BPF .o's with no programs are only supported by mainline netbpfload,
     // make sure .o's targeting non-mainline (ie. S) bpfloader don't show up.
-    if (ret == -ENOENT && bpfLoaderMinVer >= BPFLOADER_MAINLINE_VERSION)
+    if (ret == -ENOENT && bpfLoaderMinVer >= BPFLOADER_MAINLINE_S_VERSION)
         return 0;
     if (ret) {
         ALOGE("Couldn't read all code sections in %s", elfPath);
@@ -1216,8 +1218,9 @@
 const Location locations[] = {
         // S+ Tethering mainline module (network_stack): tether offload
         {
-                .dir = BPFROOT "/",
+                .dir = BPFROOT "/tethering/",
                 .prefix = "tethering/",
+                .t_plus = false,
         },
         // T+ Tethering mainline module (shared with netd & system server)
         // netutils_wrapper (for iptables xt_bpf) has access to programs
@@ -1412,6 +1415,13 @@
 }
 
 static int doLoad(char** argv, char * const envp[]) {
+    if (!isAtLeastS) {
+        ALOGE("Impossible - not reachable on Android <S.");
+        // for safety, we don't fail, this is a just-in-case workaround
+        // for any possible busted 'optimized' start everything vendor init hacks on R
+        return 0;
+    }
+
     const bool runningAsRoot = !getuid();  // true iff U QPR3 or V+
 
     const int first_api_level = GetIntProperty("ro.board.first_api_level", api_level);
@@ -1422,17 +1432,19 @@
     const bool has_platform_netbpfload_rc = exists("/system/etc/init/netbpfload.rc");
 
     // Version of Network BpfLoader depends on the Android OS version
-    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_VERSION;  // [42u]
+    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_S_VERSION;  // [42u]
     if (isAtLeastT) ++bpfloader_ver;     // [43] BPFLOADER_MAINLINE_T_VERSION
     if (isAtLeastU) ++bpfloader_ver;     // [44] BPFLOADER_MAINLINE_U_VERSION
     if (runningAsRoot) ++bpfloader_ver;  // [45] BPFLOADER_MAINLINE_U_QPR3_VERSION
     if (isAtLeastV) ++bpfloader_ver;     // [46] BPFLOADER_MAINLINE_V_VERSION
     if (isAtLeast25Q2) ++bpfloader_ver;  // [47] BPFLOADER_MAINLINE_25Q2_VERSION
 
-    ALOGI("NetBpfLoad v0.%u (%s) api:%d/%d kver:%07x (%s) uid:%d rc:%d%d",
+    ALOGI("NetBpfLoad v0.%u (%s) api:%d/%d kver:%07x (%s) libbpf: v%u.%u "
+          "uid:%d rc:%d%d",
           bpfloader_ver, argv[0], android_get_device_api_level(), api_level,
-          kernelVersion(), describeArch(), getuid(),
-          has_platform_bpfloader_rc, has_platform_netbpfload_rc);
+          kernelVersion(), describeArch(), libbpf_major_version(),
+          libbpf_minor_version(), getuid(), has_platform_bpfloader_rc,
+          has_platform_netbpfload_rc);
 
     if (!has_platform_bpfloader_rc && !has_platform_netbpfload_rc) {
         ALOGE("Unable to find platform's bpfloader & netbpfload init scripts.");
@@ -1446,14 +1458,9 @@
 
     logTetheringApexVersion();
 
-    if (!isAtLeastT) {
-        ALOGE("Impossible - not reachable on Android <T.");
-        return 1;
-    }
-
     // both S and T require kernel 4.9 (and eBpf support)
-    if (isAtLeastT && !isAtLeastKernelVersion(4, 9, 0)) {
-        ALOGE("Android T requires kernel 4.9.");
+    if (!isAtLeastKernelVersion(4, 9, 0)) {
+        ALOGE("Android S & T require kernel 4.9.");
         return 1;
     }
 
@@ -1622,18 +1629,22 @@
     //  which could otherwise fail with ENOENT during object pinning or renaming,
     //  due to ordering issues)
     for (const auto& location : locations) {
+        if (location.t_plus && !isAtLeastT) continue;
         if (createSysFsBpfSubDir(location.prefix)) return 1;
     }
 
-    // Note: there's no actual src dir for fs_bpf_loader .o's,
-    // so it is not listed in 'locations[].prefix'.
-    // This is because this is primarily meant for triggering genfscon rules,
-    // and as such this will likely always be the case.
-    // Thus we need to manually create the /sys/fs/bpf/loader subdirectory.
-    if (createSysFsBpfSubDir("loader")) return 1;
+    if (isAtLeastT) {
+        // Note: there's no actual src dir for fs_bpf_loader .o's,
+        // so it is not listed in 'locations[].prefix'.
+        // This is because this is primarily meant for triggering genfscon rules,
+        // and as such this will likely always be the case.
+        // Thus we need to manually create the /sys/fs/bpf/loader subdirectory.
+        if (createSysFsBpfSubDir("loader")) return 1;
+    }
 
     // Load all ELF objects, create programs and maps, and pin them
     for (const auto& location : locations) {
+        if (location.t_plus && !isAtLeastT) continue;
         if (loadAllElfObjects(bpfloader_ver, location) != 0) {
             ALOGE("=== CRITICAL FAILURE LOADING BPF PROGRAMS FROM %s ===", location.dir);
             ALOGE("If this triggers reliably, you're probably missing kernel options or patches.");
@@ -1654,6 +1665,9 @@
         return 1;
     }
 
+    // on S we haven't created this subdir yet, but we need it for 'mainline_done' flag below
+    if (!isAtLeastT && createSysFsBpfSubDir("netd_shared")) return 1;
+
     // leave a flag that we're done
     if (createSysFsBpfSubDir("netd_shared/mainline_done")) return 1;
 
@@ -1688,7 +1702,12 @@
 }  // namespace android
 
 int main(int argc, char** argv, char * const envp[]) {
-    InitLogging(argv, &KernelLogger);
+    if (android::bpf::isAtLeastT) {
+        InitLogging(argv, &KernelLogger);
+    } else {
+        // S lacks the sepolicy to make non-root uid KernelLogger viable
+        InitLogging(argv);
+    }
 
     if (argc == 2 && !strcmp(argv[1], "done")) {
         // we're being re-exec'ed from platform bpfloader to 'finalize' things
diff --git a/bpf/loader/netbpfload.31rc b/bpf/loader/netbpfload.31rc
new file mode 100644
index 0000000..bca7dc8
--- /dev/null
+++ b/bpf/loader/netbpfload.31rc
@@ -0,0 +1,13 @@
+# This file takes effect only on S and Sv2
+# (Note: it does take effect on R as well, but isn't actually used)
+#
+# The service is started from netd's dnsresolver call into ADnsHelper_init()
+# on initial (boot time) startup of netd.
+
+service mdnsd_netbpfload /apex/com.android.tethering/bin/netbpfload
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    group system root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw
+    user system
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,netbpfload-failed
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index e3e508b..d41aa81 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -341,8 +341,8 @@
     if (chargeUid == AID_CLAT) return -EPERM;
 
     // The socket destroy listener only monitors on the group {INET_TCP, INET_UDP, INET6_TCP,
-    // INET6_UDP}. Tagging listener unsupported socket causes that the tag can't be removed from
-    // tag map automatically. Eventually, the tag map may run out of space because of dead tag
+    // INET6_UDP}. Tagging listener unsupported sockets (on <5.10) means the tag cannot be
+    // removed from tag map automatically. Eventually, it may run out of space due to dead tag
     // entries. Note that although tagSocket() of net client has already denied the family which
     // is neither AF_INET nor AF_INET6, the family validation is still added here just in case.
     // See tagSocket in system/netd/client/NetdClient.cpp and
@@ -360,15 +360,19 @@
         return -EAFNOSUPPORT;
     }
 
-    int socketProto;
-    socklen_t protoLen = sizeof(socketProto);
-    if (getsockopt(sockFd, SOL_SOCKET, SO_PROTOCOL, &socketProto, &protoLen)) {
-        ALOGE("Failed to getsockopt SO_PROTOCOL: %s, fd: %d", strerror(errno), sockFd);
-        return -errno;
-    }
-    if (socketProto != IPPROTO_UDP && socketProto != IPPROTO_TCP) {
-        ALOGV("Unsupported protocol: %d", socketProto);
-        return -EPROTONOSUPPORT;
+    // On 5.10+ the BPF_CGROUP_INET_SOCK_RELEASE hook takes care of cookie tag map cleanup
+    // during socket destruction. As such the socket destroy listener is superfluous.
+    if (!isAtLeastKernelVersion(5, 10, 0)) {
+        int socketProto;
+        socklen_t protoLen = sizeof(socketProto);
+        if (getsockopt(sockFd, SOL_SOCKET, SO_PROTOCOL, &socketProto, &protoLen)) {
+            ALOGE("Failed to getsockopt SO_PROTOCOL: %s, fd: %d", strerror(errno), sockFd);
+            return -errno;
+        }
+        if (socketProto != IPPROTO_UDP && socketProto != IPPROTO_TCP) {
+            ALOGV("Unsupported protocol: %d", socketProto);
+            return -EPROTONOSUPPORT;
+        }
     }
 
     uint64_t sock_cookie = getSocketCookie(sockFd);
diff --git a/bpf/netd/BpfHandlerTest.cpp b/bpf/netd/BpfHandlerTest.cpp
index b38fa16..4002b4c 100644
--- a/bpf/netd/BpfHandlerTest.cpp
+++ b/bpf/netd/BpfHandlerTest.cpp
@@ -191,7 +191,11 @@
     int rawSocket = socket(AF_INET, SOCK_RAW | SOCK_CLOEXEC, IPPROTO_RAW);
     EXPECT_LE(0, rawSocket);
     EXPECT_NE(NONEXISTENT_COOKIE, getSocketCookie(rawSocket));
-    EXPECT_EQ(-EPROTONOSUPPORT, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    if (isAtLeastKernelVersion(5, 10, 0)) {
+        EXPECT_EQ(0, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    } else {
+        EXPECT_EQ(-EPROTONOSUPPORT, mBh.tagSocket(rawSocket, TEST_TAG, TEST_UID, TEST_UID));
+    }
 }
 
 TEST_F(BpfHandlerTest, TestTagSocketWithoutPermission) {
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index 20d194c..2bfe613 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -69,32 +69,16 @@
     sub_dir: "net_shared",
 }
 
-// Ships to Android S, the bpfloader of which fails to parse BTF enabled .o's.
 bpf {
     name: "offload.o",
     srcs: ["offload.c"],
-    btf: false,
+    sub_dir: "tethering",
 }
 
-// This version ships to Android T+ which uses mainline netbpfload.
-bpf {
-    name: "offload@mainline.o",
-    srcs: ["offload@mainline.c"],
-    cflags: ["-DMAINLINE"],
-}
-
-// Ships to Android S, the bpfloader of which fails to parse BTF enabled .o's.
 bpf {
     name: "test.o",
     srcs: ["test.c"],
-    btf: false,
-}
-
-// This version ships to Android T+ which uses mainline netbpfload.
-bpf {
-    name: "test@mainline.o",
-    srcs: ["test@mainline.c"],
-    cflags: ["-DMAINLINE"],
+    sub_dir: "tethering",
 }
 
 bpf {
diff --git a/bpf/progs/clatd.c b/bpf/progs/clatd.c
index 2d4551e..2bb9d6f 100644
--- a/bpf/progs/clatd.c
+++ b/bpf/progs/clatd.c
@@ -288,6 +288,9 @@
     // We cannot handle IP options, just standard 20 byte == 5 dword minimal IPv4 header
     if (ip4->ihl != 5) return TC_ACT_PIPE;
 
+    // Packet must not be multicast
+    if ((ip4->daddr & 0xf0000000) == 0xe0000000) return TC_ACT_PIPE;
+
     // Calculate the IPv4 one's complement checksum of the IPv4 header.
     __wsum sum4 = 0;
     for (unsigned i = 0; i < sizeof(*ip4) / sizeof(__u16); ++i) {
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index 08635b3..b146e45 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -597,7 +597,7 @@
 //    V |     |      |  x   |  x  |  x   |  x   |  x  |  x  |      | (netbpfload)
 //    U |     |  x   |  x   |  x  |  x   |  x   |  x  |     |      |
 //    T |  x  |  x   |  x   |  x  |  x   |  x   |     |     |      | (magic netbpfload)
-//    S |  x  |  x   |  x   |  x  |  x   |      |     |     |      | (platform loads offload)
+//    S |  x  |  x   |  x   |  x  |  x   |      |     |     |      | (dns netbpfload for offload)
 //    R |  x  |  x   |  x   |  x  |      |      |     |     |      | (no mainline ebpf)
 //
 // Not relevant for eBPF, but R can also run on 4.4
diff --git a/bpf/progs/offload.c b/bpf/progs/offload.c
index 0f23844..b34fe6f 100644
--- a/bpf/progs/offload.c
+++ b/bpf/progs/offload.c
@@ -14,16 +14,8 @@
  * limitations under the License.
  */
 
-#ifdef MAINLINE
-// BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
-// ship a different file than for later versions, but we need bpfloader v0.25+
-// for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-#else /* MAINLINE */
-// The resulting .o needs to load on the Android S bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
-#endif /* MAINLINE */
+// The resulting .o needs to load on Android S+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_S_VERSION
 
 #include "bpf_net_helpers.h"
 #include "offload.h"
diff --git a/bpf/progs/test.c b/bpf/progs/test.c
index 8585118..4dba6b9 100644
--- a/bpf/progs/test.c
+++ b/bpf/progs/test.c
@@ -14,16 +14,8 @@
  * limitations under the License.
  */
 
-#ifdef MAINLINE
-// BTF is incompatible with bpfloaders < v0.10, hence for S (v0.2) we must
-// ship a different file than for later versions, but we need bpfloader v0.25+
-// for obj@ver.o support
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-#else /* MAINLINE */
-// The resulting .o needs to load on the Android S bpfloader
-#define BPFLOADER_MIN_VER BPFLOADER_S_VERSION
-#define BPFLOADER_MAX_VER BPFLOADER_T_VERSION
-#endif /* MAINLINE */
+// The resulting .o needs to load on Android S+
+#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_S_VERSION
 
 // This is non production code, only used for testing
 // Needed because the bitmap array definition is non-kosher for pre-T OS devices.
diff --git a/bpf/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
index 75fb8e9..4d5f9b5 100644
--- a/bpf/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -196,7 +196,12 @@
 
     // S requires Linux Kernel 4.9+ and thus requires eBPF support.
     if (isAtLeastS) ASSERT_TRUE(isAtLeastKernelVersion(4, 9, 0));
-    DO_EXPECT(isAtLeastS, MAINLINE_FOR_S_PLUS);
+
+    // on S without a new enough DnsResolver apex, NetBpfLoad doesn't get triggered,
+    // and thus no mainline programs get loaded.
+    bool mainlineBpfCapableResolve = !access("/apex/com.android.resolv/NetBpfLoad-S.flag", F_OK);
+    bool mainlineNetBpfLoad = isAtLeastT || mainlineBpfCapableResolve;
+    DO_EXPECT(isAtLeastS && mainlineNetBpfLoad, MAINLINE_FOR_S_PLUS);
 
     // Nothing added or removed in SCv2.
 
diff --git a/clatd/clatd.c b/clatd/clatd.c
index bac8b1d..9b1d987 100644
--- a/clatd/clatd.c
+++ b/clatd/clatd.c
@@ -49,7 +49,8 @@
 
 struct clat_config Global_Clatd_Config;
 
-volatile sig_atomic_t running = 1;
+volatile sig_atomic_t sigterm = 0;
+bool running = true;
 
 // reads IPv6 packet from AF_PACKET socket, translates to IPv4, writes to tun
 void process_packet_6_to_4(struct tun_data *tunnel) {
@@ -78,10 +79,11 @@
     if (errno != EAGAIN) {
       logmsg(ANDROID_LOG_WARN, "%s: read error: %s", __func__, strerror(errno));
     }
+    if (errno == ENETDOWN) running = false;
     return;
   } else if (readlen == 0) {
     logmsg(ANDROID_LOG_WARN, "%s: packet socket removed?", __func__);
-    running = 0;
+    running = false;
     return;
   } else if (readlen >= sizeof(buf)) {
     logmsg(ANDROID_LOG_WARN, "%s: read truncation - ignoring pkt", __func__);
@@ -161,10 +163,11 @@
     if (errno != EAGAIN) {
       logmsg(ANDROID_LOG_WARN, "%s: read error: %s", __func__, strerror(errno));
     }
+    if (errno == ENETDOWN) running = false;  // not sure if this can happen
     return;
   } else if (readlen == 0) {
     logmsg(ANDROID_LOG_WARN, "%s: tun interface removed", __func__);
-    running = 0;
+    running = false;
     return;
   } else if (readlen >= sizeof(buf)) {
     logmsg(ANDROID_LOG_WARN, "%s: read truncation - ignoring pkt", __func__);
@@ -273,23 +276,12 @@
  *   tunnel - tun device data
  */
 void event_loop(struct tun_data *tunnel) {
-  // Apparently some network gear will refuse to perform NS for IPs that aren't DAD'ed,
-  // this would then result in an ipv6-only network with working native ipv6, working
-  // IPv4 via DNS64, but non-functioning IPv4 via CLAT (ie. IPv4 literals + IPv4 only apps).
-  // The kernel itself doesn't do DAD for anycast ips (but does handle IPV6 MLD and handle ND).
-  // So we'll spoof dad here, and yeah, we really should check for a response and in
-  // case of failure pick a different IP.  Seeing as 48-bits of the IP are utterly random
-  // (with the other 16 chosen to guarantee checksum neutrality) this seems like a remote
-  // concern...
-  // TODO: actually perform true DAD
-  send_dad(tunnel->write_fd6, &Global_Clatd_Config.ipv6_local_subnet);
-
   struct pollfd wait_fd[] = {
     { tunnel->read_fd6, POLLIN, 0 },
     { tunnel->fd4, POLLIN, 0 },
   };
 
-  while (running) {
+  while (running && !sigterm) {
     if (poll(wait_fd, ARRAY_SIZE(wait_fd), -1) == -1) {
       if (errno != EINTR) {
         logmsg(ANDROID_LOG_WARN, "event_loop/poll returned an error: %s", strerror(errno));
diff --git a/clatd/clatd.h b/clatd/clatd.h
index e170c58..daa5ebc 100644
--- a/clatd/clatd.h
+++ b/clatd/clatd.h
@@ -52,7 +52,9 @@
 
 #define ARRAY_SIZE(x) (sizeof(x) / sizeof((x)[0]))
 
-extern volatile sig_atomic_t running;
+extern volatile sig_atomic_t sigterm;
+
+void send_dad(int fd, const struct in6_addr* tgt);
 
 void event_loop(struct tun_data *tunnel);
 
diff --git a/clatd/ipv4.c b/clatd/ipv4.c
index 2be02e3..81bf87b 100644
--- a/clatd/ipv4.c
+++ b/clatd/ipv4.c
@@ -85,6 +85,11 @@
     return 0;
   }
 
+  if ((header->daddr & 0xf0000000) == 0xe0000000) {
+    logmsg_dbg(ANDROID_LOG_INFO, "ip_packet/daddr is multicast: %x", header->daddr);
+    return 0;
+  }
+
   /* rfc6145 - If any IPv4 options are present in the IPv4 packet, they MUST be
    * ignored and the packet translated normally; there is no attempt to
    * translate the options.
diff --git a/clatd/main.c b/clatd/main.c
index 7aa1671..fca996b 100644
--- a/clatd/main.c
+++ b/clatd/main.c
@@ -34,10 +34,10 @@
 
 #define DEVICEPREFIX "v4-"
 
-/* function: stop_loop
+/* function: handle_sigterm
  * signal handler: stop the event loop
  */
-static void stop_loop(__attribute__((unused)) int unused) { running = 0; };
+static void handle_sigterm(__attribute__((unused)) int unused) { sigterm = 1; };
 
 /* function: print_help
  * in case the user is running this on the command line
@@ -150,7 +150,7 @@
     exit(1);
   }
 
-  logmsg(ANDROID_LOG_INFO, "Starting clat version %s on %s plat=%s v4=%s v6=%s", CLATD_VERSION,
+  logmsg(ANDROID_LOG_INFO, "Starting clat version " CLATD_VERSION " on %s plat=%s v4=%s v6=%s",
          uplink_interface, plat_prefix ? plat_prefix : "(none)", v4_addr ? v4_addr : "(none)",
          v6_addr ? v6_addr : "(none)");
 
@@ -183,25 +183,35 @@
   }
 
   // Loop until someone sends us a signal or brings down the tun interface.
-  if (signal(SIGTERM, stop_loop) == SIG_ERR) {
+  if (signal(SIGTERM, handle_sigterm) == SIG_ERR) {
     logmsg(ANDROID_LOG_FATAL, "sigterm handler failed: %s", strerror(errno));
     exit(1);
   }
 
+  // Apparently some network gear will refuse to perform NS for IPs that aren't DAD'ed,
+  // this would then result in an ipv6-only network with working native ipv6, working
+  // IPv4 via DNS64, but non-functioning IPv4 via CLAT (ie. IPv4 literals + IPv4 only apps).
+  // The kernel itself doesn't do DAD for anycast ips (but does handle IPV6 MLD and handle ND).
+  // So we'll spoof dad here, and yeah, we really should check for a response and in
+  // case of failure pick a different IP.  Seeing as 48-bits of the IP are utterly random
+  // (with the other 16 chosen to guarantee checksum neutrality) this seems like a remote
+  // concern...
+  // TODO: actually perform true DAD
+  send_dad(tunnel.write_fd6, &Global_Clatd_Config.ipv6_local_subnet);
+
   event_loop(&tunnel);
 
-  logmsg(ANDROID_LOG_INFO, "Shutting down clat on %s", uplink_interface);
-
-  if (running) {
-    logmsg(ANDROID_LOG_INFO, "Clatd on %s waiting for SIGTERM", uplink_interface);
+  if (sigterm) {
+    logmsg(ANDROID_LOG_INFO, "Shutting down clatd on %s, already received SIGTERM", uplink_interface);
+  } else {
+    // this implies running == false, ie. we received EOF or ENETDOWN error.
+    logmsg(ANDROID_LOG_INFO, "Shutting down clatd on %s, waiting for SIGTERM", uplink_interface);
     // let's give higher level java code 15 seconds to kill us,
     // but eventually terminate anyway, in case system server forgets about us...
-    // sleep() should be interrupted by SIGTERM, the handler should clear running
+    // sleep() should be interrupted by SIGTERM, the handler should set 'sigterm'
     sleep(15);
     logmsg(ANDROID_LOG_INFO, "Clatd on %s %s SIGTERM", uplink_interface,
-           running ? "timed out waiting for" : "received");
-  } else {
-    logmsg(ANDROID_LOG_INFO, "Clatd on %s already received SIGTERM", uplink_interface);
+           sigterm ? "received" : "timed out waiting for");
   }
   return 0;
 }
diff --git a/framework/Android.bp b/framework/Android.bp
index f66bc60..ab3af9a 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -295,7 +295,6 @@
         ":framework-connectivity-t-pre-jarjar{.jar}",
         ":framework-connectivity.stubs.module_lib{.jar}",
         ":framework-connectivity-t.stubs.module_lib{.jar}",
-        ":framework-connectivity-module-api-stubs-including-flagged{.jar}",
         "jarjar-excludes.txt",
     ],
     tools: [
@@ -308,7 +307,6 @@
         "--prefix android.net.connectivity " +
         "--apistubs $(location :framework-connectivity.stubs.module_lib{.jar}) " +
         "--apistubs $(location :framework-connectivity-t.stubs.module_lib{.jar}) " +
-        "--apistubs $(location :framework-connectivity-module-api-stubs-including-flagged{.jar}) " +
         // Make a ":"-separated list. There will be an extra ":" but empty items are ignored.
         "--unsupportedapi $$(printf ':%s' $(locations :connectivity-hiddenapi-files)) " +
         "--excludes $(location jarjar-excludes.txt) " +
@@ -320,35 +318,6 @@
     ],
 }
 
-droidstubs {
-    name: "framework-connectivity-module-api-stubs-including-flagged-droidstubs",
-    srcs: [
-        ":framework-connectivity-sources",
-        ":framework-connectivity-tiramisu-updatable-sources",
-        ":framework-networksecurity-sources",
-        ":framework-nearby-java-sources",
-        ":framework-thread-sources",
-    ],
-    flags: [
-        "--show-for-stub-purposes-annotation android.annotation.SystemApi" +
-            "\\(client=android.annotation.SystemApi.Client.PRIVILEGED_APPS\\)",
-        "--show-for-stub-purposes-annotation android.annotation.SystemApi" +
-            "\\(client=android.annotation.SystemApi.Client.MODULE_LIBRARIES\\)",
-    ],
-    aidl: {
-        include_dirs: [
-            "packages/modules/Connectivity/framework/aidl-export",
-            "packages/modules/Connectivity/Tethering/common/TetheringLib/src",
-            "frameworks/native/aidl/binder", // For PersistableBundle.aidl
-        ],
-    },
-}
-
-java_library {
-    name: "framework-connectivity-module-api-stubs-including-flagged",
-    srcs: [":framework-connectivity-module-api-stubs-including-flagged-droidstubs"],
-}
-
 // Library providing limited APIs within the connectivity module, so that R+ components like
 // Tethering have a controlled way to depend on newer components like framework-connectivity that
 // are not loaded on R.
diff --git a/framework/jni/android_net_NetworkUtils.cpp b/framework/jni/android_net_NetworkUtils.cpp
index 3779a00..7404f32 100644
--- a/framework/jni/android_net_NetworkUtils.cpp
+++ b/framework/jni/android_net_NetworkUtils.cpp
@@ -23,9 +23,9 @@
 #include <netinet/in.h>
 #include <string.h>
 
+#include <DnsProxydProtocol.h> // NETID_USE_LOCAL_NAMESERVERS
 #include <bpf/BpfClassic.h>
 #include <bpf/KernelUtils.h>
-#include <DnsProxydProtocol.h> // NETID_USE_LOCAL_NAMESERVERS
 #include <nativehelper/JNIPlatformHelp.h>
 #include <nativehelper/ScopedPrimitiveArray.h>
 #include <utils/Log.h>
@@ -259,6 +259,21 @@
     return bpf::isX86();
 }
 
+static jlong android_net_utils_getSocketCookie(JNIEnv *env, jclass clazz,
+                                               jobject javaFd) {
+    int sock = AFileDescriptor_getFd(env, javaFd);
+    uint64_t cookie = 0;
+    socklen_t cookie_len = sizeof(cookie);
+    if (getsockopt(sock, SOL_SOCKET, SO_COOKIE, &cookie, &cookie_len)) {
+        // Failure is almost certainly either EBADF or ENOTSOCK
+        jniThrowErrnoException(env, "getSocketCookie", errno);
+    } else if (cookie_len != sizeof(cookie)) {
+        // This probably cannot actually happen, but...
+        jniThrowErrnoException(env, "getSocketCookie", 523); // EBADCOOKIE
+    }
+    return static_cast<jlong>(cookie);
+}
+
 // ----------------------------------------------------------------------------
 
 /*
@@ -283,6 +298,7 @@
     (void*) android_net_utils_setsockoptBytes},
     { "isKernel64Bit", "()Z", (void*) android_net_utils_isKernel64Bit },
     { "isKernelX86", "()Z", (void*) android_net_utils_isKernelX86 },
+    { "getSocketCookie", "(Ljava/io/FileDescriptor;)J", (void*) android_net_utils_getSocketCookie },
 };
 // clang-format on
 
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 5d99b74..3d7ea69 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -1242,6 +1242,22 @@
     @ConnectivityManagerFeature
     private Long mEnabledConnectivityManagerFeatures = null;
 
+    /**
+     * A class to help with mocking ConnectivityManager.
+     * @hide
+     */
+    public static class MockHelpers {
+        /**
+         * Produce an instance of the class returned by
+         * {@link ConnectivityManager#registerNetworkAgent}
+         * @hide
+         */
+        public static Network registerNetworkAgentResult(
+                @Nullable final Network network, @Nullable final INetworkAgentRegistry registry) {
+            return network;
+        }
+    }
+
     private TetheringManager getTetheringManager() {
         synchronized (mTetheringEventCallbacks) {
             if (mTetheringManager == null) {
diff --git a/framework/src/android/net/NetworkAgentConfig.java b/framework/src/android/net/NetworkAgentConfig.java
index deaa734..da12a0a 100644
--- a/framework/src/android/net/NetworkAgentConfig.java
+++ b/framework/src/android/net/NetworkAgentConfig.java
@@ -272,27 +272,6 @@
         return mVpnRequiresValidation;
     }
 
-    /**
-     * Whether the native network creation should be skipped.
-     *
-     * If set, the native network and routes should be maintained by the caller.
-     *
-     * @hide
-     */
-    private boolean mSkipNativeNetworkCreation = false;
-
-
-    /**
-     * @return Whether the native network creation should be skipped.
-     * @hide
-     */
-    // TODO: Expose API when ready.
-    // @FlaggedApi(Flags.FLAG_TETHERING_NETWORK_AGENT)
-    // @SystemApi(client = MODULE_LIBRARIES) when ready.
-    public boolean shouldSkipNativeNetworkCreation() {
-        return mSkipNativeNetworkCreation;
-    }
-
     /** @hide */
     public NetworkAgentConfig() {
     }
@@ -314,7 +293,6 @@
             mLegacyExtraInfo = nac.mLegacyExtraInfo;
             excludeLocalRouteVpn = nac.excludeLocalRouteVpn;
             mVpnRequiresValidation = nac.mVpnRequiresValidation;
-            mSkipNativeNetworkCreation = nac.mSkipNativeNetworkCreation;
         }
     }
 
@@ -506,26 +484,6 @@
         }
 
         /**
-         * Sets the native network creation should be skipped.
-         *
-         * @return this builder, to facilitate chaining.
-         * @hide
-         */
-        @NonNull
-        // TODO: Expose API when ready.
-        // @FlaggedApi(Flags.FLAG_TETHERING_NETWORK_AGENT)
-        // @SystemApi(client = MODULE_LIBRARIES) when ready.
-        public Builder setSkipNativeNetworkCreation(boolean skipNativeNetworkCreation) {
-            if (!SdkLevel.isAtLeastV()) {
-                // Local agents are supported starting on U on TVs and on V on everything else.
-                // Thus, only support this flag on V+.
-                throw new UnsupportedOperationException("Method is not supported");
-            }
-            mConfig.mSkipNativeNetworkCreation = skipNativeNetworkCreation;
-            return this;
-        }
-
-        /**
          * Returns the constructed {@link NetworkAgentConfig} object.
          */
         @NonNull
@@ -552,8 +510,7 @@
                 && Objects.equals(legacySubTypeName, that.legacySubTypeName)
                 && Objects.equals(mLegacyExtraInfo, that.mLegacyExtraInfo)
                 && excludeLocalRouteVpn == that.excludeLocalRouteVpn
-                && mVpnRequiresValidation == that.mVpnRequiresValidation
-                && mSkipNativeNetworkCreation == that.mSkipNativeNetworkCreation;
+                && mVpnRequiresValidation == that.mVpnRequiresValidation;
     }
 
     @Override
@@ -561,8 +518,7 @@
         return Objects.hash(allowBypass, explicitlySelected, acceptUnvalidated,
                 acceptPartialConnectivity, provisioningNotificationDisabled, subscriberId,
                 skip464xlat, legacyType, legacySubType, legacyTypeName, legacySubTypeName,
-                mLegacyExtraInfo, excludeLocalRouteVpn, mVpnRequiresValidation,
-                mSkipNativeNetworkCreation);
+                mLegacyExtraInfo, excludeLocalRouteVpn, mVpnRequiresValidation);
     }
 
     @Override
@@ -583,7 +539,6 @@
                 + ", legacyExtraInfo = '" + mLegacyExtraInfo + '\''
                 + ", excludeLocalRouteVpn = '" + excludeLocalRouteVpn + '\''
                 + ", vpnRequiresValidation = '" + mVpnRequiresValidation + '\''
-                + ", skipNativeNetworkCreation = '" + mSkipNativeNetworkCreation + '\''
                 + "}";
     }
 
@@ -608,35 +563,33 @@
         out.writeString(mLegacyExtraInfo);
         out.writeInt(excludeLocalRouteVpn ? 1 : 0);
         out.writeInt(mVpnRequiresValidation ? 1 : 0);
-        out.writeInt(mSkipNativeNetworkCreation ? 1 : 0);
     }
 
     public static final @NonNull Creator<NetworkAgentConfig> CREATOR =
             new Creator<NetworkAgentConfig>() {
-                @Override
-                public NetworkAgentConfig createFromParcel(Parcel in) {
-                    NetworkAgentConfig networkAgentConfig = new NetworkAgentConfig();
-                    networkAgentConfig.allowBypass = in.readInt() != 0;
-                    networkAgentConfig.explicitlySelected = in.readInt() != 0;
-                    networkAgentConfig.acceptUnvalidated = in.readInt() != 0;
-                    networkAgentConfig.acceptPartialConnectivity = in.readInt() != 0;
-                    networkAgentConfig.subscriberId = in.readString();
-                    networkAgentConfig.provisioningNotificationDisabled = in.readInt() != 0;
-                    networkAgentConfig.skip464xlat = in.readInt() != 0;
-                    networkAgentConfig.legacyType = in.readInt();
-                    networkAgentConfig.legacyTypeName = in.readString();
-                    networkAgentConfig.legacySubType = in.readInt();
-                    networkAgentConfig.legacySubTypeName = in.readString();
-                    networkAgentConfig.mLegacyExtraInfo = in.readString();
-                    networkAgentConfig.excludeLocalRouteVpn = in.readInt() != 0;
-                    networkAgentConfig.mVpnRequiresValidation = in.readInt() != 0;
-                    networkAgentConfig.mSkipNativeNetworkCreation = in.readInt() != 0;
-                    return networkAgentConfig;
-                }
+        @Override
+        public NetworkAgentConfig createFromParcel(Parcel in) {
+            NetworkAgentConfig networkAgentConfig = new NetworkAgentConfig();
+            networkAgentConfig.allowBypass = in.readInt() != 0;
+            networkAgentConfig.explicitlySelected = in.readInt() != 0;
+            networkAgentConfig.acceptUnvalidated = in.readInt() != 0;
+            networkAgentConfig.acceptPartialConnectivity = in.readInt() != 0;
+            networkAgentConfig.subscriberId = in.readString();
+            networkAgentConfig.provisioningNotificationDisabled = in.readInt() != 0;
+            networkAgentConfig.skip464xlat = in.readInt() != 0;
+            networkAgentConfig.legacyType = in.readInt();
+            networkAgentConfig.legacyTypeName = in.readString();
+            networkAgentConfig.legacySubType = in.readInt();
+            networkAgentConfig.legacySubTypeName = in.readString();
+            networkAgentConfig.mLegacyExtraInfo = in.readString();
+            networkAgentConfig.excludeLocalRouteVpn = in.readInt() != 0;
+            networkAgentConfig.mVpnRequiresValidation = in.readInt() != 0;
+            return networkAgentConfig;
+        }
 
-                @Override
-                public NetworkAgentConfig[] newArray(int size) {
-                    return new NetworkAgentConfig[size];
-                }
-            };
+        @Override
+        public NetworkAgentConfig[] newArray(int size) {
+            return new NetworkAgentConfig[size];
+        }
+    };
 }
diff --git a/framework/src/android/net/NetworkUtils.java b/framework/src/android/net/NetworkUtils.java
index 18feb84..6b2eb08 100644
--- a/framework/src/android/net/NetworkUtils.java
+++ b/framework/src/android/net/NetworkUtils.java
@@ -443,4 +443,13 @@
 
     /** Returns whether the Linux Kernel is x86 */
     public static native boolean isKernelX86();
+
+    /**
+     * Returns socket cookie.
+     *
+     * @param fd The socket file descriptor
+     * @return The socket cookie.
+     * @throws ErrnoException if retrieving the socket cookie fails.
+     */
+    public static native long getSocketCookie(FileDescriptor fd) throws ErrnoException;
 }
diff --git a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
index 317854b..2261c69 100644
--- a/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
+++ b/framework/src/android/net/connectivity/ConnectivityCompatChanges.java
@@ -100,9 +100,9 @@
     public static final long ENABLE_MATCH_LOCAL_NETWORK = 319212206L;
 
     /**
-     * On Android {@link android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM} or higher releases,
-     * network access from apps targeting Android 36 or higher that do not have the
-     * {@link android.Manifest.permission#INTERNET} permission is considered blocked.
+     * On Android versions starting from 37, network access from apps targeting
+     * Android 37 or higher, that do not have the {@link android.Manifest.permission#INTERNET}
+     * permission, is considered blocked.
      * This results in API behaviors change for apps without
      * {@link android.Manifest.permission#INTERNET} permission.
      * {@link android.net.NetworkInfo} returned from {@link android.net.ConnectivityManager} APIs
@@ -115,10 +115,12 @@
      * network access from apps without {@link android.Manifest.permission#INTERNET} permission is
      * considered not blocked even though apps cannot access any networks.
      *
+     * TODO: b/400903101 - Update the target SDK version once it's finalized.
+     *
      * @hide
      */
     @ChangeId
-    @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    @EnabledAfter(targetSdkVersion = 36)
     public static final long NETWORK_BLOCKED_WITHOUT_INTERNET_PERMISSION = 333340911L;
 
     /**
diff --git a/networksecurity/service/Android.bp b/networksecurity/service/Android.bp
index d7aacdb..3c964e5 100644
--- a/networksecurity/service/Android.bp
+++ b/networksecurity/service/Android.bp
@@ -32,6 +32,7 @@
         "framework-connectivity-pre-jarjar",
         "service-connectivity-pre-jarjar",
         "framework-statsd.stubs.module_lib",
+        "ServiceConnectivityResources",
     ],
 
     static_libs: [
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 fb42c03..41b58fa 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -33,8 +33,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.Collection;
 import java.util.Optional;
 
 /** Helper class to download certificate transparency log files. */
@@ -48,28 +47,21 @@
     private final DownloadHelper mDownloadHelper;
     private final SignatureVerifier mSignatureVerifier;
     private final CertificateTransparencyLogger mLogger;
-
-    private final List<CompatibilityVersion> mCompatVersions = new ArrayList<>();
+    private final Collection<CompatibilityVersion> mCompatVersions;
 
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
             SignatureVerifier signatureVerifier,
-            CertificateTransparencyLogger logger) {
+            CertificateTransparencyLogger logger,
+            Collection<CompatibilityVersion> compatVersions) {
         mContext = context;
         mSignatureVerifier = signatureVerifier;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
         mLogger = logger;
-    }
-
-    void addCompatibilityVersion(CompatibilityVersion compatVersion) {
-        mCompatVersions.add(compatVersion);
-    }
-
-    void clearCompatibilityVersions() {
-        mCompatVersions.clear();
+        mCompatVersions = compatVersions;
     }
 
     long startPublicKeyDownload() {
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
index e6f1379..286f326 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyJob.java
@@ -28,6 +28,8 @@
 import android.os.SystemClock;
 import android.util.Log;
 
+import java.util.Collection;
+
 /** Implementation of the Certificate Transparency job */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyJob extends BroadcastReceiver {
@@ -37,7 +39,8 @@
     private final Context mContext;
     private final DataStore mDataStore;
     private final CertificateTransparencyDownloader mCertificateTransparencyDownloader;
-    private final CompatibilityVersion mCompatVersion;
+    private final SignatureVerifier mSignatureVerifier;
+    private final Collection<CompatibilityVersion> mCompatVersions;
     private final AlarmManager mAlarmManager;
     private final PendingIntent mPendingIntent;
 
@@ -49,11 +52,13 @@
             Context context,
             DataStore dataStore,
             CertificateTransparencyDownloader certificateTransparencyDownloader,
-            CompatibilityVersion compatVersion) {
+            SignatureVerifier signatureVerifier,
+            Collection<CompatibilityVersion> compatVersions) {
         mContext = context;
         mDataStore = dataStore;
         mCertificateTransparencyDownloader = certificateTransparencyDownloader;
-        mCompatVersion = compatVersion;
+        mSignatureVerifier = signatureVerifier;
+        mCompatVersions = compatVersions;
 
         mAlarmManager = context.getSystemService(AlarmManager.class);
         mPendingIntent =
@@ -96,7 +101,9 @@
         }
         mDependenciesReady = false;
 
-        mCompatVersion.delete();
+        for (CompatibilityVersion compatVersion : mCompatVersions) {
+            compatVersion.delete();
+        }
 
         if (Config.DEBUG) {
             Log.d(TAG, "CertificateTransparencyJob canceled.");
@@ -126,7 +133,7 @@
 
     private void startDependencies() {
         mDataStore.load();
-        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
+        mSignatureVerifier.loadAllowedKeys();
         mContext.registerReceiver(
                 mCertificateTransparencyDownloader,
                 new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
@@ -139,7 +146,7 @@
 
     private void stopDependencies() {
         mContext.unregisterReceiver(mCertificateTransparencyDownloader);
-        mCertificateTransparencyDownloader.clearCompatibilityVersions();
+        mSignatureVerifier.clearAllowedKeys();
         mDataStore.delete();
 
         if (Config.DEBUG) {
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 a71ff7c..5e530c7 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -30,6 +30,8 @@
 
 import com.android.server.SystemService;
 
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.concurrent.Executors;
 
 /** Implementation of the Certificate Transparency service. */
@@ -51,6 +53,17 @@
     /** Creates a new {@link CertificateTransparencyService} object. */
     public CertificateTransparencyService(Context context) {
         DataStore dataStore = new DataStore(Config.PREFERENCES_FILE);
+        SignatureVerifier signatureVerifier = new SignatureVerifier(context);
+        Collection<CompatibilityVersion> compatVersions =
+                Arrays.asList(
+                        new CompatibilityVersion(
+                                Config.COMPATIBILITY_VERSION_V1,
+                                Config.URL_SIGNATURE_V1,
+                                Config.URL_LOG_LIST_V1),
+                        new CompatibilityVersion(
+                                Config.COMPATIBILITY_VERSION_V2,
+                                Config.URL_SIGNATURE_V2,
+                                Config.URL_LOG_LIST_V2));
 
         mCertificateTransparencyJob =
                 new CertificateTransparencyJob(
@@ -60,13 +73,11 @@
                                 context,
                                 dataStore,
                                 new DownloadHelper(context),
-                                new SignatureVerifier(context),
-                                new CertificateTransparencyLoggerImpl(dataStore)),
-                        new CompatibilityVersion(
-                                Config.COMPATIBILITY_VERSION,
-                                Config.URL_SIGNATURE,
-                                Config.URL_LOG_LIST,
-                                Config.CT_ROOT_DIRECTORY_PATH));
+                                signatureVerifier,
+                                new CertificateTransparencyLoggerImpl(dataStore),
+                                compatVersions),
+                        signatureVerifier,
+                        compatVersions);
     }
 
     /**
diff --git a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
index e8a6e64..0a91963 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CompatibilityVersion.java
@@ -23,6 +23,8 @@
 import android.system.Os;
 import android.util.Log;
 
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.net.ct.CertificateTransparencyLogger.CTLogListUpdateState;
 
 import org.json.JSONException;
@@ -40,6 +42,8 @@
 
     private static final String TAG = "CompatibilityVersion";
 
+    private static File sRootDirectory = new File(Config.CT_ROOT_DIRECTORY_PATH);
+
     static final String LOGS_DIR_PREFIX = "logs-";
     static final String LOGS_LIST_FILE_NAME = "log_list.json";
     static final String CURRENT_LOGS_DIR_SYMLINK_NAME = "current";
@@ -48,23 +52,21 @@
 
     private final String mMetadataUrl;
     private final String mContentUrl;
-    private final File mRootDirectory;
     private final File mVersionDirectory;
     private final File mCurrentLogsDirSymlink;
 
     CompatibilityVersion(
-            String compatVersion, String metadataUrl, String contentUrl, File rootDirectory) {
+            String compatVersion, String metadataUrl, String contentUrl) {
         mCompatVersion = compatVersion;
         mMetadataUrl = metadataUrl;
         mContentUrl = contentUrl;
-        mRootDirectory = rootDirectory;
-        mVersionDirectory = new File(rootDirectory, compatVersion);
+        mVersionDirectory = new File(sRootDirectory, compatVersion);
         mCurrentLogsDirSymlink = new File(mVersionDirectory, CURRENT_LOGS_DIR_SYMLINK_NAME);
     }
 
-    CompatibilityVersion(
-            String compatVersion, String metadataUrl, String contentUrl, String rootDirectoryPath) {
-        this(compatVersion, metadataUrl, contentUrl, new File(rootDirectoryPath));
+    @VisibleForTesting
+    static void setRootDirectoryForTesting(File rootDirectory) {
+        sRootDirectory = rootDirectory;
     }
 
     /**
@@ -75,8 +77,8 @@
      * @return true if the log list was installed successfully, false otherwise.
      * @throws IOException if the list cannot be saved in the CT directory.
      */
-    LogListUpdateStatus install(
-            InputStream newContent, LogListUpdateStatus.Builder statusBuilder) throws IOException {
+    LogListUpdateStatus install(InputStream newContent, LogListUpdateStatus.Builder statusBuilder)
+            throws IOException {
         String content = new String(newContent.readAllBytes(), UTF_8);
         try {
             JSONObject contentJson = new JSONObject(content);
@@ -98,7 +100,7 @@
         // there's a bunch of steps. We create a new directory with the logs and then do
         // an atomic update of the current symlink to point to the new directory.
         // 1. Ensure the path to the root and version directories exist and are readable.
-        DirectoryUtils.makeDir(mRootDirectory);
+        DirectoryUtils.makeDir(sRootDirectory);
         DirectoryUtils.makeDir(mVersionDirectory);
 
         File newLogsDir = new File(mVersionDirectory, LOGS_DIR_PREFIX + version);
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index 5fdba09..72b715a 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,18 +33,14 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
-    // CT directory
+    // CT paths
     static final String CT_ROOT_DIRECTORY_PATH = "/data/misc/keychain/ct/";
-    static final String COMPATIBILITY_VERSION = "v1";
+    static final String URL_PREFIX = "https://www.gstatic.com/android/certificate_transparency/";
 
     // Phenotype flags
     static final String NAMESPACE_NETWORK_SECURITY = "network_security";
     private static final String FLAGS_PREFIX = "CertificateTransparencyLogList__";
     static final String FLAG_SERVICE_ENABLED = FLAGS_PREFIX + "service_enabled";
-    static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
-    static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
-    static final String FLAG_VERSION = FLAGS_PREFIX + "version";
-    static final String FLAG_PUBLIC_KEY = FLAGS_PREFIX + "public_key";
 
     // properties
     static final String VERSION = "version";
@@ -53,9 +49,18 @@
     static final String PUBLIC_KEY_DOWNLOAD_ID = "public_key_download_id";
     static final String LOG_LIST_UPDATE_FAILURE_COUNT = "log_list_update_failure_count";
 
-    // URLs
-    static final String URL_PREFIX = "https://www.gstatic.com/android/certificate_transparency/";
-    static final String URL_LOG_LIST = URL_PREFIX + "log_list.json";
-    static final String URL_SIGNATURE = URL_PREFIX + "log_list.sig";
+    // Public Key URLs
     static final String URL_PUBLIC_KEY = URL_PREFIX + "log_list.pub";
+
+    // Compatibility Version v1
+    static final String COMPATIBILITY_VERSION_V1 = "v1";
+    static final String URL_PREFIX_V1 = URL_PREFIX;
+    static final String URL_LOG_LIST_V1 = URL_PREFIX_V1 + "log_list.json";
+    static final String URL_SIGNATURE_V1 = URL_PREFIX_V1 + "log_list.sig";
+
+    // Compatibility Version v2
+    static final String COMPATIBILITY_VERSION_V2 = "v2";
+    static final String URL_PREFIX_V2 = URL_PREFIX + COMPATIBILITY_VERSION_V2 + "/";
+    static final String URL_LOG_LIST_V2 = URL_PREFIX_V2 + "log_list.json";
+    static final String URL_SIGNATURE_V2 = URL_PREFIX_V2 + "log_list.sig";
 }
diff --git a/networksecurity/service/src/com/android/server/net/ct/PemReader.java b/networksecurity/service/src/com/android/server/net/ct/PemReader.java
new file mode 100644
index 0000000..56b3973
--- /dev/null
+++ b/networksecurity/service/src/com/android/server/net/ct/PemReader.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.KeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+
+/** Utility class to read keys in PEM format. */
+class PemReader {
+
+    private static final String BEGIN = "-----BEGIN";
+    private static final String END = "-----END";
+
+    /**
+     * Parse the provided input stream and return the list of keys from the stream.
+     *
+     * @param input the input stream
+     * @return the keys
+     */
+    public static Collection<PublicKey> readKeysFrom(InputStream input)
+            throws IOException, GeneralSecurityException {
+        KeyFactory instance = KeyFactory.getInstance("RSA");
+        Collection<PublicKey> keys = new ArrayList<>();
+
+        try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) {
+            String line = reader.readLine();
+            while (line != null) {
+                if (line.startsWith(BEGIN)) {
+                    keys.add(instance.generatePublic(readNextKey(reader)));
+                } else {
+                    throw new IOException("Unexpected line in the reader: " + line);
+                }
+                line = reader.readLine();
+            }
+        } catch (IllegalArgumentException e) {
+            throw new GeneralSecurityException("Invalid public key base64 encoding", e);
+        }
+
+        return keys;
+    }
+
+    private static KeySpec readNextKey(BufferedReader reader) throws IOException {
+        StringBuilder publicKeyBuilder = new StringBuilder();
+
+        String line = reader.readLine();
+        while (line != null) {
+            if (line.startsWith(END)) {
+                return new X509EncodedKeySpec(
+                        Base64.getDecoder().decode(publicKeyBuilder.toString()));
+            } else {
+                publicKeyBuilder.append(line);
+            }
+            line = reader.readLine();
+        }
+
+        throw new IOException("Unexpected end of the reader");
+    }
+}
diff --git a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
index 6040ef6..87a4973 100644
--- a/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
+++ b/networksecurity/service/src/com/android/server/net/ct/SignatureVerifier.java
@@ -30,6 +30,9 @@
 
 import androidx.annotation.VisibleForTesting;
 
+import com.android.connectivity.resources.R;
+import com.android.server.connectivity.ConnectivityResources;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
@@ -39,21 +42,39 @@
 import java.security.Signature;
 import java.security.spec.X509EncodedKeySpec;
 import java.util.Base64;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Set;
 
 /** Verifier of the log list signature. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class SignatureVerifier {
 
-    private final Context mContext;
     private static final String TAG = "SignatureVerifier";
 
+    private final Context mContext;
+
     @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
 
+    private final Set<PublicKey> mAllowedKeys = new HashSet<>();
+
     public SignatureVerifier(Context context) {
         mContext = context;
     }
 
+    void loadAllowedKeys() {
+        try (InputStream input =
+                new ConnectivityResources(mContext).get().openRawResource(R.raw.ct_public_keys)) {
+            mAllowedKeys.addAll(PemReader.readKeysFrom(input));
+        } catch (GeneralSecurityException | IOException e) {
+            Log.e(TAG, "Error loading public keys", e);
+        }
+    }
+
+    void clearAllowedKeys() {
+        mAllowedKeys.clear();
+    }
+
     @VisibleForTesting
     Optional<PublicKey> getPublicKey() {
         return mPublicKey;
@@ -82,7 +103,11 @@
     }
 
     @VisibleForTesting
-    void setPublicKey(PublicKey publicKey) {
+    void setPublicKey(PublicKey publicKey) throws GeneralSecurityException {
+        if (!mAllowedKeys.contains(publicKey)) {
+            // TODO(b/400704086): add logging for this failure.
+            throw new GeneralSecurityException("Public key not in allowlist");
+        }
         mPublicKey = Optional.of(publicKey);
     }
 
@@ -105,21 +130,18 @@
 
             byte[] signatureBytes = signatureStream.readAllBytes();
             statusBuilder.setSignature(new String(signatureBytes));
-            try {
-                byte[] decodedSigBytes = Base64.getDecoder().decode(signatureBytes);
 
-                if (!verifier.verify(decodedSigBytes)) {
-                    // Leave the UpdateState as UNKNOWN_STATE if successful as there are other
-                    // potential failures past the signature verification step
-                    statusBuilder.setState(SIGNATURE_VERIFICATION_FAILED);
-                }
-            } catch (IllegalArgumentException e) {
-                Log.w(TAG, "Invalid signature base64 encoding", e);
-                statusBuilder.setState(SIGNATURE_INVALID);
-                return statusBuilder.build();
+            if (!verifier.verify(Base64.getDecoder().decode(signatureBytes))) {
+                // Leave the UpdateState as UNKNOWN_STATE if successful as there are other
+                // potential failures past the signature verification step
+                statusBuilder.setState(SIGNATURE_VERIFICATION_FAILED);
             }
+        } catch (IllegalArgumentException e) {
+            Log.w(TAG, "Invalid signature base64 encoding", e);
+            statusBuilder.setState(SIGNATURE_INVALID);
+            return statusBuilder.build();
         } catch (InvalidKeyException e) {
-            Log.e(TAG, "Signature invalid for log list verification", e);
+            Log.e(TAG, "Key invalid for log list verification", e);
             statusBuilder.setState(SIGNATURE_INVALID);
             return statusBuilder.build();
         } catch (IOException | GeneralSecurityException e) {
@@ -135,4 +157,9 @@
 
         return statusBuilder.build();
     }
+
+    @VisibleForTesting
+    boolean addAllowedKey(PublicKey publicKey) {
+        return mAllowedKeys.add(publicKey);
+    }
 }
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 2af0122..956bad5 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -60,6 +60,7 @@
 import java.security.PrivateKey;
 import java.security.PublicKey;
 import java.security.Signature;
+import java.util.Arrays;
 import java.util.Base64;
 import java.util.Optional;
 
@@ -94,23 +95,25 @@
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mDataStore = new DataStore(File.createTempFile("datastore-test", ".properties"));
         mSignatureVerifier = new SignatureVerifier(mContext);
+
+        CompatibilityVersion.setRootDirectoryForTesting(mContext.getFilesDir());
+        mCompatVersion =
+                new CompatibilityVersion(
+                        /* compatVersion= */ "v666",
+                        Config.URL_SIGNATURE_V1,
+                        Config.URL_LOG_LIST_V1);
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
                         mContext,
                         mDataStore,
                         new DownloadHelper(mDownloadManager),
                         mSignatureVerifier,
-                        mLogger);
-        mCompatVersion =
-                new CompatibilityVersion(
-                        /* compatVersion= */ "v666",
-                        Config.URL_SIGNATURE,
-                        Config.URL_LOG_LIST,
-                        mContext.getFilesDir());
+                        mLogger,
+                        Arrays.asList(mCompatVersion));
 
         prepareDownloadManager();
+        mSignatureVerifier.addAllowedKey(mPublicKey);
         mDataStore.load();
-        mCertificateTransparencyDownloader.addCompatibilityVersion(mCompatVersion);
     }
 
     @After
@@ -165,6 +168,22 @@
 
     @Test
     public void
+            testDownloader_publicKeyDownloadSuccess_publicKeyNotAllowed_doNotStartMetadataDownload()
+                    throws Exception {
+        mCertificateTransparencyDownloader.startPublicKeyDownload();
+        PublicKey notAllowed = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makePublicKeyDownloadCompleteIntent(writePublicKeyToFile(notAllowed)));
+
+        assertThat(mSignatureVerifier.getPublicKey()).isEmpty();
+        assertThat(mCertificateTransparencyDownloader.hasMetadataDownloadId()).isFalse();
+    }
+
+    @Test
+    public void
             testDownloader_publicKeyDownloadSuccess_updatePublicKeyFail_doNotStartMetadataDownload()
                     throws Exception {
         mCertificateTransparencyDownloader.startPublicKeyDownload();
@@ -197,8 +216,7 @@
     }
 
     @Test
-    public void testDownloader_publicKeyDownloadFail_logsFailure()
-            throws Exception {
+    public void testDownloader_publicKeyDownloadFail_logsFailure() throws Exception {
         mCertificateTransparencyDownloader.startPublicKeyDownload();
 
         mCertificateTransparencyDownloader.onReceive(
@@ -243,8 +261,7 @@
     }
 
     @Test
-    public void testDownloader_metadataDownloadFail_logsFailure()
-            throws Exception {
+    public void testDownloader_metadataDownloadFail_logsFailure() throws Exception {
         mCertificateTransparencyDownloader.startMetadataDownload();
 
         mCertificateTransparencyDownloader.onReceive(
@@ -294,8 +311,7 @@
     }
 
     @Test
-    public void testDownloader_contentDownloadFail_logsFailure()
-            throws Exception {
+    public void testDownloader_contentDownloadFail_logsFailure() throws Exception {
         mCertificateTransparencyDownloader.startContentDownload(mCompatVersion);
 
         mCertificateTransparencyDownloader.onReceive(
@@ -329,9 +345,8 @@
     }
 
     @Test
-    public void
-            testDownloader_contentDownloadSuccess_noPublicKeyFound_logsSingleFailure()
-                    throws Exception {
+    public void testDownloader_contentDownloadSuccess_noPublicKeyFound_logsSingleFailure()
+            throws Exception {
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
@@ -351,16 +366,17 @@
     }
 
     @Test
-    public void
-            testDownloader_contentDownloadSuccess_wrongSignatureAlgo_logsSingleFailure()
-                    throws Exception {
+    public void testDownloader_contentDownloadSuccess_wrongSignatureAlgo_logsSingleFailure()
+            throws Exception {
         // Arrange
         File logListFile = makeLogListFile("456");
         File metadataFile = sign(logListFile);
 
         // Set the key to be deliberately wrong by using diff algorithm
-        KeyPairGenerator instance = KeyPairGenerator.getInstance("EC");
-        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
+        PublicKey wrongAlgorithmKey =
+                KeyPairGenerator.getInstance("EC").generateKeyPair().getPublic();
+        mSignatureVerifier.addAllowedKey(wrongAlgorithmKey);
+        mSignatureVerifier.setPublicKey(wrongAlgorithmKey);
 
         // Act
         mCertificateTransparencyDownloader.startMetadataDownload();
@@ -377,16 +393,15 @@
     }
 
     @Test
-    public void
-            testDownloader_contentDownloadSuccess_signatureNotVerified_logsSingleFailure()
-                    throws Exception {
+    public void testDownloader_contentDownloadSuccess_signatureNotVerified_logsSingleFailure()
+            throws Exception {
         // Arrange
         File logListFile = makeLogListFile("456");
-        File metadataFile = sign(logListFile);
+        mSignatureVerifier.setPublicKey(mPublicKey);
 
-        // Set the key to be deliberately wrong by using diff key pair
+        // Sign the list with a disallowed key pair
         KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
-        mSignatureVerifier.setPublicKey(instance.generateKeyPair().getPublic());
+        File metadataFile = sign(logListFile, instance.generateKeyPair().getPrivate());
 
         // Act
         mCertificateTransparencyDownloader.startMetadataDownload();
@@ -405,9 +420,7 @@
     }
 
     @Test
-    public void
-            testDownloader_contentDownloadSuccess_installFail_logsFailure()
-                    throws Exception {
+    public void testDownloader_contentDownloadSuccess_installFail_logsFailure() throws Exception {
         File invalidLogListFile = writeToFile("not_a_json_log_list".getBytes());
         File metadataFile = sign(invalidLogListFile);
         mSignatureVerifier.setPublicKey(mPublicKey);
@@ -615,9 +628,14 @@
     }
 
     private File sign(File file) throws IOException, GeneralSecurityException {
+        return sign(file, mPrivateKey);
+    }
+
+    private File sign(File file, PrivateKey privateKey)
+            throws IOException, GeneralSecurityException {
         File signatureFile = File.createTempFile("log_list-metadata", "sig");
         Signature signer = Signature.getInstance("SHA256withRSA");
-        signer.initSign(mPrivateKey);
+        signer.initSign(privateKey);
 
         try (InputStream fileStream = new FileInputStream(file);
                 OutputStream outputStream = new FileOutputStream(signatureFile)) {
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
index 2b8b3cd..0d15183 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CompatibilityVersionTest.java
@@ -27,6 +27,7 @@
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -47,9 +48,16 @@
 
     private final File mTestDir =
             InstrumentationRegistry.getInstrumentation().getContext().getFilesDir();
-    private final CompatibilityVersion mCompatVersion =
-            new CompatibilityVersion(
-                    TEST_VERSION, Config.URL_SIGNATURE, Config.URL_LOG_LIST, mTestDir);
+
+    private CompatibilityVersion mCompatVersion;
+
+    @Before
+    public void setUp() {
+        CompatibilityVersion.setRootDirectoryForTesting(mTestDir);
+        mCompatVersion =
+                new CompatibilityVersion(
+                        TEST_VERSION, Config.URL_SIGNATURE_V1, Config.URL_LOG_LIST_V1);
+    }
 
     @After
     public void tearDown() {
@@ -111,9 +119,7 @@
         JSONObject logList = makeLogList(version, "i_am_content");
 
         try (InputStream inputStream = asStream(logList)) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(getSuccessfulUpdateStatus());
         }
 
@@ -142,9 +148,7 @@
     @Test
     public void testCompatibilityVersion_deleteSuccessfully() throws Exception {
         try (InputStream inputStream = asStream(makeLogList(/* version= */ "123"))) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(getSuccessfulUpdateStatus());
         }
 
@@ -156,9 +160,7 @@
     @Test
     public void testCompatibilityVersion_invalidLogList() throws Exception {
         try (InputStream inputStream = new ByteArrayInputStream(("not_a_valid_list".getBytes()))) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(LogListUpdateStatus.builder().setState(LOG_LIST_INVALID).build());
         }
 
@@ -179,9 +181,7 @@
 
         JSONObject newLogList = makeLogList(existingVersion, "i_am_the_real_content");
         try (InputStream inputStream = asStream(newLogList)) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(getSuccessfulUpdateStatus());
         }
 
@@ -193,16 +193,12 @@
         String existingVersion = "666";
         JSONObject existingLogList = makeLogList(existingVersion, "i_was_installed_successfully");
         try (InputStream inputStream = asStream(existingLogList)) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(getSuccessfulUpdateStatus());
         }
 
         try (InputStream inputStream = asStream(makeLogList(existingVersion, "i_am_ignored"))) {
-            assertThat(
-                            mCompatVersion.install(
-                                    inputStream, LogListUpdateStatus.builder()))
+            assertThat(mCompatVersion.install(inputStream, LogListUpdateStatus.builder()))
                     .isEqualTo(
                             LogListUpdateStatus.builder()
                                     .setState(VERSION_ALREADY_EXISTS)
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java
new file mode 100644
index 0000000..08629db
--- /dev/null
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/PemReaderTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.server.net.ct;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.util.Base64;
+
+/** Tests for the {@link PemReader}. */
+@RunWith(JUnit4.class)
+public class PemReaderTest {
+
+    @Test
+    public void testReadKeys_singleKey() throws GeneralSecurityException, IOException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+
+        assertThat(PemReader.readKeysFrom(toInputStream(key))).containsExactly(key);
+    }
+
+    @Test
+    public void testReadKeys_multipleKeys() throws GeneralSecurityException, IOException {
+        KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
+        PublicKey key1 = instance.generateKeyPair().getPublic();
+        PublicKey key2 = instance.generateKeyPair().getPublic();
+
+        assertThat(PemReader.readKeysFrom(toInputStream(key1, key2))).containsExactly(key1, key2);
+    }
+
+    @Test
+    public void testReadKeys_notSupportedKeyType() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("EC").generateKeyPair().getPublic();
+
+        assertThrows(
+                GeneralSecurityException.class, () -> PemReader.readKeysFrom(toInputStream(key)));
+    }
+
+    @Test
+    public void testReadKeys_notBase64EncodedKey() throws GeneralSecurityException {
+        InputStream inputStream =
+                new ByteArrayInputStream(
+                        (""
+                                        + "-----BEGIN PUBLIC KEY-----\n"
+                                        + KeyPairGenerator.getInstance("RSA")
+                                                .generateKeyPair()
+                                                .getPublic()
+                                                .toString()
+                                        + "\n-----END PUBLIC KEY-----\n")
+                                .getBytes());
+
+        assertThrows(GeneralSecurityException.class, () -> PemReader.readKeysFrom(inputStream));
+    }
+
+    @Test
+    public void testReadKeys_noPemBegin() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+        String base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
+        String pemNoBegin = base64Key + "\n-----END PUBLIC KEY-----\n";
+
+        assertThrows(
+                IOException.class,
+                () -> PemReader.readKeysFrom(new ByteArrayInputStream(pemNoBegin.getBytes())));
+    }
+
+    @Test
+    public void testReadKeys_noPemEnd() throws GeneralSecurityException {
+        PublicKey key = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic();
+        String base64Key = Base64.getEncoder().encodeToString(key.getEncoded());
+        String pemNoEnd = "-----BEGIN PUBLIC KEY-----\n" + base64Key;
+
+        assertThrows(
+                IOException.class,
+                () -> PemReader.readKeysFrom(new ByteArrayInputStream(pemNoEnd.getBytes())));
+    }
+
+    private InputStream toInputStream(PublicKey... keys) {
+        StringBuilder builder = new StringBuilder();
+
+        for (PublicKey key : keys) {
+            builder.append("-----BEGIN PUBLIC KEY-----\n")
+                    .append(Base64.getEncoder().encodeToString(key.getEncoded()))
+                    .append("\n-----END PUBLIC KEY-----\n");
+        }
+
+        return new ByteArrayInputStream(builder.toString().getBytes());
+    }
+}
diff --git a/service-t/Android.bp b/service-t/Android.bp
index d2e2a80..81378f5 100644
--- a/service-t/Android.bp
+++ b/service-t/Android.bp
@@ -29,6 +29,7 @@
     name: "service-connectivity-tiramisu-sources",
     srcs: [
         "src/**/*.java",
+        ":vcn-location-sources",
     ],
     visibility: ["//visibility:private"],
 }
@@ -108,13 +109,15 @@
         ":service-mdns-droidstubs",
     ],
     exclude_srcs: [
+        "src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java",
         "src/com/android/server/connectivity/mdns/internal/SocketNetlinkMonitor.java",
-        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
         "src/com/android/server/connectivity/mdns/MdnsAdvertiser.java",
         "src/com/android/server/connectivity/mdns/MdnsAnnouncer.java",
         "src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java",
         "src/com/android/server/connectivity/mdns/MdnsProber.java",
         "src/com/android/server/connectivity/mdns/MdnsRecordRepository.java",
+        "src/com/android/server/connectivity/mdns/SchedulerFactory.java",
+        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
     ],
     static_libs: [
         "net-utils-device-common-mdns-standalone-build-test",
@@ -131,7 +134,10 @@
 
 droidstubs {
     name: "service-mdns-droidstubs",
-    srcs: ["src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java"],
+    srcs: [
+        "src/com/android/server/connectivity/mdns/SchedulerFactory.java",
+        "src/com/android/server/connectivity/mdns/SocketNetLinkMonitorFactory.java",
+    ],
     libs: [
         "net-utils-device-common-mdns-standalone-build-test",
         "service-connectivity-tiramisu-pre-jarjar",
diff --git a/service-t/src/com/android/server/ConnectivityServiceInitializer.java b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
index 5d23fdc..5ef1aef 100644
--- a/service-t/src/com/android/server/ConnectivityServiceInitializer.java
+++ b/service-t/src/com/android/server/ConnectivityServiceInitializer.java
@@ -30,6 +30,9 @@
 import com.android.server.nearby.NearbyService;
 import com.android.server.net.ct.CertificateTransparencyService;
 import com.android.server.thread.ThreadNetworkService;
+import com.android.server.vcn.VcnLocation;
+
+import java.lang.reflect.Constructor;
 
 /**
  * Connectivity service initializer for core networking. This is called by system server to create
@@ -37,6 +40,9 @@
  */
 public final class ConnectivityServiceInitializer extends SystemService {
     private static final String TAG = ConnectivityServiceInitializer.class.getSimpleName();
+    private static final String CONNECTIVITY_SERVICE_INITIALIZER_B_CLASS =
+            "com.android.server.ConnectivityServiceInitializerB";
+
     private final ConnectivityNativeService mConnectivityNative;
     private final ConnectivityService mConnectivity;
     private final IpSecService mIpSecService;
@@ -45,6 +51,7 @@
     private final EthernetServiceImpl mEthernetServiceImpl;
     private final ThreadNetworkService mThreadNetworkService;
     private final CertificateTransparencyService mCertificateTransparencyService;
+    private final SystemService mConnectivityServiceInitializerB;
 
     public ConnectivityServiceInitializer(Context context) {
         super(context);
@@ -58,6 +65,7 @@
         mNearbyService = createNearbyService(context);
         mThreadNetworkService = createThreadNetworkService(context);
         mCertificateTransparencyService = createCertificateTransparencyService(context);
+        mConnectivityServiceInitializerB = createConnectivityServiceInitializerB(context);
     }
 
     @Override
@@ -99,6 +107,11 @@
             publishBinderService(ThreadNetworkManager.SERVICE_NAME, mThreadNetworkService,
                     /* allowIsolated= */ false);
         }
+
+        if (mConnectivityServiceInitializerB != null) {
+            Log.i(TAG, "ConnectivityServiceInitializerB#onStart");
+            mConnectivityServiceInitializerB.onStart();
+        }
     }
 
     @Override
@@ -118,6 +131,10 @@
         if (SdkLevel.isAtLeastV() && mCertificateTransparencyService != null) {
             mCertificateTransparencyService.onBootPhase(phase);
         }
+
+        if (mConnectivityServiceInitializerB != null) {
+            mConnectivityServiceInitializerB.onBootPhase(phase);
+        }
     }
 
     /**
@@ -202,4 +219,28 @@
                 ? new CertificateTransparencyService(context)
                 : null;
     }
+
+    // TODO: b/374174952 After VCN code is moved to the Connectivity folder, merge
+    // ConnectivityServiceInitializerB into ConnectivityServiceInitializer and directly create and
+    // register VcnManagementService in ConnectivityServiceInitializer
+    /** Return ConnectivityServiceInitializerB instance if enable, otherwise null. */
+    @Nullable
+    private SystemService createConnectivityServiceInitializerB(Context context) {
+        if (!VcnLocation.IS_VCN_IN_MAINLINE || !SdkLevel.isAtLeastB()) {
+            return null;
+        }
+
+        try {
+            final Class<?> connectivityServiceInitializerBClass =
+                    Class.forName(CONNECTIVITY_SERVICE_INITIALIZER_B_CLASS);
+            final Constructor constructor =
+                    connectivityServiceInitializerBClass.getConstructor(Context.class);
+
+            return (SystemService) constructor.newInstance(context);
+        } catch (Exception e) {
+            Log.e(TAG, "Fail to load ConnectivityServiceInitializerB " + e);
+        }
+
+        return null;
+    }
 }
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 555549c..4af8b0e 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -1942,7 +1942,7 @@
                         mContext, MdnsFeatureFlags.NSD_QUERY_WITH_KNOWN_ANSWER))
                 .setAvoidAdvertisingEmptyTxtRecords(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_AVOID_ADVERTISING_EMPTY_TXT_RECORDS))
-                .setIsCachedServicesRemovalEnabled(mDeps.isFeatureEnabled(
+                .setIsCachedServicesRemovalEnabled(mDeps.isTetheringFeatureNotChickenedOut(
                         mContext, MdnsFeatureFlags.NSD_CACHED_SERVICES_REMOVAL))
                 .setCachedServicesRetentionTime(mDeps.getDeviceConfigPropertyInt(
                         MdnsFeatureFlags.NSD_CACHED_SERVICES_RETENTION_TIME,
diff --git a/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java b/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java
new file mode 100644
index 0000000..99354f8
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/DiscoveryExecutor.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.util.Log;
+import android.util.Pair;
+
+import androidx.annotation.GuardedBy;
+
+import com.android.net.module.util.HandlerUtils;
+
+import java.util.ArrayList;
+import java.util.concurrent.Executor;
+
+/**
+ * A utility class to generate a handler, optionally with a looper, and to run functions on the
+ * newly created handler.
+ */
+public class DiscoveryExecutor implements Executor {
+    private static final String TAG = DiscoveryExecutor.class.getSimpleName();
+    @Nullable
+    private final HandlerThread mHandlerThread;
+
+    @GuardedBy("mPendingTasks")
+    @Nullable
+    private Handler mHandler;
+    // Store pending tasks and associated delay time. Each Pair represents a pending task
+    // (first) and its delay time (second).
+    @GuardedBy("mPendingTasks")
+    @NonNull
+    private final ArrayList<Pair<Runnable, Long>> mPendingTasks = new ArrayList<>();
+
+    @GuardedBy("mPendingTasks")
+    @Nullable
+    Scheduler mScheduler;
+    @NonNull private final MdnsFeatureFlags mMdnsFeatureFlags;
+
+    DiscoveryExecutor(@Nullable Looper defaultLooper, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        mMdnsFeatureFlags = mdnsFeatureFlags;
+        if (defaultLooper != null) {
+            this.mHandlerThread = null;
+            synchronized (mPendingTasks) {
+                this.mHandler = new Handler(defaultLooper);
+            }
+        } else {
+            this.mHandlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName()) {
+                @Override
+                protected void onLooperPrepared() {
+                    synchronized (mPendingTasks) {
+                        mHandler = new Handler(getLooper());
+                        for (Pair<Runnable, Long> pendingTask : mPendingTasks) {
+                            executeDelayed(pendingTask.first, pendingTask.second);
+                        }
+                        mPendingTasks.clear();
+                    }
+                }
+            };
+            this.mHandlerThread.start();
+        }
+    }
+
+    /**
+     * Check if the current thread is the expected thread. If it is, run the given function.
+     * Otherwise, execute it using the handler.
+     */
+    public void checkAndRunOnHandlerThread(@NonNull Runnable function) {
+        if (this.mHandlerThread == null) {
+            // Callers are expected to already be running on the handler when a defaultLooper
+            // was provided
+            function.run();
+        } else {
+            execute(function);
+        }
+    }
+
+    /** Execute the given function */
+    @Override
+    public void execute(Runnable function) {
+        executeDelayed(function, 0L /* delayMillis */);
+    }
+
+    /** Execute the given function after the specified amount of time elapses. */
+    public void executeDelayed(Runnable function, long delayMillis) {
+        final Handler handler;
+        final Scheduler scheduler;
+        synchronized (mPendingTasks) {
+            if (this.mHandler == null) {
+                mPendingTasks.add(Pair.create(function, delayMillis));
+                return;
+            } else {
+                handler = this.mHandler;
+                if (mMdnsFeatureFlags.mIsAccurateDelayCallbackEnabled
+                        && this.mScheduler == null) {
+                    this.mScheduler = SchedulerFactory.createScheduler(mHandler);
+                }
+                scheduler = this.mScheduler;
+            }
+        }
+        if (scheduler != null) {
+            if (delayMillis == 0L) {
+                handler.post(function);
+                return;
+            }
+            if (HandlerUtils.isRunningOnHandlerThread(handler)) {
+                scheduler.postDelayed(function, delayMillis);
+            } else {
+                handler.post(() -> scheduler.postDelayed(function, delayMillis));
+            }
+        } else {
+            handler.postDelayed(function, delayMillis);
+        }
+    }
+
+    /** Shutdown the thread if necessary. */
+    public void shutDown() {
+        if (this.mHandlerThread != null) {
+            this.mHandlerThread.quitSafely();
+        }
+        synchronized (mPendingTasks) {
+            if (mScheduler != null) {
+                mScheduler.close();
+            }
+        }
+    }
+
+    /**
+     * Ensures that the current running thread is the same as the handler thread.
+     */
+    public void ensureRunningOnHandlerThread() {
+        synchronized (mPendingTasks) {
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        }
+    }
+
+    /**
+     * Runs the specified task synchronously for dump method.
+     */
+    public void runWithScissorsForDumpIfReady(@NonNull Runnable function) {
+        final Handler handler;
+        synchronized (mPendingTasks) {
+            if (this.mHandler == null) {
+                Log.d(TAG, "The handler is not ready. Ignore the DiscoveryManager dump");
+                return;
+            } else {
+                handler = this.mHandler;
+            }
+        }
+        HandlerUtils.runWithScissorsForDump(handler, function, 10_000);
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
index c3306bd..bd00b70 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -35,6 +35,7 @@
 import android.os.Looper;
 import android.text.TextUtils;
 import android.util.ArrayMap;
+import android.util.ArraySet;
 import android.util.Log;
 import android.util.SparseArray;
 
@@ -93,6 +94,7 @@
             new ArrayMap<>();
     private final MdnsFeatureFlags mMdnsFeatureFlags;
     private final Map<String, Integer> mServiceTypeToOffloadPriority;
+    private final ArraySet<String> mOffloadServiceTypeDenyList;
 
     /**
      * Dependencies for {@link MdnsAdvertiser}, useful for testing.
@@ -160,6 +162,16 @@
         return mInterfaceOffloadServices.getOrDefault(interfaceName, Collections.emptyList());
     }
 
+    private boolean isInOffloadDenyList(@NonNull String serviceType) {
+        for (int i = 0; i < mOffloadServiceTypeDenyList.size(); ++i) {
+            final String denyListServiceType = mOffloadServiceTypeDenyList.valueAt(i);
+            if (DnsUtils.equalsIgnoreDnsCase(serviceType, denyListServiceType)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private final MdnsInterfaceAdvertiser.Callback mInterfaceAdvertiserCb =
             new MdnsInterfaceAdvertiser.Callback() {
         @Override
@@ -173,19 +185,25 @@
             if (mMdnsFeatureFlags.mIsMdnsOffloadFeatureEnabled
                     // TODO: Enable offload when the serviceInfo contains a custom host.
                     && TextUtils.isEmpty(registration.getServiceInfo().getHostname())) {
-                final String interfaceName = advertiser.getSocketInterfaceName();
-                final List<OffloadServiceInfoWrapper> existingOffloadServiceInfoWrappers =
-                        mInterfaceOffloadServices.computeIfAbsent(interfaceName,
-                                k -> new ArrayList<>());
-                // Remove existing offload services from cache for update.
-                existingOffloadServiceInfoWrappers.removeIf(item -> item.mServiceId == serviceId);
+                final String serviceType = registration.getServiceInfo().getServiceType();
+                if (isInOffloadDenyList(serviceType)) {
+                    mSharedLog.i("Offload denied for service type: " + serviceType);
+                } else {
+                    final String interfaceName = advertiser.getSocketInterfaceName();
+                    final List<OffloadServiceInfoWrapper> existingOffloadServiceInfoWrappers =
+                            mInterfaceOffloadServices.computeIfAbsent(interfaceName,
+                                    k -> new ArrayList<>());
+                    // Remove existing offload services from cache for update.
+                    existingOffloadServiceInfoWrappers.removeIf(
+                            item -> item.mServiceId == serviceId);
 
-                byte[] rawOffloadPacket = advertiser.getRawOffloadPayload(serviceId);
-                final OffloadServiceInfoWrapper newOffloadServiceInfoWrapper = createOffloadService(
-                        serviceId, registration, rawOffloadPacket);
-                existingOffloadServiceInfoWrappers.add(newOffloadServiceInfoWrapper);
-                mCb.onOffloadStartOrUpdate(interfaceName,
-                        newOffloadServiceInfoWrapper.mOffloadServiceInfo);
+                    byte[] rawOffloadPacket = advertiser.getRawOffloadPayload(serviceId);
+                    final OffloadServiceInfoWrapper newOffloadServiceInfoWrapper =
+                            createOffloadService(serviceId, registration, rawOffloadPacket);
+                    existingOffloadServiceInfoWrappers.add(newOffloadServiceInfoWrapper);
+                    mCb.onOffloadStartOrUpdate(interfaceName,
+                            newOffloadServiceInfoWrapper.mOffloadServiceInfo);
+                }
             }
 
             // Wait for all current interfaces to be done probing before notifying of success.
@@ -846,6 +864,8 @@
         final ConnectivityResources res = new ConnectivityResources(context);
         mServiceTypeToOffloadPriority = parseOffloadPriorityList(
                 res.get().getStringArray(R.array.config_nsdOffloadServicesPriority), sharedLog);
+        mOffloadServiceTypeDenyList = new ArraySet<>(
+                res.get().getStringArray(R.array.config_nsdOffloadServicesDenyList));
     }
 
     private static Map<String, Integer> parseOffloadPriorityList(
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
index c833422..8cd3662 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -16,24 +16,17 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.internal.annotations.VisibleForTesting.Visibility;
-
 import android.Manifest.permission;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.annotation.RequiresPermission;
-import android.os.Handler;
-import android.os.HandlerThread;
 import android.os.Looper;
 import android.util.ArrayMap;
 import android.util.Log;
 import android.util.Pair;
 
-import androidx.annotation.GuardedBy;
-
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.DnsUtils;
-import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.SharedLog;
 
 import java.io.IOException;
@@ -41,7 +34,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.Executor;
 
 /**
  * This class keeps tracking the set of registered {@link MdnsServiceBrowserListener} instances, and
@@ -133,99 +125,7 @@
         this.sharedLog = sharedLog;
         this.perSocketServiceTypeClients = new PerSocketServiceTypeClients();
         this.mdnsFeatureFlags = mdnsFeatureFlags;
-        this.discoveryExecutor = new DiscoveryExecutor(socketClient.getLooper());
-    }
-
-    /**
-     * A utility class to generate a handler, optionally with a looper, and to run functions on the
-     * newly created handler.
-     */
-    @VisibleForTesting(visibility = Visibility.PRIVATE)
-    static class DiscoveryExecutor implements Executor {
-        private final HandlerThread handlerThread;
-
-        @GuardedBy("pendingTasks")
-        @Nullable private Handler handler;
-        // Store pending tasks and associated delay time. Each Pair represents a pending task
-        // (first) and its delay time (second).
-        @GuardedBy("pendingTasks")
-        @NonNull private final ArrayList<Pair<Runnable, Long>> pendingTasks = new ArrayList<>();
-
-        DiscoveryExecutor(@Nullable Looper defaultLooper) {
-            if (defaultLooper != null) {
-                this.handlerThread = null;
-                synchronized (pendingTasks) {
-                    this.handler = new Handler(defaultLooper);
-                }
-            } else {
-                this.handlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName()) {
-                    @Override
-                    protected void onLooperPrepared() {
-                        synchronized (pendingTasks) {
-                            handler = new Handler(getLooper());
-                            for (Pair<Runnable, Long> pendingTask : pendingTasks) {
-                                handler.postDelayed(pendingTask.first, pendingTask.second);
-                            }
-                            pendingTasks.clear();
-                        }
-                    }
-                };
-                this.handlerThread.start();
-            }
-        }
-
-        public void checkAndRunOnHandlerThread(@NonNull Runnable function) {
-            if (this.handlerThread == null) {
-                // Callers are expected to already be running on the handler when a defaultLooper
-                // was provided
-                function.run();
-            } else {
-                execute(function);
-            }
-        }
-
-        @Override
-        public void execute(Runnable function) {
-            executeDelayed(function, 0L /* delayMillis */);
-        }
-
-        public void executeDelayed(Runnable function, long delayMillis) {
-            final Handler handler;
-            synchronized (pendingTasks) {
-                if (this.handler == null) {
-                    pendingTasks.add(Pair.create(function, delayMillis));
-                    return;
-                } else {
-                    handler = this.handler;
-                }
-            }
-            handler.postDelayed(function, delayMillis);
-        }
-
-        void shutDown() {
-            if (this.handlerThread != null) {
-                this.handlerThread.quitSafely();
-            }
-        }
-
-        void ensureRunningOnHandlerThread() {
-            synchronized (pendingTasks) {
-                HandlerUtils.ensureRunningOnHandlerThread(handler);
-            }
-        }
-
-        public void runWithScissorsForDumpIfReady(@NonNull Runnable function) {
-            final Handler handler;
-            synchronized (pendingTasks) {
-                if (this.handler == null) {
-                    Log.d(TAG, "The handler is not ready. Ignore the DiscoveryManager dump");
-                    return;
-                } else {
-                    handler = this.handler;
-                }
-            }
-            HandlerUtils.runWithScissorsForDump(handler, function, 10_000);
-        }
+        this.discoveryExecutor = new DiscoveryExecutor(socketClient.getLooper(), mdnsFeatureFlags);
     }
 
     /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index 2f3bdc5..11a374d 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -95,6 +95,11 @@
             "nsd_cached_services_retention_time";
     public static final int DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS = 10000;
 
+    /**
+     * A feature flag to control whether the accurate delay callback should be enabled.
+     */
+    public static final String NSD_ACCURATE_DELAY_CALLBACK = "nsd_accurate_delay_callback";
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
@@ -128,6 +133,9 @@
     // Retention Time for cached services
     public final long mCachedServicesRetentionTime;
 
+    // Flag for accurate delay callback
+    public final boolean mIsAccurateDelayCallbackEnabled;
+
     // Flag to use shorter (16 characters + .local) hostnames
     public final boolean mIsShortHostnamesEnabled;
 
@@ -231,6 +239,14 @@
     }
 
     /**
+     * Indicates whether {@link #NSD_ACCURATE_DELAY_CALLBACK} is enabled, including for testing.
+     */
+    public boolean isAccurateDelayCallbackEnabled() {
+        return mIsAccurateDelayCallbackEnabled
+                || isForceEnabledForTest(NSD_ACCURATE_DELAY_CALLBACK);
+    }
+
+    /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
     public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
@@ -244,6 +260,7 @@
             boolean avoidAdvertisingEmptyTxtRecords,
             boolean isCachedServicesRemovalEnabled,
             long cachedServicesRetentionTime,
+            boolean isAccurateDelayCallbackEnabled,
             boolean isShortHostnamesEnabled,
             @Nullable FlagOverrideProvider overrideProvider) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
@@ -257,6 +274,7 @@
         mAvoidAdvertisingEmptyTxtRecords = avoidAdvertisingEmptyTxtRecords;
         mIsCachedServicesRemovalEnabled = isCachedServicesRemovalEnabled;
         mCachedServicesRetentionTime = cachedServicesRetentionTime;
+        mIsAccurateDelayCallbackEnabled = isAccurateDelayCallbackEnabled;
         mIsShortHostnamesEnabled = isShortHostnamesEnabled;
         mOverrideProvider = overrideProvider;
     }
@@ -281,6 +299,7 @@
         private boolean mAvoidAdvertisingEmptyTxtRecords;
         private boolean mIsCachedServicesRemovalEnabled;
         private long mCachedServicesRetentionTime;
+        private boolean mIsAccurateDelayCallbackEnabled;
         private boolean mIsShortHostnamesEnabled;
         private FlagOverrideProvider mOverrideProvider;
 
@@ -297,8 +316,9 @@
             mIsAggressiveQueryModeEnabled = false;
             mIsQueryWithKnownAnswerEnabled = false;
             mAvoidAdvertisingEmptyTxtRecords = true; // Default enabled.
-            mIsCachedServicesRemovalEnabled = false;
+            mIsCachedServicesRemovalEnabled = true; // Default enabled.
             mCachedServicesRetentionTime = DEFAULT_CACHED_SERVICES_RETENTION_TIME_MILLISECONDS;
+            mIsAccurateDelayCallbackEnabled = false;
             mIsShortHostnamesEnabled = true; // Default enabled.
             mOverrideProvider = null;
         }
@@ -426,6 +446,16 @@
         }
 
         /**
+         * Set whether the accurate delay callback is enabled.
+         *
+         * @see #NSD_ACCURATE_DELAY_CALLBACK
+         */
+        public Builder setIsAccurateDelayCallbackEnabled(boolean isAccurateDelayCallbackEnabled) {
+            mIsAccurateDelayCallbackEnabled = isAccurateDelayCallbackEnabled;
+            return this;
+        }
+
+        /**
          * Set whether the short hostnames feature is enabled.
          *
          * @see #NSD_USE_SHORT_HOSTNAMES
@@ -450,6 +480,7 @@
                     mAvoidAdvertisingEmptyTxtRecords,
                     mIsCachedServicesRemovalEnabled,
                     mCachedServicesRetentionTime,
+                    mIsAccurateDelayCallbackEnabled,
                     mIsShortHostnamesEnabled,
                     mOverrideProvider);
         }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index 4ae8701..7c72fb1 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -78,6 +78,17 @@
             return "CacheKey{ ServiceType=" + mUpperCaseServiceType + ", " + mSocketKey + " }";
         }
     }
+
+    public static class CachedService {
+        @NonNull final MdnsResponse mService;
+        boolean mServiceExpired;
+
+        CachedService(MdnsResponse service) {
+            mService = service;
+            mServiceExpired = false;
+        }
+    }
+
     /**
      * A map of cached services. Key is composed of service type and socket. Value is the list of
      * services which are discovered from the given CacheKey.
@@ -86,7 +97,7 @@
      * removal process to progress through the expiration check efficiently.
      */
     @NonNull
-    private final ArrayMap<CacheKey, List<MdnsResponse>> mCachedServices = new ArrayMap<>();
+    private final ArrayMap<CacheKey, List<CachedService>> mCachedServices = new ArrayMap<>();
     /**
      * A map of service expire callbacks. Key is composed of service type and socket and value is
      * the callback listener.
@@ -113,6 +124,14 @@
         mClock = clock;
     }
 
+    private List<MdnsResponse> cachedServicesToResponses(List<CachedService> cachedServices) {
+        final List<MdnsResponse> responses = new ArrayList<>();
+        for (CachedService cachedService : cachedServices) {
+            responses.add(cachedService.mService);
+        }
+        return responses;
+    }
+
     /**
      * Get the cache services which are queried from given service type and socket.
      *
@@ -126,7 +145,8 @@
             maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
         }
         return mCachedServices.containsKey(cacheKey)
-                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(cacheKey)))
+                ? Collections.unmodifiableList(
+                        cachedServicesToResponses(mCachedServices.get(cacheKey)))
                 : Collections.emptyList();
     }
 
@@ -147,6 +167,16 @@
         return null;
     }
 
+    private static CachedService findMatchedCachedService(
+            @NonNull List<CachedService> cachedServices, @NonNull String serviceName) {
+        for (CachedService cachedService : cachedServices) {
+            if (equalsIgnoreDnsCase(serviceName, cachedService.mService.getServiceInstanceName())) {
+                return cachedService;
+            }
+        }
+        return null;
+    }
+
     /**
      * Get the cache service.
      *
@@ -160,22 +190,23 @@
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             maybeRemoveExpiredServices(cacheKey, mClock.elapsedRealtime());
         }
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             return null;
         }
-        final MdnsResponse response = findMatchedResponse(responses, serviceName);
-        return response != null ? new MdnsResponse(response) : null;
+        final CachedService cachedService = findMatchedCachedService(cachedServices, serviceName);
+        return cachedService != null ? new MdnsResponse(cachedService.mService) : null;
     }
 
-    static void insertResponseAndSortList(
-            List<MdnsResponse> responses, MdnsResponse response, long now) {
+    static void insertServiceAndSortList(
+            List<CachedService> cachedServices, CachedService cachedService, long now) {
         // binarySearch returns "the index of the search key, if it is contained in the list;
         // otherwise, (-(insertion point) - 1)"
-        final int searchRes = Collections.binarySearch(responses, response,
+        final int searchRes = Collections.binarySearch(cachedServices, cachedService,
                 // Sort the list by ttl.
-                (o1, o2) -> Long.compare(o1.getMinRemainingTtl(now), o2.getMinRemainingTtl(now)));
-        responses.add(searchRes >= 0 ? searchRes : (-searchRes - 1), response);
+                (o1, o2) -> Long.compare(o1.mService.getMinRemainingTtl(now),
+                        o2.mService.getMinRemainingTtl(now)));
+        cachedServices.add(searchRes >= 0 ? searchRes : (-searchRes - 1), cachedService);
     }
 
     /**
@@ -186,20 +217,22 @@
      */
     public void addOrUpdateService(@NonNull CacheKey cacheKey, @NonNull MdnsResponse response) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses = mCachedServices.computeIfAbsent(
+        final List<CachedService> cachedServices = mCachedServices.computeIfAbsent(
                 cacheKey, key -> new ArrayList<>());
         // Remove existing service if present.
-        final MdnsResponse existing =
-                findMatchedResponse(responses, response.getServiceInstanceName());
-        responses.remove(existing);
+        final CachedService existing = findMatchedCachedService(cachedServices,
+                response.getServiceInstanceName());
+        cachedServices.remove(existing);
+
+        final CachedService cachedService = new CachedService(response);
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             final long now = mClock.elapsedRealtime();
             // Insert and sort service
-            insertResponseAndSortList(responses, response, now);
+            insertServiceAndSortList(cachedServices, cachedService, now);
             // Update the next expiration check time when a new service is added.
             mNextExpirationTime = getNextExpirationTime(now);
         } else {
-            responses.add(response);
+            cachedServices.add(cachedService);
         }
     }
 
@@ -212,30 +245,30 @@
     @Nullable
     public MdnsResponse removeService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             return null;
         }
-        final Iterator<MdnsResponse> iterator = responses.iterator();
-        MdnsResponse removedResponse = null;
+        final Iterator<CachedService> iterator = cachedServices.iterator();
+        CachedService removedService = null;
         while (iterator.hasNext()) {
-            final MdnsResponse response = iterator.next();
-            if (equalsIgnoreDnsCase(serviceName, response.getServiceInstanceName())) {
+            final CachedService cachedService = iterator.next();
+            if (equalsIgnoreDnsCase(serviceName, cachedService.mService.getServiceInstanceName())) {
                 iterator.remove();
-                removedResponse = response;
+                removedService = cachedService;
                 break;
             }
         }
 
         if (mMdnsFeatureFlags.mIsExpiredServicesRemovalEnabled) {
             // Remove the serviceType if no response.
-            if (responses.isEmpty()) {
+            if (cachedServices.isEmpty()) {
                 mCachedServices.remove(cacheKey);
             }
             // Update the next expiration check time when a service is removed.
             mNextExpirationTime = getNextExpirationTime(mClock.elapsedRealtime());
         }
-        return removedResponse;
+        return removedService == null ? null : removedService.mService;
     }
 
     /**
@@ -288,24 +321,25 @@
         mHandler.post(()-> callback.onServiceRecordExpired(previousResponse, newResponse));
     }
 
-    static List<MdnsResponse> removeExpiredServices(@NonNull List<MdnsResponse> responses,
+    static List<CachedService> removeExpiredServices(@NonNull List<CachedService> cachedServices,
             long now) {
-        final List<MdnsResponse> removedResponses = new ArrayList<>();
-        final Iterator<MdnsResponse> iterator = responses.iterator();
+        final List<CachedService> removedServices = new ArrayList<>();
+        final Iterator<CachedService> iterator = cachedServices.iterator();
         while (iterator.hasNext()) {
-            final MdnsResponse response = iterator.next();
+            final CachedService cachedService = iterator.next();
             // TODO: Check other records (A, AAAA, TXT) ttl time and remove the record if it's
             //  expired. Then send service update notification.
-            if (!response.hasServiceRecord() || response.getMinRemainingTtl(now) > 0) {
+            if (!cachedService.mService.hasServiceRecord()
+                    || cachedService.mService.getMinRemainingTtl(now) > 0) {
                 // The responses are sorted by the service record ttl time. Break out of loop
                 // early if service is not expired or no service record.
                 break;
             }
             // Remove the ttl expired service.
             iterator.remove();
-            removedResponses.add(response);
+            removedServices.add(cachedService);
         }
-        return removedResponses;
+        return removedServices;
     }
 
     private long getNextExpirationTime(long now) {
@@ -319,7 +353,7 @@
                     // The empty lists are not kept in the map, so there's always at least one
                     // element in the list. Therefore, it's fine to get the first element without a
                     // null check.
-                    mCachedServices.valueAt(i).get(0).getMinRemainingTtl(now));
+                    mCachedServices.valueAt(i).get(0).mService.getMinRemainingTtl(now));
         }
         return minRemainingTtl == EXPIRATION_NEVER ? EXPIRATION_NEVER : now + minRemainingTtl;
     }
@@ -334,24 +368,24 @@
             return;
         }
 
-        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
-        if (responses == null) {
+        final List<CachedService> cachedServices = mCachedServices.get(cacheKey);
+        if (cachedServices == null) {
             // No such services.
             return;
         }
 
-        final List<MdnsResponse> removedResponses = removeExpiredServices(responses, now);
-        if (removedResponses.isEmpty()) {
+        final List<CachedService> removedServices = removeExpiredServices(cachedServices, now);
+        if (removedServices.isEmpty()) {
             // No expired services.
             return;
         }
 
-        for (MdnsResponse previousResponse : removedResponses) {
-            notifyServiceExpired(cacheKey, previousResponse, null /* newResponse */);
+        for (CachedService previousService : removedServices) {
+            notifyServiceExpired(cacheKey, previousService.mService, null /* newResponse */);
         }
 
         // Remove the serviceType if no response.
-        if (responses.isEmpty()) {
+        if (cachedServices.isEmpty()) {
             mCachedServices.remove(cacheKey);
         }
 
@@ -368,8 +402,9 @@
         for (int i = 0; i < mCachedServices.size(); i++) {
             final CacheKey key = mCachedServices.keyAt(i);
             pw.println(indent + key);
-            for (MdnsResponse response : mCachedServices.valueAt(i)) {
-                pw.println(indent + "  Response{ " + response + " }");
+            for (CachedService cachedService : mCachedServices.valueAt(i)) {
+                pw.println(indent + "  Response{ " + cachedService.mService
+                        + " } Expired=" + cachedService.mServiceExpired);
             }
             pw.println();
         }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 8c86fb8..95f4fff 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -20,6 +20,7 @@
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.ServiceExpiredCallback;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.findMatchedResponse;
+import static com.android.server.connectivity.mdns.MdnsQueryScheduler.ScheduledQueryTaskArgs;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.Clock;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.buildMdnsServiceInfoFromResponse;
 
@@ -94,6 +95,9 @@
     private final boolean removeServiceAfterTtlExpires =
             MdnsConfigs.removeServiceAfterTtlExpires();
     private final Clock clock;
+    // Use MdnsRealtimeScheduler for query scheduling, which allows for more accurate sending of
+    // queries.
+    @Nullable private final Scheduler scheduler;
 
     @Nullable private MdnsSearchOptions searchOptions;
 
@@ -139,8 +143,7 @@
         public void handleMessage(Message msg) {
             switch (msg.what) {
                 case EVENT_START_QUERYTASK: {
-                    final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs =
-                            (MdnsQueryScheduler.ScheduledQueryTaskArgs) msg.obj;
+                    final ScheduledQueryTaskArgs taskArgs = (ScheduledQueryTaskArgs) msg.obj;
                     // QueryTask should be run immediately after being created (not be scheduled in
                     // advance). Because the result of "makeResponsesForResolve" depends on answers
                     // that were received before it is called, so to take into account all answers
@@ -174,7 +177,7 @@
                     final long now = clock.elapsedRealtime();
                     lastSentTime = now;
                     final long minRemainingTtl = getMinRemainingTtl(now);
-                    MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+                    final ScheduledQueryTaskArgs args =
                             mdnsQueryScheduler.scheduleNextRun(
                                     sentResult.taskArgs.config,
                                     minRemainingTtl,
@@ -189,10 +192,14 @@
                     sharedLog.log(String.format("Query sent with transactionId: %d. "
                                     + "Next run: sessionId: %d, in %d ms",
                             sentResult.transactionId, args.sessionId, timeToNextTaskMs));
-                    dependencies.sendMessageDelayed(
-                            handler,
-                            handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                            timeToNextTaskMs);
+                    if (scheduler != null) {
+                        setDelayedTask(args, timeToNextTaskMs);
+                    } else {
+                        dependencies.sendMessageDelayed(
+                                handler,
+                                handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                                timeToNextTaskMs);
+                    }
                     break;
                 }
                 default:
@@ -254,6 +261,14 @@
                 return List.of(new DatagramPacket(queryBuffer, 0, queryBuffer.length, address));
             }
         }
+
+        /**
+         * @see Scheduler
+         */
+        @Nullable
+        public Scheduler createScheduler(@NonNull Handler handler) {
+            return SchedulerFactory.createScheduler(handler);
+        }
     }
 
     /**
@@ -301,6 +316,8 @@
         this.mdnsQueryScheduler = new MdnsQueryScheduler();
         this.cacheKey = new MdnsServiceCache.CacheKey(serviceType, socketKey);
         this.featureFlags = featureFlags;
+        this.scheduler = featureFlags.isAccurateDelayCallbackEnabled()
+                ? dependencies.createScheduler(handler) : null;
     }
 
     /**
@@ -310,6 +327,9 @@
         removeScheduledTask();
         mdnsQueryScheduler.cancelScheduledRun();
         serviceCache.unregisterServiceExpiredCallback(cacheKey);
+        if (scheduler != null) {
+            scheduler.close();
+        }
     }
 
     private List<MdnsResponse> getExistingServices() {
@@ -317,6 +337,12 @@
                 ? serviceCache.getCachedServices(cacheKey) : Collections.emptyList();
     }
 
+    private void setDelayedTask(ScheduledQueryTaskArgs args, long timeToNextTaskMs) {
+        scheduler.removeDelayedMessage(EVENT_START_QUERYTASK);
+        scheduler.sendDelayedMessage(
+                handler.obtainMessage(EVENT_START_QUERYTASK, args), timeToNextTaskMs);
+    }
+
     /**
      * Registers {@code listener} for receiving discovery event of mDNS service instances, and
      * starts
@@ -363,7 +389,7 @@
         }
         final long minRemainingTtl = getMinRemainingTtl(now);
         if (hadReply) {
-            MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+            final ScheduledQueryTaskArgs args =
                     mdnsQueryScheduler.scheduleNextRun(
                             taskConfig,
                             minRemainingTtl,
@@ -377,10 +403,14 @@
             final long timeToNextTaskMs = calculateTimeToNextTask(args, now);
             sharedLog.log(String.format("Schedule a query. Next run: sessionId: %d, in %d ms",
                     args.sessionId, timeToNextTaskMs));
-            dependencies.sendMessageDelayed(
-                    handler,
-                    handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                    timeToNextTaskMs);
+            if (scheduler != null) {
+                setDelayedTask(args, timeToNextTaskMs);
+            } else {
+                dependencies.sendMessageDelayed(
+                        handler,
+                        handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                        timeToNextTaskMs);
+            }
         } else {
             final List<MdnsResponse> servicesToResolve = makeResponsesForResolve(socketKey);
             final QueryTask queryTask = new QueryTask(
@@ -420,7 +450,11 @@
     }
 
     private void removeScheduledTask() {
-        dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
+        if (scheduler != null) {
+            scheduler.removeDelayedMessage(EVENT_START_QUERYTASK);
+        } else {
+            dependencies.removeMessages(handler, EVENT_START_QUERYTASK);
+        }
         sharedLog.log("Remove EVENT_START_QUERYTASK"
                 + ", current session: " + currentSessionId);
         ++currentSessionId;
@@ -506,10 +540,13 @@
                 }
             }
         }
-        if (dependencies.hasMessages(handler, EVENT_START_QUERYTASK)) {
+        final boolean hasScheduledTask = scheduler != null
+                ? scheduler.hasDelayedMessage(EVENT_START_QUERYTASK)
+                : dependencies.hasMessages(handler, EVENT_START_QUERYTASK);
+        if (hasScheduledTask) {
             final long now = clock.elapsedRealtime();
             final long minRemainingTtl = getMinRemainingTtl(now);
-            MdnsQueryScheduler.ScheduledQueryTaskArgs args =
+            final ScheduledQueryTaskArgs args =
                     mdnsQueryScheduler.maybeRescheduleCurrentRun(now, minRemainingTtl,
                             lastSentTime, currentSessionId + 1,
                             searchOptions.numOfQueriesBeforeBackoff());
@@ -518,10 +555,14 @@
                 final long timeToNextTaskMs = calculateTimeToNextTask(args, now);
                 sharedLog.log(String.format("Reschedule a query. Next run: sessionId: %d, in %d ms",
                         args.sessionId, timeToNextTaskMs));
-                dependencies.sendMessageDelayed(
-                        handler,
-                        handler.obtainMessage(EVENT_START_QUERYTASK, args),
-                        timeToNextTaskMs);
+                if (scheduler != null) {
+                    setDelayedTask(args, timeToNextTaskMs);
+                } else {
+                    dependencies.sendMessageDelayed(
+                            handler,
+                            handler.obtainMessage(EVENT_START_QUERYTASK, args),
+                            timeToNextTaskMs);
+                }
             }
         }
     }
@@ -686,10 +727,10 @@
     private static class QuerySentArguments {
         private final int transactionId;
         private final List<String> subTypes = new ArrayList<>();
-        private final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs;
+        private final ScheduledQueryTaskArgs taskArgs;
 
         QuerySentArguments(int transactionId, @NonNull List<String> subTypes,
-                @NonNull MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs) {
+                @NonNull ScheduledQueryTaskArgs taskArgs) {
             this.transactionId = transactionId;
             this.subTypes.addAll(subTypes);
             this.taskArgs = taskArgs;
@@ -698,14 +739,14 @@
 
     // A FutureTask that enqueues a single query, and schedule a new FutureTask for the next task.
     private class QueryTask implements Runnable {
-        private final MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs;
+        private final ScheduledQueryTaskArgs taskArgs;
         private final List<MdnsResponse> servicesToResolve = new ArrayList<>();
         private final List<String> subtypes = new ArrayList<>();
         private final boolean sendDiscoveryQueries;
         private final List<MdnsResponse> existingServices = new ArrayList<>();
         private final boolean onlyUseIpv6OnIpv6OnlyNetworks;
         private final SocketKey socketKey;
-        QueryTask(@NonNull MdnsQueryScheduler.ScheduledQueryTaskArgs taskArgs,
+        QueryTask(@NonNull ScheduledQueryTaskArgs taskArgs,
                 @NonNull Collection<MdnsResponse> servicesToResolve,
                 @NonNull Collection<String> subtypes, boolean sendDiscoveryQueries,
                 @NonNull Collection<MdnsResponse> existingServices,
@@ -771,7 +812,7 @@
         return minRemainingTtl == Long.MAX_VALUE ? 0 : minRemainingTtl;
     }
 
-    private static long calculateTimeToNextTask(MdnsQueryScheduler.ScheduledQueryTaskArgs args,
+    private static long calculateTimeToNextTask(ScheduledQueryTaskArgs args,
             long now) {
         return Math.max(args.timeToRun - now, 0);
     }
diff --git a/service-t/src/com/android/server/connectivity/mdns/Scheduler.java b/service-t/src/com/android/server/connectivity/mdns/Scheduler.java
new file mode 100644
index 0000000..85a8e76
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/Scheduler.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns;
+
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+
+/**
+ * The interface for scheduler.
+ */
+public interface Scheduler {
+    /**
+     * Set a message to be sent after a specified delay.
+     */
+    boolean sendDelayedMessage(@NonNull Message message, long delayMs);
+
+    /**
+     * Remove a scheduled message.
+     */
+    void removeDelayedMessage(int what);
+
+    /**
+     * Check if there is a scheduled message.
+     */
+    boolean hasDelayedMessage(int what);
+
+    /**
+     * Set a runnable to be executed after a specified delay.
+     */
+    boolean postDelayed(@NonNull Runnable runnable, long delayMs);
+
+    /**
+     * Close this object.
+     */
+    void close();
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java b/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java
new file mode 100644
index 0000000..1cc9a6b
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/SchedulerFactory.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+
+import com.android.server.connectivity.mdns.internal.MdnsRealtimeScheduler;
+
+/**
+ * The factory class for creating a scheduler.
+ */
+public class SchedulerFactory {
+
+    /**
+     * Creates an realtime delay callback.
+     */
+    public static Scheduler createScheduler(@NonNull Handler handler) {
+        return new MdnsRealtimeScheduler(handler);
+    }
+
+    private SchedulerFactory() {
+    }
+}
diff --git a/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java b/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java
new file mode 100644
index 0000000..eff7085
--- /dev/null
+++ b/service-t/src/com/android/server/connectivity/mdns/internal/MdnsRealtimeScheduler.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns.internal;
+
+import android.os.Handler;
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+
+import com.android.net.module.util.RealtimeScheduler;
+import com.android.server.connectivity.mdns.Scheduler;
+
+/**
+ * The delay callback for delivering scheduled tasks accurately.
+ */
+public class MdnsRealtimeScheduler extends RealtimeScheduler implements
+        Scheduler {
+    private static final String TAG = MdnsRealtimeScheduler.class.getSimpleName();
+
+    public MdnsRealtimeScheduler(@NonNull Handler handler) {
+        super(handler);
+    }
+
+    public boolean sendDelayedMessage(@NonNull Message message, long delayMs) {
+        return super.sendDelayedMessage(message, delayMs);
+    }
+
+    public void removeDelayedMessage(int what) {
+        super.removeDelayedMessage(what);
+    }
+
+    public boolean hasDelayedMessage(int what) {
+        return super.hasDelayedMessage(what);
+    }
+
+    public boolean postDelayed(@NonNull Runnable runnable, long delayMs) {
+        return super.postDelayed(runnable, delayMs);
+    }
+
+    public void close() {
+        super.close();
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsFactory.java b/service-t/src/com/android/server/net/NetworkStatsFactory.java
index 5f66f47..5ff708d 100644
--- a/service-t/src/com/android/server/net/NetworkStatsFactory.java
+++ b/service-t/src/com/android/server/net/NetworkStatsFactory.java
@@ -21,7 +21,6 @@
 import static android.net.NetworkStats.UID_ALL;
 
 import android.annotation.NonNull;
-import android.annotation.Nullable;
 import android.content.Context;
 import android.net.NetworkStats;
 import android.net.UnderlyingNetworkInfo;
@@ -31,6 +30,7 @@
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.BpfNetMaps;
+import com.android.server.connectivity.InterfaceTracker;
 
 import java.io.IOException;
 import java.net.ProtocolException;
@@ -108,7 +108,7 @@
 
         /** Create a new {@link BpfNetMaps}. */
         public BpfNetMaps createBpfNetMaps(@NonNull Context ctx) {
-            return new BpfNetMaps(ctx);
+            return new BpfNetMaps(ctx, new InterfaceTracker(ctx));
         }
     }
 
diff --git a/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem b/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem
index 80dccbe..8a5ebbf 100644
--- a/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem
+++ b/service/ServiceConnectivityResources/res/raw/ct_public_keys.pem
@@ -1,4 +1,18 @@
 -----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAmDwwE2FRpVJlw58fo5Ra
+Fsocb7DP3FJwwuaghXL3xPtyZisDDXIpfVG+UwDPyIGrRuYzeu9pjZ/0xGSYSPZ0
+l/H8L2XurInoAbj+Z370HB7W3njIOqG9rw5N6u/xT4nscBj1HKeUwh+Hwc0F1UHS
+MP8J32nWAfVepHrte3Jy+w/V7BId6WjJmxtI9OoJ7WTsoTeD+jLANUJWtpbx0p1L
+OAy70BlHbB0UvAJdMH149qi7Y9KaJ74Ea2ofKY43NWGgWfR+fY6V7CCfUXCOgvNM
+qq5QGyMnFKrlP0XkoOaVJkK92VEtyNff8KUXik2ZyUzhNkg4ZplCrhESWeykckB/
+mdZpVc45KZ+6Sx3U+FF30eRwlu2Nw2h1KKHzYfa6M1bcy1f/xw+IDq4R+1rR7sPb
+J2mMKz0OPeCXwGEXWzBuMOs0IQu6gyNdyVZcRSyQ+LcUzvEwksLP6G/ycqmwVfdw
+JE28k3MPUR3IxnMDQrdcZb7M7kjBoykKW3jQfwlEoK4EcNQbMXVn8Ws8rcwgQcQJ
+MjjQnbISojsJYo2fG+TE6d9rORB6CYVzICOj4YguXm4LO89cYQlR600W32pP5y3o
+3/yAd9OjsKrNfREDlcCXUx1APc7gOF351RFdHlDI0+RF/pIHbH3sww3VMCJ+tjst
+ZldgJk9yaz0cvOdKyVWC83ECAwEAAQ==
+-----END PUBLIC KEY-----
+-----BEGIN PUBLIC KEY-----
 MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAnmb1lacOnP5H1bwb06mG
 fEUeC9PZRwNQskSs9KaWrpfrSkLKuHXkVCbgeagbUR/Sh1OeIhyJRSS0PLCO0JjC
 UpGhYMrIGRgEET4IrP9f8aMFqxxxBUEanI+OxAhIJlP9tiWfGdKAASYcxg/DyXXz
diff --git a/service/ServiceConnectivityResources/res/values-iw/strings.xml b/service/ServiceConnectivityResources/res/values-iw/strings.xml
index b5b4071..88b539a 100644
--- a/service/ServiceConnectivityResources/res/values-iw/strings.xml
+++ b/service/ServiceConnectivityResources/res/values-iw/strings.xml
@@ -23,15 +23,15 @@
     <!-- no translation found for network_available_sign_in_detailed (8439369644697866359) -->
     <skip />
     <string name="mobile_network_available_no_internet" msgid="1000871587359324217">"אין אינטרנט"</string>
-    <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"יכול להיות שחבילת הגלישה שלך אצל <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> נגמרה. אפשר להקיש כדי להציג את האפשרויות."</string>
-    <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"יכול להיות שחבילת הגלישה נגמרה. אפשר להקיש כדי להציג את האפשרויות."</string>
+    <string name="mobile_network_available_no_internet_detailed" msgid="5438738723127062816">"יכול להיות שחבילת הגלישה שלך אצל <xliff:g id="NETWORK_CARRIER">%1$s</xliff:g> נגמרה. אפשר ללחוץ כדי להציג את האפשרויות."</string>
+    <string name="mobile_network_available_no_internet_detailed_unknown_carrier" msgid="5375681117265354337">"יכול להיות שחבילת הגלישה נגמרה. אפשר ללחוץ כדי להציג את האפשרויות."</string>
     <string name="wifi_no_internet" msgid="1326348603404555475">"ל-<xliff:g id="NETWORK_SSID">%1$s</xliff:g> אין גישה לאינטרנט"</string>
-    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"הקש לקבלת אפשרויות"</string>
+    <string name="wifi_no_internet_detailed" msgid="1746921096565304090">"אפשר ללחוץ כדי להציג את האפשרויות"</string>
     <string name="mobile_no_internet" msgid="4087718456753201450">"לרשת הסלולרית אין גישה לאינטרנט"</string>
     <string name="other_networks_no_internet" msgid="5693932964749676542">"לרשת אין גישה לאינטרנט"</string>
     <string name="private_dns_broken_detailed" msgid="2677123850463207823">"‏לא ניתן לגשת לשרת DNS הפרטי"</string>
     <string name="network_partial_connectivity" msgid="5549503845834993258">"הקישוריות של <xliff:g id="NETWORK_SSID">%1$s</xliff:g> מוגבלת"</string>
-    <string name="network_partial_connectivity_detailed" msgid="4732435946300249845">"כדי להתחבר למרות זאת יש להקיש"</string>
+    <string name="network_partial_connectivity_detailed" msgid="4732435946300249845">"כדי להתחבר למרות זאת יש ללחוץ"</string>
     <string name="network_switch_metered" msgid="5016937523571166319">"מעבר אל <xliff:g id="NETWORK_TYPE">%1$s</xliff:g>"</string>
     <string name="network_switch_metered_detail" msgid="1257300152739542096">"המכשיר משתמש ברשת <xliff:g id="NEW_NETWORK">%1$s</xliff:g> כשלרשת <xliff:g id="PREVIOUS_NETWORK">%2$s</xliff:g> אין גישה לאינטרנט. עשויים לחול חיובים."</string>
     <string name="network_switch_metered_toast" msgid="70691146054130335">"עבר מרשת <xliff:g id="PREVIOUS_NETWORK">%1$s</xliff:g> לרשת <xliff:g id="NEW_NETWORK">%2$s</xliff:g>"</string>
diff --git a/service/ServiceConnectivityResources/res/values/config.xml b/service/ServiceConnectivityResources/res/values/config.xml
index 2d3647a..1b0f29d 100644
--- a/service/ServiceConnectivityResources/res/values/config.xml
+++ b/service/ServiceConnectivityResources/res/values/config.xml
@@ -135,6 +135,15 @@
     <string-array translatable="false" name="config_nsdOffloadServicesPriority">
     </string-array>
 
+    <!-- An array of service types that shouldn't be offloaded via NsdManager#registerOffloadEngine.
+         Format is [service type], for example: "_testservice._tcp"
+         Due to limited RAM in hardware offload, we prioritize user-impacting services.
+         _googlezone._tcp, an internal Googlecast service, was therefore blocked.
+    -->
+    <string-array name="config_nsdOffloadServicesDenyList" translatable="false">
+        <item>_googlezone._tcp</item>
+    </string-array>
+
     <!-- Whether to use an ongoing notification for signing in to captive portals, instead of a
          notification that can be dismissed. -->
     <bool name="config_ongoingSignInNotification">false</bool>
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index d1d9e52..128a98f 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -26,6 +26,10 @@
     -->
     <bool name="config_thread_default_enabled">true</bool>
 
+    <!-- Sets to {@code true} to enable Thread Border Router on the device by default.
+    -->
+    <bool name="config_thread_border_router_default_enabled">false</bool>
+
     <!-- Whether to use location APIs in the algorithm to determine country code or not.
     If disabled, will use other sources (telephony, wifi, etc) to determine device location for
     Thread Network regulatory purposes.
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index 7ac86aa..5c0ba78 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -31,6 +31,7 @@
             <item type="integer" name="config_networkWakeupPacketMask"/>
             <item type="integer" name="config_networkNotifySwitchType"/>
             <item type="array" name="config_networkNotifySwitches"/>
+            <item type="array" name="config_nsdOffloadServicesDenyList"/>
             <item type="array" name="config_nsdOffloadServicesPriority"/>
             <item type="bool" name="config_ongoingSignInNotification"/>
             <item type="bool" name="config_autoCancelNetworkNotifications"/>
diff --git a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
index c0082bb..622fba8 100644
--- a/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
+++ b/service/jni/com_android_server_connectivity_ClatCoordinator.cpp
@@ -104,7 +104,7 @@
     // First verify the clatd directory and binary,
     // since this is built into the apex file system image,
     // failures here are 99% likely to be build problems.
-    V(kClatdDir, S_IFDIR|0750, ROOT, SYSTEM, "system_file", DIR);
+    V(kClatdDir, S_IFDIR|0750, CLAT, SYSTEM, "system_file", DIR);
     V(kClatdBin, S_IFREG|S_ISUID|S_ISGID|0755, CLAT, CLAT, "clatd_exec", BIN);
 
     // Move on to verifying that the bpf programs and maps are as expected.
diff --git a/service/src/com/android/server/BpfNetMaps.java b/service/src/com/android/server/BpfNetMaps.java
index 7c0c223..25c0617 100644
--- a/service/src/com/android/server/BpfNetMaps.java
+++ b/service/src/com/android/server/BpfNetMaps.java
@@ -49,6 +49,8 @@
 import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UNINSTALLED;
 import static com.android.server.connectivity.NetworkPermissions.TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS;
 
+import android.annotation.NonNull;
+import android.annotation.Nullable;
 import android.app.StatsManager;
 import android.content.Context;
 import android.net.BpfNetMapsUtils;
@@ -85,12 +87,14 @@
 import com.android.net.module.util.bpf.IngressDiscardKey;
 import com.android.net.module.util.bpf.IngressDiscardValue;
 import com.android.net.module.util.bpf.LocalNetAccessKey;
+import com.android.server.connectivity.InterfaceTracker;
 
 import java.io.FileDescriptor;
 import java.io.IOException;
 import java.net.InetAddress;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.StringJoiner;
 
@@ -142,6 +146,7 @@
             Pair.create(TRAFFIC_PERMISSION_INTERNET, "PERMISSION_INTERNET"),
             Pair.create(TRAFFIC_PERMISSION_UPDATE_DEVICE_STATS, "PERMISSION_UPDATE_DEVICE_STATS")
     );
+    private final InterfaceTracker mInterfaceTracker;
 
     /**
      * Set configurationMap for test.
@@ -423,23 +428,27 @@
 
     /** Constructor used after T that doesn't need to use netd anymore. */
     @RequiresApi(Build.VERSION_CODES.TIRAMISU)
-    public BpfNetMaps(final Context context) {
-        this(context, null);
+    public BpfNetMaps(final Context context, @NonNull final InterfaceTracker interfaceTracker) {
+        this(context, null, interfaceTracker);
 
         if (!SdkLevel.isAtLeastT()) throw new IllegalArgumentException("BpfNetMaps need to use netd before T");
     }
 
-    public BpfNetMaps(final Context context, final INetd netd) {
-        this(context, netd, new Dependencies());
+    public BpfNetMaps(final Context context, final INetd netd, @NonNull final InterfaceTracker
+            interfaceTracker) {
+        this(context, netd, new Dependencies(), interfaceTracker);
     }
 
     @VisibleForTesting
-    public BpfNetMaps(final Context context, final INetd netd, final Dependencies deps) {
+    public BpfNetMaps(final Context context, final INetd netd, final Dependencies deps,
+            @NonNull final  InterfaceTracker interfaceTracker) {
+        Objects.requireNonNull(interfaceTracker);
         if (SdkLevel.isAtLeastT()) {
             ensureInitialized(context);
         }
         mNetd = netd;
         mDeps = deps;
+        mInterfaceTracker = interfaceTracker;
     }
 
     private void maybeThrow(final int err, final String msg) {
@@ -902,11 +911,16 @@
      * @param isAllowed is the local network call allowed or blocked.
      */
     @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
-    public void addLocalNetAccess(final int lpmBitlen, final String iface,
+    public void addLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
             final InetAddress address, final int protocol, final int remotePort,
             final boolean isAllowed) {
         throwIfPre25Q2("addLocalNetAccess is not available on pre-B devices");
-        final int ifIndex = mDeps.getIfIndex(iface);
+        final int ifIndex;
+        if (iface == null) {
+            ifIndex = 0;
+        } else {
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
         if (ifIndex == 0) {
             Log.e(TAG, "Failed to get if index, skip addLocalNetAccess for " + address
                     + "(" + iface + ")");
@@ -932,10 +946,15 @@
      * @param remotePort src/dst port for ingress/egress
      */
     @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
-    public void removeLocalNetAccess(final int lpmBitlen, final String iface,
+    public void removeLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
             final InetAddress address, final int protocol, final int remotePort) {
         throwIfPre25Q2("removeLocalNetAccess is not available on pre-B devices");
-        final int ifIndex = mDeps.getIfIndex(iface);
+        final int ifIndex;
+        if (iface == null) {
+            ifIndex = 0;
+        } else {
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
         if (ifIndex == 0) {
             Log.e(TAG, "Failed to get if index, skip removeLocalNetAccess for " + address
                     + "(" + iface + ")");
@@ -963,10 +982,15 @@
      * is not local network or if configuration is allowed like local dns servers.
      */
     @RequiresApi(Build.VERSION_CODES.CUR_DEVELOPMENT)
-    public boolean getLocalNetAccess(final int lpmBitlen, final String iface,
+    public boolean getLocalNetAccess(final int lpmBitlen, @Nullable final String iface,
             final InetAddress address, final int protocol, final int remotePort) {
         throwIfPre25Q2("getLocalNetAccess is not available on pre-B devices");
-        final int ifIndex = mDeps.getIfIndex(iface);
+        final int ifIndex;
+        if (iface == null) {
+            ifIndex = 0;
+        } else {
+            ifIndex = mInterfaceTracker.getInterfaceIndex(iface);
+        }
         if (ifIndex == 0) {
             Log.e(TAG, "Failed to get if index, returning default from getLocalNetAccess for "
                     + address + "(" + iface + ")");
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index adf593e..79dbe35 100644
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -365,6 +365,7 @@
 import com.android.server.connectivity.DnsManager.PrivateDnsValidationUpdate;
 import com.android.server.connectivity.DscpPolicyTracker;
 import com.android.server.connectivity.FullScore;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.server.connectivity.InvalidTagException;
 import com.android.server.connectivity.KeepaliveResourceUtil;
 import com.android.server.connectivity.KeepaliveTracker;
@@ -423,14 +424,14 @@
 import java.util.concurrent.CopyOnWriteArraySet;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiConsumer;
 import java.util.function.Consumer;
 
 /**
  * @hide
  */
-public class ConnectivityService extends IConnectivityManager.Stub
-        implements PendingIntent.OnFinished {
+public class ConnectivityService extends IConnectivityManager.Stub {
     private static final String TAG = ConnectivityService.class.getSimpleName();
 
     private static final String DIAG_ARG = "--diag";
@@ -577,6 +578,7 @@
     private final NetworkStatsManager mStatsManager;
     private final NetworkPolicyManager mPolicyManager;
     private final BpfNetMaps mBpfNetMaps;
+    private final InterfaceTracker mInterfaceTracker;
 
     /**
      * TestNetworkService (lazily) created upon first usage. Locked to prevent creation of multiple
@@ -1662,8 +1664,17 @@
          * @param netd a netd binder
          * @return BpfNetMaps implementation.
          */
-        public BpfNetMaps getBpfNetMaps(Context context, INetd netd) {
-            return new BpfNetMaps(context, netd);
+        public BpfNetMaps getBpfNetMaps(Context context, INetd netd,
+                InterfaceTracker interfaceTracker) {
+            return new BpfNetMaps(context, netd, interfaceTracker);
+        }
+
+        /**
+         * Get the InterfaceTracker implementation to use in ConnectivityService.
+         * @return InterfaceTracker implementation.
+         */
+        public InterfaceTracker getInterfaceTracker(Context context) {
+            return new InterfaceTracker(context);
         }
 
         /**
@@ -1886,7 +1897,8 @@
         mWakeUpMask = mask;
 
         mNetd = netd;
-        mBpfNetMaps = mDeps.getBpfNetMaps(mContext, netd);
+        mInterfaceTracker = mDeps.getInterfaceTracker(mContext);
+        mBpfNetMaps = mDeps.getBpfNetMaps(mContext, netd, mInterfaceTracker);
         mHandlerThread = mDeps.makeHandlerThread("ConnectivityServiceThread");
         mPermissionMonitorDeps = mPermDeps;
         mPermissionMonitor =
@@ -5405,12 +5417,12 @@
     }
 
     @VisibleForTesting
-    protected static boolean shouldCreateNetworksImmediately() {
+    protected static boolean shouldCreateNetworksImmediately(@NonNull NetworkCapabilities caps) {
         // The feature of creating the networks immediately was slated for U, but race conditions
         // detected late required this was flagged off.
         // TODO : enable this in a Mainline update or in V, and re-enable the test for this
         // in NetworkAgentTest.
-        return false;
+        return caps.hasCapability(NET_CAPABILITY_LOCAL_NETWORK);
     }
 
     private static boolean shouldCreateNativeNetwork(@NonNull NetworkAgentInfo nai,
@@ -5419,12 +5431,12 @@
         if (state == NetworkInfo.State.CONNECTED) return true;
         if (state != NetworkInfo.State.CONNECTING) {
             // TODO: throw if no WTFs are observed in the field.
-            if (shouldCreateNetworksImmediately()) {
+            if (shouldCreateNetworksImmediately(nai.getCapsNoCopy())) {
                 Log.wtf(TAG, "Uncreated network in invalid state: " + state);
             }
             return false;
         }
-        return nai.isVPN() || shouldCreateNetworksImmediately();
+        return nai.isVPN() || shouldCreateNetworksImmediately(nai.getCapsNoCopy());
     }
 
     private static boolean shouldDestroyNativeNetwork(@NonNull NetworkAgentInfo nai) {
@@ -5823,7 +5835,7 @@
             }
 
             if (shouldTrackUidsForBlockedStatusCallbacks()
-                    && isAppRequest(nri)
+                    && nri.mMessenger != null
                     && !nri.mUidTrackedForBlockedStatus) {
                 Log.wtf(TAG, "Registered nri is not tracked for sending blocked status: " + nri);
             }
@@ -7482,7 +7494,7 @@
 
     private boolean isLegacyLockdownNai(NetworkAgentInfo nai) {
         return mLockdownEnabled
-                && getVpnType(nai) == VpnManager.TYPE_VPN_LEGACY
+                && isLegacyVpn(nai)
                 && nai.networkCapabilities.appliesToUid(Process.FIRST_APPLICATION_UID);
     }
 
@@ -9605,8 +9617,6 @@
 
         updateIngressToVpnAddressFiltering(newLp, oldLp, networkAgent);
 
-        updateLocalNetworkAddresses(newLp, oldLp);
-
         updateMtu(newLp, oldLp);
         // TODO - figure out what to do for clat
 //        for (LinkProperties lp : newLp.getStackedLinks()) {
@@ -9769,16 +9779,23 @@
                     wakeupModifyInterface(iface, nai, true);
                     mDeps.reportNetworkInterfaceForTransports(mContext, iface,
                             nai.networkCapabilities.getTransportTypes());
+                    mInterfaceTracker.addInterface(iface);
                 } catch (Exception e) {
                     logw("Exception adding interface: " + e);
                 }
             }
         }
+
+        // The local network addresses needs to be updated before interfaces are removed because
+        // modifying bpf map local_net_access requires mapping interface name to index.
+        updateLocalNetworkAddresses(newLp, oldLp);
+
         for (final String iface : interfaceDiff.removed) {
             try {
                 if (DBG) log("Removing iface " + iface + " from network " + netId);
                 wakeupModifyInterface(iface, nai, false);
                 mRoutingCoordinatorService.removeInterfaceFromNetwork(netId, iface);
+                mInterfaceTracker.removeInterface(iface);
             } catch (Exception e) {
                 loge("Exception removing interface: " + e);
             }
@@ -9810,8 +9827,8 @@
         }
 
         // The both list contain current link properties + stacked links for new and old LP.
-        List<LinkProperties> newLinkProperties = new ArrayList<>();
-        List<LinkProperties> oldLinkProperties = new ArrayList<>();
+        final List<LinkProperties> newLinkProperties = new ArrayList<>();
+        final List<LinkProperties> oldLinkProperties = new ArrayList<>();
 
         if (newLp != null) {
             newLinkProperties.add(newLp);
@@ -9824,13 +9841,13 @@
 
         // map contains interface name to list of local network prefixes added because of change
         // in link properties
-        Map<String, List<IpPrefix>> prefixesAddedForInterface = new ArrayMap<>();
+        final Map<String, List<IpPrefix>> prefixesAddedForInterface = new ArrayMap<>();
 
         final CompareResult<LinkProperties> linkPropertiesDiff = new CompareResult<>(
                 oldLinkProperties, newLinkProperties);
 
         for (LinkProperties linkProperty : linkPropertiesDiff.added) {
-            List<IpPrefix> unicastLocalPrefixesToBeAdded = new ArrayList<>();
+            final List<IpPrefix> unicastLocalPrefixesToBeAdded = new ArrayList<>();
             for (LinkAddress linkAddress : linkProperty.getLinkAddresses()) {
                 unicastLocalPrefixesToBeAdded.addAll(
                         getLocalNetworkPrefixesForAddress(linkAddress));
@@ -9838,7 +9855,7 @@
             addLocalAddressesToBpfMap(linkProperty.getInterfaceName(),
                     unicastLocalPrefixesToBeAdded, linkProperty);
 
-            // adding iterface name -> ip prefixes that we added to map
+            // populating interface name -> ip prefixes which were added to local_net_access map.
             if (!prefixesAddedForInterface.containsKey(linkProperty.getInterfaceName())) {
                 prefixesAddedForInterface.put(linkProperty.getInterfaceName(), new ArrayList<>());
             }
@@ -9847,9 +9864,9 @@
         }
 
         for (LinkProperties linkProperty : linkPropertiesDiff.removed) {
-            List<IpPrefix> unicastLocalPrefixesToBeRemoved = new ArrayList<>();
-            List<IpPrefix> unicastLocalPrefixesAdded = prefixesAddedForInterface.getOrDefault(
-                    linkProperty.getInterfaceName(), new ArrayList<>());
+            final List<IpPrefix> unicastLocalPrefixesToBeRemoved = new ArrayList<>();
+            final List<IpPrefix> unicastLocalPrefixesAdded = prefixesAddedForInterface.getOrDefault(
+                    linkProperty.getInterfaceName(), Collections.emptyList());
 
             for (LinkAddress linkAddress : linkProperty.getLinkAddresses()) {
                 unicastLocalPrefixesToBeRemoved.addAll(
@@ -9857,8 +9874,8 @@
             }
 
             // This is to ensure if 10.0.10.0/24 was added and 10.0.11.0/24 was removed both will
-            // still populate the same prefix of 10.0.0.0/8, which mean we should not remove the
-            // prefix because of removal of 10.0.11.0/24
+            // still populate the same prefix of 10.0.0.0/8, which mean 10.0.0.0/8 should not be
+            // removed due to removal of 10.0.11.0/24
             unicastLocalPrefixesToBeRemoved.removeAll(unicastLocalPrefixesAdded);
 
             removeLocalAddressesFromBpfMap(linkProperty.getInterfaceName(),
@@ -10116,8 +10133,8 @@
      * interfaces.
      * Ingress discard rule is added to the address iff
      *   1. The address is not a link local address
-     *   2. The address is used by a single interface of VPN whose VPN type is not TYPE_VPN_LEGACY
-     *      or TYPE_VPN_OEM and the address is not used by any other interfaces even non-VPN ones
+     *   2. The address is used by a single interface of VPN whose VPN type is not LEGACY, OEM or
+     *      OEM_LEGACY and the address is not used by any other interfaces even non-VPN ones
      * Ingress discard rule is not be added to TYPE_VPN_LEGACY or TYPE_VPN_OEM VPN since these VPNs
      * might need to receive packet to VPN address via non-VPN interface.
      * This method can be called during network disconnects, when nai has already been removed from
@@ -10155,9 +10172,7 @@
         final Set<Pair<InetAddress, String>> ingressDiscardRules = new ArraySet<>();
         for (final NetworkAgentInfo agent : nais) {
             final int vpnType = getVpnType(agent);
-            if (!agent.isVPN() || agent.isDestroyed()
-                    || vpnType == VpnManager.TYPE_VPN_LEGACY
-                    || vpnType == VpnManager.TYPE_VPN_OEM) {
+            if (!agent.isVPN() || agent.isDestroyed() || !vpnSupportsInterfaceFiltering(agent)) {
                 continue;
             }
             final LinkProperties agentLp = (nai == agent) ? lp : agent.linkProperties;
@@ -10936,10 +10951,42 @@
         // else not handled
     }
 
+    /**
+     * A small class to manage releasing a lock exactly once even if releaseLock is called
+     * multiple times. See b/390043283
+     * PendingIntent#send throws CanceledException in various cases. In some of them it will
+     * still call onSendFinished, in others it won't and the client can't know. This class
+     * keeps a ref to the wakelock that it releases exactly once, thanks to Atomics semantics.
+     */
+    private class WakeLockOnFinishedReceiver implements PendingIntent.OnFinished {
+        private final AtomicReference<PowerManager.WakeLock> mLock;
+        WakeLockOnFinishedReceiver(@NonNull final PowerManager.WakeLock lock) {
+            mLock = new AtomicReference<>(lock);
+            lock.acquire();
+        }
+
+        public void releaseLock() {
+            final PowerManager.WakeLock lock = mLock.getAndSet(null);
+            if (null != lock) lock.release();
+        }
+
+        @Override
+        public void onSendFinished(PendingIntent pendingIntent, Intent intent, int resultCode,
+                String resultData, Bundle resultExtras) {
+            if (DBG) log("Finished sending " + pendingIntent);
+            releaseLock();
+            releasePendingNetworkRequestWithDelay(pendingIntent);
+        }
+    }
+
     // TODO(b/193460475): Remove when tooling supports SystemApi to public API.
     @SuppressLint("NewApi")
     private void sendIntent(PendingIntent pendingIntent, Intent intent) {
-        mPendingIntentWakeLock.acquire();
+        // Since the receiver will take the lock exactly once and release it exactly once, it
+        // is safe to pass the same wakelock to all receivers and avoid creating a new lock
+        // every time.
+        final WakeLockOnFinishedReceiver receiver =
+                new WakeLockOnFinishedReceiver(mPendingIntentWakeLock);
         try {
             if (DBG) log("Sending " + pendingIntent);
             final BroadcastOptions options = BroadcastOptions.makeBasic();
@@ -10948,25 +10995,14 @@
                 // utilizing the PendingIntent as a backdoor to do this.
                 options.setPendingIntentBackgroundActivityLaunchAllowed(false);
             }
-            pendingIntent.send(mContext, 0, intent, this /* onFinished */, null /* Handler */,
+            pendingIntent.send(mContext, 0, intent, receiver, null /* Handler */,
                     null /* requiredPermission */,
                     mDeps.isAtLeastT() ? options.toBundle() : null);
         } catch (PendingIntent.CanceledException e) {
             if (DBG) log(pendingIntent + " was not sent, it had been canceled.");
-            mPendingIntentWakeLock.release();
+            receiver.releaseLock();
             releasePendingNetworkRequest(pendingIntent);
         }
-        // ...otherwise, mPendingIntentWakeLock.release() gets called by onSendFinished()
-    }
-
-    @Override
-    public void onSendFinished(PendingIntent pendingIntent, Intent intent, int resultCode,
-            String resultData, Bundle resultExtras) {
-        if (DBG) log("Finished sending " + pendingIntent);
-        mPendingIntentWakeLock.release();
-        // Release with a delay so the receiving client has an opportunity to put in its
-        // own request.
-        releasePendingNetworkRequestWithDelay(pendingIntent);
     }
 
     @Nullable
@@ -12027,7 +12063,7 @@
             // interfaces and routing rules have been added, DNS servers programmed, etc.
             // For VPNs, this must be done before the capabilities are updated, because as soon as
             // that happens, UIDs are routed to the network.
-            if (shouldCreateNetworksImmediately()) {
+            if (shouldCreateNetworksImmediately(networkAgent.getCapsNoCopy())) {
                 applyInitialLinkProperties(networkAgent);
             }
 
@@ -12052,7 +12088,7 @@
             networkAgent.getAndSetNetworkCapabilities(networkAgent.networkCapabilities);
 
             handlePerNetworkPrivateDnsConfig(networkAgent, mDnsManager.getPrivateDnsConfig());
-            if (!shouldCreateNetworksImmediately()) {
+            if (!shouldCreateNetworksImmediately(networkAgent.getCapsNoCopy())) {
                 applyInitialLinkProperties(networkAgent);
             } else {
                 // The network was created when the agent registered, and the LinkProperties are
@@ -12779,6 +12815,23 @@
         return ((VpnTransportInfo) ti).getType();
     }
 
+    private boolean isVpnServiceVpn(NetworkAgentInfo nai) {
+        final int vpnType = getVpnType(nai);
+        return vpnType == VpnManager.TYPE_VPN_SERVICE || vpnType == VpnManager.TYPE_VPN_OEM_SERVICE;
+    }
+
+    private boolean isLegacyVpn(NetworkAgentInfo nai) {
+        final int vpnType = getVpnType(nai);
+        return vpnType == VpnManager.TYPE_VPN_LEGACY || vpnType == VpnManager.TYPE_VPN_OEM_LEGACY;
+    }
+
+    private boolean vpnSupportsInterfaceFiltering(NetworkAgentInfo vpn) {
+        final int vpnType = getVpnType(vpn);
+        return vpnType != VpnManager.TYPE_VPN_LEGACY
+                && vpnType != VpnManager.TYPE_VPN_OEM
+                && vpnType != VpnManager.TYPE_VPN_OEM_LEGACY;
+    }
+
     private void maybeUpdateWifiRoamTimestamp(@NonNull NetworkAgentInfo nai,
             @NonNull NetworkCapabilities nc) {
         final TransportInfo prevInfo = nai.networkCapabilities.getTransportInfo();
@@ -12812,7 +12865,7 @@
         if (hasNetworkStackPermission()) return uid;
 
         final NetworkAgentInfo vpn = getVpnForUid(uid);
-        if (vpn == null || getVpnType(vpn) != VpnManager.TYPE_VPN_SERVICE
+        if (vpn == null || !isVpnServiceVpn(vpn)
                 || vpn.networkCapabilities.getOwnerUid() != mDeps.getCallingUid()) {
             return INVALID_UID;
         }
diff --git a/service/src/com/android/server/connectivity/InterfaceTracker.java b/service/src/com/android/server/connectivity/InterfaceTracker.java
new file mode 100644
index 0000000..0b4abeb
--- /dev/null
+++ b/service/src/com/android/server/connectivity/InterfaceTracker.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.system.Os;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.BpfNetMaps;
+
+import java.util.Map;
+
+/**
+ * InterfaceTracker is responsible for providing interface mapping and tracking.
+ * @hide
+ */
+public class InterfaceTracker {
+    static {
+        if (BpfNetMaps.isAtLeast25Q2()) {
+            System.loadLibrary("service-connectivity");
+        }
+    }
+    private static final String TAG = "InterfaceTracker";
+    private final Dependencies mDeps;
+    private final Map<String, Integer> mInterfaceMap;
+
+    public InterfaceTracker(final Context context) {
+        this(context, new Dependencies());
+    }
+
+    @VisibleForTesting
+    public InterfaceTracker(final Context context, final Dependencies deps) {
+        this.mInterfaceMap = new ArrayMap<>();
+        this.mDeps = deps;
+    }
+
+    /**
+     * To add interface to tracking
+     * @param interfaceName name of interface added.
+     */
+    public void addInterface(@Nullable final String interfaceName) {
+        final int interfaceIndex;
+        if (interfaceName == null) {
+            interfaceIndex = 0;
+        } else {
+            interfaceIndex = mDeps.getIfIndex(interfaceName);
+        }
+        if (interfaceIndex == 0) {
+            Log.e(TAG, "Failed to get interface index for " + interfaceName);
+            return;
+        }
+        synchronized (mInterfaceMap) {
+            mInterfaceMap.put(interfaceName, interfaceIndex);
+        }
+    }
+
+    /**
+     * To remove interface from tracking
+     * @param interfaceName name of interface removed.
+     * @return true if the value was present and now removed.
+     */
+    public boolean removeInterface(@Nullable final String interfaceName) {
+        if (interfaceName == null) return false;
+        synchronized (mInterfaceMap) {
+            return mInterfaceMap.remove(interfaceName) != null;
+        }
+    }
+
+    /**
+     * Get interface index from interface name.
+     * @param interfaceName name of interface
+     * @return interface index for given interface name or 0 if interface is not found.
+     */
+    public int getInterfaceIndex(@Nullable final String interfaceName) {
+        final int interfaceIndex;
+        if (interfaceName != null) {
+            synchronized (mInterfaceMap) {
+                interfaceIndex = mInterfaceMap.getOrDefault(interfaceName, 0);
+            }
+        } else {
+            interfaceIndex = 0;
+        }
+        return interfaceIndex;
+    }
+
+    /**
+     * Dependencies of InterfaceTracker, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /**
+         * Get interface index.
+         */
+        public int getIfIndex(final String ifName) {
+            return Os.if_nametoindex(ifName);
+        }
+
+        /**
+         * Get interface name
+         */
+        public String getIfName(final int ifIndex) {
+            return Os.if_indextoname(ifIndex);
+        }
+
+    }
+}
diff --git a/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java b/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
index af4aee5..c4de80f 100644
--- a/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
+++ b/service/src/com/android/server/connectivity/MulticastRoutingCoordinatorService.java
@@ -21,6 +21,7 @@
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
 import static android.system.OsConstants.AF_INET6;
 import static android.system.OsConstants.EADDRINUSE;
+import static android.system.OsConstants.EADDRNOTAVAIL;
 import static android.system.OsConstants.IPPROTO_ICMPV6;
 import static android.system.OsConstants.IPPROTO_IPV6;
 import static android.system.OsConstants.SOCK_CLOEXEC;
@@ -258,6 +259,10 @@
             mDependencies.setsockoptMrt6DelMif(mMulticastRoutingFd, virtualIndex);
             Log.d(TAG, "Removed mifi " + virtualIndex + " from MIF");
         } catch (ErrnoException e) {
+            if (e.errno == EADDRNOTAVAIL) {
+                Log.w(TAG, "multicast virtual interface " + virtualIndex + " already removed", e);
+                return;
+            }
             Log.e(TAG, "failed to remove multicast virtual interface" + virtualIndex, e);
         }
     }
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 0eab6e7..abfc447 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -81,17 +81,6 @@
     },
 }
 
-java_defaults {
-    name: "lib_mockito_extended",
-    static_libs: [
-        "mockito-target-extended-minus-junit4",
-    ],
-    jni_libs: [
-        "libdexmakerjvmtiagent",
-        "libstaticjvmtiagent",
-    ],
-}
-
 java_library {
     name: "net-utils-dnspacket-common",
     srcs: [
diff --git a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
index 8b2fe58..4af516d 100644
--- a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
+++ b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
@@ -148,25 +148,6 @@
         netd.tetherStartWithConfiguration(config);
     }
 
-    /** Setup interface for tethering. */
-    public static void tetherInterface(final INetd netd, int netId, final String iface,
-            final IpPrefix dest) throws RemoteException, ServiceSpecificException {
-        tetherInterface(netd, netId, iface, dest, 20 /* maxAttempts */, 50 /* pollingIntervalMs */);
-    }
-
-    /** Setup interface with configurable retries for tethering. */
-    public static void tetherInterface(final INetd netd, int netId, final String iface,
-            final IpPrefix dest, int maxAttempts, int pollingIntervalMs)
-            throws RemoteException, ServiceSpecificException {
-        netd.tetherInterfaceAdd(iface);
-        networkAddInterface(netd, netId, iface, maxAttempts, pollingIntervalMs);
-        // Activate a route to dest and IPv6 link local.
-        modifyRoute(netd, ModifyOperation.ADD, netId,
-                new RouteInfo(dest, null, iface, RTN_UNICAST));
-        modifyRoute(netd, ModifyOperation.ADD, netId,
-                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
-    }
-
     /**
      * Retry Netd#networkAddInterface for EBUSY error code.
      * If the same interface (e.g., wlan0) is in client mode and then switches to tethered mode.
@@ -174,7 +155,7 @@
      * in use in netd because the ConnectivityService thread hasn't processed the disconnect yet.
      * See b/158269544 for detail.
      */
-    private static void networkAddInterface(final INetd netd, int netId, final String iface,
+    public static void networkAddInterface(final INetd netd, int netId, final String iface,
             int maxAttempts, int pollingIntervalMs)
             throws ServiceSpecificException, RemoteException {
         for (int i = 1; i <= maxAttempts; i++) {
@@ -193,16 +174,6 @@
         }
     }
 
-    /** Reset interface for tethering. */
-    public static void untetherInterface(final INetd netd, int netId, String iface)
-            throws RemoteException, ServiceSpecificException {
-        try {
-            netd.tetherInterfaceRemove(iface);
-        } finally {
-            netd.networkRemoveInterface(netId, iface);
-        }
-    }
-
     /** Add |routes| to the given network. */
     public static void addRoutesToNetwork(final INetd netd, int netId, final String iface,
             final List<RouteInfo> routes) {
diff --git a/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java b/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
index c2fbb56..0241d0a 100644
--- a/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
+++ b/staticlibs/client-libs/tests/unit/src/com/android/net/module/util/NetdUtilsTest.java
@@ -24,13 +24,9 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -39,7 +35,6 @@
 
 import android.net.INetd;
 import android.net.InterfaceConfigurationParcel;
-import android.net.IpPrefix;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
 
@@ -61,7 +56,6 @@
     @Mock private INetd mNetd;
 
     private static final String IFACE = "TEST_IFACE";
-    private static final IpPrefix TEST_IPPREFIX = new IpPrefix("192.168.42.1/24");
     private static final int TEST_NET_ID = 123;
 
     @Before
@@ -150,20 +144,21 @@
     }
 
     @Test
-    public void testTetherInterfaceSuccessful() throws Exception {
+    public void testNetworkAddInterfaceSuccessful() throws Exception {
         // Expect #networkAddInterface successful at first tries.
-        verifyTetherInterfaceSucceeds(1);
+        verifyNetworkAddInterfaceSucceeds(1);
 
         // Expect #networkAddInterface successful after 10 tries.
-        verifyTetherInterfaceSucceeds(10);
+        verifyNetworkAddInterfaceSucceeds(10);
     }
 
-    private void runTetherInterfaceWithServiceSpecificException(int expectedTries,
+    private void runNetworkAddInterfaceWithServiceSpecificException(int expectedTries,
             int expectedCode) throws Exception {
         setNetworkAddInterfaceOutcome(new ServiceSpecificException(expectedCode), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, TEST_NET_ID, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                    20 /* maxAttempts */, 0 /* pollingIntervalMs */);
             fail("Expect throw ServiceSpecificException");
         } catch (ServiceSpecificException e) {
             assertEquals(e.errorCode, expectedCode);
@@ -173,11 +168,12 @@
         reset(mNetd);
     }
 
-    private void runTetherInterfaceWithRemoteException(int expectedTries) throws Exception {
+    private void runNetworkAddInterfaceWithRemoteException(int expectedTries) throws Exception {
         setNetworkAddInterfaceOutcome(new RemoteException(), expectedTries);
 
         try {
-            NetdUtils.tetherInterface(mNetd, TEST_NET_ID, IFACE, TEST_IPPREFIX, 20, 0);
+            NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                    20 /* maxAttempts */, 0 /* pollingIntervalMs */);
             fail("Expect throw RemoteException");
         } catch (RemoteException e) { }
 
@@ -186,41 +182,37 @@
     }
 
     private void verifyNetworkAddInterfaceFails(int expectedTries) throws Exception {
-        verify(mNetd).tetherInterfaceAdd(IFACE);
         verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
-        verify(mNetd, never()).networkAddRoute(anyInt(), anyString(), any(), any());
-
         verifyNoMoreInteractions(mNetd);
     }
 
-    private void verifyTetherInterfaceSucceeds(int expectedTries) throws Exception {
+    private void verifyNetworkAddInterfaceSucceeds(int expectedTries) throws Exception {
         setNetworkAddInterfaceOutcome(null, expectedTries);
 
-        NetdUtils.tetherInterface(mNetd, TEST_NET_ID, IFACE, TEST_IPPREFIX);
-        verify(mNetd).tetherInterfaceAdd(IFACE);
+        NetdUtils.networkAddInterface(mNetd, TEST_NET_ID, IFACE,
+                20 /* maxAttempts */, 0 /* pollingIntervalMs */);
         verify(mNetd, times(expectedTries)).networkAddInterface(TEST_NET_ID, IFACE);
-        verify(mNetd, times(2)).networkAddRoute(eq(TEST_NET_ID), eq(IFACE), any(), any());
         verifyNoMoreInteractions(mNetd);
         reset(mNetd);
     }
 
     @Test
-    public void testTetherInterfaceFailOnNetworkAddInterface() throws Exception {
+    public void testFailOnNetworkAddInterface() throws Exception {
         // Test throwing ServiceSpecificException with EBUSY failure.
-        runTetherInterfaceWithServiceSpecificException(20, EBUSY);
+        runNetworkAddInterfaceWithServiceSpecificException(20, EBUSY);
 
         // Test throwing ServiceSpecificException with unexpectedError.
         final int unexpectedError = 999;
-        runTetherInterfaceWithServiceSpecificException(1, unexpectedError);
+        runNetworkAddInterfaceWithServiceSpecificException(1, unexpectedError);
 
         // Test throwing ServiceSpecificException with unexpectedError after 7 tries.
-        runTetherInterfaceWithServiceSpecificException(7, unexpectedError);
+        runNetworkAddInterfaceWithServiceSpecificException(7, unexpectedError);
 
         // Test throwing RemoteException.
-        runTetherInterfaceWithRemoteException(1);
+        runNetworkAddInterfaceWithRemoteException(1);
 
         // Test throwing RemoteException after 3 tries.
-        runTetherInterfaceWithRemoteException(3);
+        runNetworkAddInterfaceWithRemoteException(3);
     }
 
     @Test
diff --git a/staticlibs/device/com/android/net/module/util/BpfDump.java b/staticlibs/device/com/android/net/module/util/BpfDump.java
index 4227194..d79f52e 100644
--- a/staticlibs/device/com/android/net/module/util/BpfDump.java
+++ b/staticlibs/device/com/android/net/module/util/BpfDump.java
@@ -23,6 +23,7 @@
 import android.util.Pair;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
 
 import java.io.PrintWriter;
 import java.nio.ByteBuffer;
@@ -132,17 +133,35 @@
         }
     }
 
+    public static class Dependencies {
+        /**
+         * Call {@link Os#access}
+         */
+        public boolean access(String path, int mode) throws ErrnoException {
+            return Os.access(path, mode);
+        }
+    }
+
     /**
      * Dump the BpfMap status
      */
     public static <K extends Struct, V extends Struct> void dumpMapStatus(IBpfMap<K, V> map,
             PrintWriter pw, String mapName, String path) {
+        dumpMapStatus(map, pw, mapName, path, new Dependencies());
+    }
+
+    /**
+     * Dump the BpfMap status. Only test should use this method directly.
+     */
+    @VisibleForTesting
+    public static <K extends Struct, V extends Struct> void dumpMapStatus(IBpfMap<K, V> map,
+            PrintWriter pw, String mapName, String path, Dependencies deps) {
         if (map != null) {
             pw.println(mapName + ": OK");
             return;
         }
         try {
-            Os.access(path, R_OK);
+            deps.access(path, R_OK);
             pw.println(mapName + ": NULL(map is pinned to " + path + ")");
         } catch (ErrnoException e) {
             pw.println(mapName + ": NULL(map is not pinned to " + path + ": "
diff --git a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index bb95585..2ce5b86 100644
--- a/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -33,12 +33,14 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
+import android.os.Build;
 import android.os.RemoteException;
 import android.util.ArrayMap;
 
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
 
 import java.io.PrintWriter;
 import java.net.Inet4Address;
@@ -67,9 +69,6 @@
     // WARNING: Keep in sync with chooseDownstreamAddress
     public static final int PREFIX_LENGTH = 24;
 
-    public static final String TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION =
-            "tether_force_random_prefix_base_selection";
-
     // Upstream monitor would be stopped when tethering is down. When tethering restart, downstream
     // address may be requested before coordinator get current upstream notification. To ensure
     // coordinator do not select conflict downstream prefix, mUpstreamPrefixMap would not be cleared
@@ -258,8 +257,15 @@
         return null;
     }
 
+    // TODO: Remove this method when SdkLevel.isAtLeastB() is fixed, aosp is at sdk level 36 or use
+    //  NetworkStackUtils.isAtLeast25Q2 when it is moved to a static lib.
+    public static boolean isAtLeast25Q2() {
+        return SdkLevel.isAtLeastB()  || (SdkLevel.isAtLeastV()
+                && "Baklava".equals(Build.VERSION.CODENAME));
+    }
+
     private int getRandomPrefixIndex() {
-        if (!mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)) return 0;
+        if (!isAtLeast25Q2()) return 0;
 
         final int random = getRandomInt() & 0xffffff;
         // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
diff --git a/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
index c8fdf72..2d95223 100644
--- a/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
+++ b/staticlibs/device/com/android/net/module/util/RealtimeScheduler.java
@@ -227,6 +227,13 @@
         return enqueueTask(new MessageTask(msg, SystemClock.elapsedRealtime() + delayMs), delayMs);
     }
 
+    private static boolean isMessageTask(Task task, int what) {
+        if (task instanceof MessageTask && ((MessageTask) task).mMessage.what == what) {
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Remove a scheduled message.
      *
@@ -234,8 +241,24 @@
      */
     public void removeDelayedMessage(int what) {
         ensureRunningOnCorrectThread();
-        mTaskQueue.removeIf(task -> task instanceof MessageTask
-                && ((MessageTask) task).mMessage.what == what);
+        mTaskQueue.removeIf(task -> isMessageTask(task, what));
+    }
+
+    /**
+     * Check if there is a scheduled message.
+     *
+     * @param what the message to be checked
+     * @return true if there is a target message, false otherwise.
+     */
+    public boolean hasDelayedMessage(int what) {
+        ensureRunningOnCorrectThread();
+
+        for (Task task : mTaskQueue) {
+            if (isMessageTask(task, what)) {
+                return true;
+            }
+        }
+        return false;
     }
 
     /**
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
index c19a124..5d49fa3 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
@@ -22,7 +22,6 @@
 import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTM_GETLINK;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
-import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST;
 import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST_ACK;
 
 import android.net.MacAddress;
@@ -307,7 +306,7 @@
         }
 
         return RtNetlinkLinkMessage.build(
-                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST, sequenceNumber),
+                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST_ACK, sequenceNumber),
                 new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
                 DEFAULT_MTU, null, null);
     }
diff --git a/staticlibs/netd/Android.bp b/staticlibs/netd/Android.bp
index 03f5f06..9222b17 100644
--- a/staticlibs/netd/Android.bp
+++ b/staticlibs/netd/Android.bp
@@ -22,7 +22,7 @@
     sdk_version: "system_current",
     min_sdk_version: "30",
     static_libs: [
-        "netd_aidl_interface-V16-java",
+        "netd_aidl_interface-V17-java",
     ],
     apex_available: [
         "//apex_available:platform", // used from services.net
@@ -45,7 +45,7 @@
 cc_library_static {
     name: "netd_aidl_interface-lateststable-ndk",
     whole_static_libs: [
-        "netd_aidl_interface-V16-ndk",
+        "netd_aidl_interface-V17-ndk",
     ],
     apex_available: [
         "com.android.resolv",
@@ -56,12 +56,12 @@
 
 cc_defaults {
     name: "netd_aidl_interface_lateststable_cpp_static",
-    static_libs: ["netd_aidl_interface-V16-cpp"],
+    static_libs: ["netd_aidl_interface-V17-cpp"],
 }
 
 cc_defaults {
     name: "netd_aidl_interface_lateststable_cpp_shared",
-    shared_libs: ["netd_aidl_interface-V16-cpp"],
+    shared_libs: ["netd_aidl_interface-V17-cpp"],
 }
 
 aidl_interface {
@@ -175,6 +175,10 @@
             version: "16",
             imports: [],
         },
+        {
+            version: "17",
+            imports: [],
+        },
 
     ],
     frozen: true,
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash b/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash
new file mode 100644
index 0000000..f69c88b
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/.hash
@@ -0,0 +1 @@
+a786da2fe41bda627a8c1e63b99264a415e769c8
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl
new file mode 100644
index 0000000..8351b56
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetd.aidl
@@ -0,0 +1,272 @@
+/**
+ * Copyright (c) 2016, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetd {
+  boolean isAlive();
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  boolean firewallReplaceUidChain(in @utf8InCpp String chainName, boolean isAllowlist, in int[] uids);
+  boolean bandwidthEnableDataSaver(boolean enable);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreatePhysical(int netId, int permission);
+  /**
+   * @deprecated use networkCreate() instead.
+   */
+  void networkCreateVpn(int netId, boolean secure);
+  void networkDestroy(int netId);
+  void networkAddInterface(int netId, in @utf8InCpp String iface);
+  void networkRemoveInterface(int netId, in @utf8InCpp String iface);
+  void networkAddUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRemoveUidRanges(int netId, in android.net.UidRangeParcel[] uidRanges);
+  void networkRejectNonSecureVpn(boolean add, in android.net.UidRangeParcel[] uidRanges);
+  void socketDestroy(in android.net.UidRangeParcel[] uidRanges, in int[] exemptUids);
+  boolean tetherApplyDnsInterfaces();
+  android.net.TetherStatsParcel[] tetherGetStats();
+  void interfaceAddAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  void interfaceDelAddress(in @utf8InCpp String ifName, in @utf8InCpp String addrString, int prefixLength);
+  @utf8InCpp String getProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter);
+  void setProcSysNet(int ipversion, int which, in @utf8InCpp String ifname, in @utf8InCpp String parameter, in @utf8InCpp String value);
+  void ipSecSetEncapSocketOwner(in ParcelFileDescriptor socket, int newUid);
+  int ipSecAllocateSpi(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecAddSecurityAssociation(int transformId, int mode, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int underlyingNetId, int spi, int markValue, int markMask, in @utf8InCpp String authAlgo, in byte[] authKey, in int authTruncBits, in @utf8InCpp String cryptAlgo, in byte[] cryptKey, in int cryptTruncBits, in @utf8InCpp String aeadAlgo, in byte[] aeadKey, in int aeadIcvBits, int encapType, int encapLocalPort, int encapRemotePort, int interfaceId);
+  void ipSecDeleteSecurityAssociation(int transformId, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecApplyTransportModeTransform(in ParcelFileDescriptor socket, int transformId, int direction, in @utf8InCpp String sourceAddress, in @utf8InCpp String destinationAddress, int spi);
+  void ipSecRemoveTransportModeTransform(in ParcelFileDescriptor socket);
+  void ipSecAddSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecUpdateSecurityPolicy(int transformId, int selAddrFamily, int direction, in @utf8InCpp String tmplSrcAddress, in @utf8InCpp String tmplDstAddress, int spi, int markValue, int markMask, int interfaceId);
+  void ipSecDeleteSecurityPolicy(int transformId, int selAddrFamily, int direction, int markValue, int markMask, int interfaceId);
+  void ipSecAddTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecUpdateTunnelInterface(in @utf8InCpp String deviceName, in @utf8InCpp String localAddress, in @utf8InCpp String remoteAddress, int iKey, int oKey, int interfaceId);
+  void ipSecRemoveTunnelInterface(in @utf8InCpp String deviceName);
+  void wakeupAddInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void wakeupDelInterface(in @utf8InCpp String ifName, in @utf8InCpp String prefix, int mark, int mask);
+  void setIPv6AddrGenMode(in @utf8InCpp String ifName, int mode);
+  void idletimerAddInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void idletimerRemoveInterface(in @utf8InCpp String ifName, int timeout, in @utf8InCpp String classLabel);
+  void strictUidCleartextPenalty(int uid, int policyPenalty);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  @utf8InCpp String clatdStart(in @utf8InCpp String ifName, in @utf8InCpp String nat64Prefix);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The clatd control plane moved to the mainline module starting in T. See ClatCoordinator.
+   */
+  void clatdStop(in @utf8InCpp String ifName);
+  boolean ipfwdEnabled();
+  @utf8InCpp String[] ipfwdGetRequesterList();
+  void ipfwdEnableForwarding(in @utf8InCpp String requester);
+  void ipfwdDisableForwarding(in @utf8InCpp String requester);
+  void ipfwdAddInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void ipfwdRemoveInterfaceForward(in @utf8InCpp String fromIface, in @utf8InCpp String toIface);
+  void bandwidthSetInterfaceQuota(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceQuota(in @utf8InCpp String ifName);
+  void bandwidthSetInterfaceAlert(in @utf8InCpp String ifName, long bytes);
+  void bandwidthRemoveInterfaceAlert(in @utf8InCpp String ifName);
+  void bandwidthSetGlobalAlert(long bytes);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNaughtyApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthAddNiceApp(int uid);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void bandwidthRemoveNiceApp(int uid);
+  void tetherStart(in @utf8InCpp String[] dhcpRanges);
+  void tetherStop();
+  boolean tetherIsEnabled();
+  void tetherInterfaceAdd(in @utf8InCpp String ifName);
+  void tetherInterfaceRemove(in @utf8InCpp String ifName);
+  @utf8InCpp String[] tetherInterfaceList();
+  void tetherDnsSet(int netId, in @utf8InCpp String[] dnsAddrs);
+  @utf8InCpp String[] tetherDnsList();
+  void networkAddRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkRemoveRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop);
+  void networkAddLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  void networkRemoveLegacyRoute(int netId, in @utf8InCpp String ifName, in @utf8InCpp String destination, in @utf8InCpp String nextHop, int uid);
+  int networkGetDefault();
+  void networkSetDefault(int netId);
+  void networkClearDefault();
+  void networkSetPermissionForNetwork(int netId, int permission);
+  void networkSetPermissionForUser(int permission, in int[] uids);
+  void networkClearPermissionForUser(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSetNetPermForUids(int permission, in int[] uids);
+  void networkSetProtectAllow(int uid);
+  void networkSetProtectDeny(int uid);
+  boolean networkCanProtect(int uid);
+  void firewallSetFirewallType(int firewalltype);
+  void firewallSetInterfaceRule(in @utf8InCpp String ifName, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallSetUidRule(int childChain, int uid, int firewallRule);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallEnableChildChain(int childChain, boolean enable);
+  @utf8InCpp String[] interfaceGetList();
+  android.net.InterfaceConfigurationParcel interfaceGetCfg(in @utf8InCpp String ifName);
+  void interfaceSetCfg(in android.net.InterfaceConfigurationParcel cfg);
+  void interfaceSetIPv6PrivacyExtensions(in @utf8InCpp String ifName, boolean enable);
+  void interfaceClearAddrs(in @utf8InCpp String ifName);
+  void interfaceSetEnableIPv6(in @utf8InCpp String ifName, boolean enable);
+  void interfaceSetMtu(in @utf8InCpp String ifName, int mtu);
+  void tetherAddForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void tetherRemoveForward(in @utf8InCpp String intIface, in @utf8InCpp String extIface);
+  void setTcpRWmemorySize(in @utf8InCpp String rmemValues, in @utf8InCpp String wmemValues);
+  void registerUnsolicitedEventListener(android.net.INetdUnsolicitedEventListener listener);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallAddUidInterfaceRules(in @utf8InCpp String ifName, in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void firewallRemoveUidInterfaceRules(in int[] uids);
+  /**
+   * @deprecated unimplemented on T+.
+   */
+  void trafficSwapActiveStatsMap();
+  IBinder getOemNetd();
+  void tetherStartWithConfiguration(in android.net.TetherConfigParcel config);
+  android.net.MarkMaskParcel getFwmarkForNetwork(int netId);
+  void networkAddRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkUpdateRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  void networkRemoveRouteParcel(int netId, in android.net.RouteInfoParcel routeInfo);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleAdd(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadRuleRemove(in android.net.TetherOffloadRuleParcel rule);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel[] tetherOffloadGetStats();
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  void tetherOffloadSetInterfaceQuota(int ifIndex, long quotaBytes);
+  /**
+   * @deprecated This method has no effect and throws UnsupportedOperationException. The mainline module accesses the BPF map directly starting in S. See BpfCoordinator.
+   */
+  android.net.TetherStatsParcel tetherOffloadGetAndClearStats(int ifIndex);
+  void networkCreate(in android.net.NativeNetworkConfig config);
+  void networkAddUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void networkRemoveUidRangesParcel(in android.net.netd.aidl.NativeUidRangeConfig uidRangesConfig);
+  void ipSecMigrate(in android.net.IpSecMigrateInfoParcel migrateInfo);
+  void setNetworkAllowlist(in android.net.netd.aidl.NativeUidRangeConfig[] allowedNetworks);
+  void networkAllowBypassVpnOnNetwork(boolean allow, int uid, int netId);
+  const int IPV4 = 4;
+  const int IPV6 = 6;
+  const int CONF = 1;
+  const int NEIGH = 2;
+  const String IPSEC_INTERFACE_PREFIX = "ipsec";
+  const int IPV6_ADDR_GEN_MODE_EUI64 = 0;
+  const int IPV6_ADDR_GEN_MODE_NONE = 1;
+  const int IPV6_ADDR_GEN_MODE_STABLE_PRIVACY = 2;
+  const int IPV6_ADDR_GEN_MODE_RANDOM = 3;
+  const int IPV6_ADDR_GEN_MODE_DEFAULT = 0;
+  const int PENALTY_POLICY_ACCEPT = 1;
+  const int PENALTY_POLICY_LOG = 2;
+  const int PENALTY_POLICY_REJECT = 3;
+  const int CLAT_MARK = 0xdeadc1a7;
+  const int LOCAL_NET_ID = 99;
+  const int DUMMY_NET_ID = 51;
+  const int UNREACHABLE_NET_ID = 52;
+  const String NEXTHOP_NONE = "";
+  const String NEXTHOP_UNREACHABLE = "unreachable";
+  const String NEXTHOP_THROW = "throw";
+  const int PERMISSION_NONE = 0;
+  const int PERMISSION_NETWORK = 1;
+  const int PERMISSION_SYSTEM = 2;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int NO_PERMISSIONS = 0;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_INTERNET = 4;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UPDATE_DEVICE_STATS = 8;
+  /**
+   * @deprecated usage is internal to module.
+   */
+  const int PERMISSION_UNINSTALLED = (-1) /* -1 */;
+  /**
+   * @deprecated use FIREWALL_ALLOWLIST.
+   */
+  const int FIREWALL_WHITELIST = 0;
+  const int FIREWALL_ALLOWLIST = 0;
+  /**
+   * @deprecated use FIREWALL_DENYLIST.
+   */
+  const int FIREWALL_BLACKLIST = 1;
+  const int FIREWALL_DENYLIST = 1;
+  const int FIREWALL_RULE_ALLOW = 1;
+  const int FIREWALL_RULE_DENY = 2;
+  const int FIREWALL_CHAIN_NONE = 0;
+  const int FIREWALL_CHAIN_DOZABLE = 1;
+  const int FIREWALL_CHAIN_STANDBY = 2;
+  const int FIREWALL_CHAIN_POWERSAVE = 3;
+  const int FIREWALL_CHAIN_RESTRICTED = 4;
+  const String IF_STATE_UP = "up";
+  const String IF_STATE_DOWN = "down";
+  const String IF_FLAG_BROADCAST = "broadcast";
+  const String IF_FLAG_LOOPBACK = "loopback";
+  const String IF_FLAG_POINTOPOINT = "point-to-point";
+  const String IF_FLAG_RUNNING = "running";
+  const String IF_FLAG_MULTICAST = "multicast";
+  const int IPSEC_DIRECTION_IN = 0;
+  const int IPSEC_DIRECTION_OUT = 1;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl
new file mode 100644
index 0000000..31775df
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/INetdUnsolicitedEventListener.aidl
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2018, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+interface INetdUnsolicitedEventListener {
+  oneway void onInterfaceClassActivityChanged(boolean isActive, int timerLabel, long timestampNs, int uid);
+  oneway void onQuotaLimitReached(@utf8InCpp String alertName, @utf8InCpp String ifName);
+  oneway void onInterfaceDnsServerInfo(@utf8InCpp String ifName, long lifetimeS, in @utf8InCpp String[] servers);
+  oneway void onInterfaceAddressUpdated(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAddressRemoved(@utf8InCpp String addr, @utf8InCpp String ifName, int flags, int scope);
+  oneway void onInterfaceAdded(@utf8InCpp String ifName);
+  oneway void onInterfaceRemoved(@utf8InCpp String ifName);
+  oneway void onInterfaceChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onInterfaceLinkStateChanged(@utf8InCpp String ifName, boolean up);
+  oneway void onRouteChanged(boolean updated, @utf8InCpp String route, @utf8InCpp String gateway, @utf8InCpp String ifName);
+  oneway void onStrictCleartextDetected(int uid, @utf8InCpp String hex);
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl
new file mode 100644
index 0000000..1869d8d
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/InterfaceConfigurationParcel.aidl
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable InterfaceConfigurationParcel {
+  @utf8InCpp String ifName;
+  @utf8InCpp String hwAddr;
+  @utf8InCpp String ipv4Addr;
+  int prefixLength;
+  @utf8InCpp String[] flags;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl
new file mode 100644
index 0000000..975a261
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/IpSecMigrateInfoParcel.aidl
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2022, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@JavaOnlyImmutable
+parcelable IpSecMigrateInfoParcel {
+  int requestId;
+  int selAddrFamily;
+  int direction;
+  @utf8InCpp String oldSourceAddress;
+  @utf8InCpp String oldDestinationAddress;
+  @utf8InCpp String newSourceAddress;
+  @utf8InCpp String newDestinationAddress;
+  int interfaceId;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl
new file mode 100644
index 0000000..8ea20d1
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/MarkMaskParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable MarkMaskParcel {
+  int mark;
+  int mask;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl
new file mode 100644
index 0000000..77d814b
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkConfig.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeNetworkConfig {
+  int netId;
+  android.net.NativeNetworkType networkType = android.net.NativeNetworkType.PHYSICAL;
+  int permission;
+  boolean secure;
+  android.net.NativeVpnType vpnType = android.net.NativeVpnType.PLATFORM;
+  boolean excludeLocalRoutes = false;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl
new file mode 100644
index 0000000..e77a143
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeNetworkType.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeNetworkType {
+  PHYSICAL = 0,
+  VIRTUAL = 1,
+  PHYSICAL_LOCAL = 2,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl
new file mode 100644
index 0000000..a6af5f7
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/NativeVpnType.aidl
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+@Backing(type="int")
+enum NativeVpnType {
+  SERVICE = 1,
+  PLATFORM = 2,
+  LEGACY = 3,
+  OEM = 4,
+  OEM_SERVICE = 5,
+  OEM_LEGACY = 6,
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl
new file mode 100644
index 0000000..5ef95e6
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/RouteInfoParcel.aidl
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2020, The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+parcelable RouteInfoParcel {
+  @utf8InCpp String destination;
+  @utf8InCpp String ifName;
+  @utf8InCpp String nextHop;
+  int mtu;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl
new file mode 100644
index 0000000..7b39c22
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherConfigParcel.aidl
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherConfigParcel {
+  boolean usingLegacyDnsProxy;
+  @utf8InCpp String[] dhcpRanges;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl
new file mode 100644
index 0000000..983e986
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherOffloadRuleParcel.aidl
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherOffloadRuleParcel {
+  int inputInterfaceIndex;
+  int outputInterfaceIndex;
+  byte[] destination;
+  int prefixLength;
+  byte[] srcL2Address;
+  byte[] dstL2Address;
+  int pmtu = 1500;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl
new file mode 100644
index 0000000..5f1b722
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/TetherStatsParcel.aidl
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+parcelable TetherStatsParcel {
+  @utf8InCpp String iface;
+  long rxBytes;
+  long rxPackets;
+  long txBytes;
+  long txPackets;
+  int ifIndex = 0;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl
new file mode 100644
index 0000000..72e987a
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/UidRangeParcel.aidl
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable UidRangeParcel {
+  int start;
+  int stop;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl
new file mode 100644
index 0000000..9bb679f
--- /dev/null
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/17/android/net/netd/aidl/NativeUidRangeConfig.aidl
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+///////////////////////////////////////////////////////////////////////////////
+// THIS FILE IS IMMUTABLE. DO NOT EDIT IN ANY CASE.                          //
+///////////////////////////////////////////////////////////////////////////////
+
+// This file is a snapshot of an AIDL file. Do not edit it manually. There are
+// two cases:
+// 1). this is a frozen version file - do not edit this in any case.
+// 2). this is a 'current' file. If you make a backwards compatible change to
+//     the interface (from the latest frozen version), the build system will
+//     prompt you to update this file with `m <name>-update-api`.
+//
+// You must not make a backward incompatible change to any AIDL file built
+// with the aidl_interface module type with versions property set. The module
+// type is used to build AIDL files in a way that they can be used across
+// independently updatable components of the system. If a device is shipped
+// with such a backward incompatible change, it has a high risk of breaking
+// later when a module using the interface is updated, e.g., Mainline modules.
+
+package android.net.netd.aidl;
+/* @hide */
+@JavaDerive(equals=true, toString=true) @JavaOnlyImmutable
+parcelable NativeUidRangeConfig {
+  int netId;
+  android.net.UidRangeParcel[] uidRanges;
+  int subPriority;
+}
diff --git a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
index 8a8be83..a6af5f7 100644
--- a/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
+++ b/staticlibs/netd/aidl_api/netd_aidl_interface/current/android/net/NativeVpnType.aidl
@@ -38,4 +38,6 @@
   PLATFORM = 2,
   LEGACY = 3,
   OEM = 4,
+  OEM_SERVICE = 5,
+  OEM_LEGACY = 6,
 }
diff --git a/staticlibs/netd/binder/android/net/NativeVpnType.aidl b/staticlibs/netd/binder/android/net/NativeVpnType.aidl
index cd1b447..aa0fdc1 100644
--- a/staticlibs/netd/binder/android/net/NativeVpnType.aidl
+++ b/staticlibs/netd/binder/android/net/NativeVpnType.aidl
@@ -34,7 +34,20 @@
   LEGACY = 3,
 
   /**
-   * An VPN created by OEM code through other means than VpnService or VpnManager.
+   * A VPN created by OEM code through other means than VpnService or VpnManager.
    */
   OEM = 4,
-}
\ No newline at end of file
+
+  /**
+   * A VPN created by OEM code using VpnService, and which OEM code desires to differentiate from
+   * other VPN types. The core networking stack will treat this VPN type similarly to SERVICE.
+   */
+  OEM_SERVICE = 5,
+
+  /**
+   * A VPN created by OEM code using the legacy VPN mechanisms, and which OEM code desires to
+   * differentiate from other VPN types. The core networking stack will treat this VPN type
+   * similarly to LEGACY.
+   */
+  OEM_LEGACY = 6,
+}
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 419b338..d4753b7 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -26,7 +26,7 @@
     get_apf_counter,
     get_apf_counters_from_dumpsys,
     get_ipv4_addresses,
-    get_ipv6_addresses,
+    get_non_tentative_ipv6_addresses,
     get_hardware_address,
     is_send_raw_packet_downstream_supported,
     is_packet_capture_supported,
@@ -103,8 +103,12 @@
       self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = """
+45: rmnet29: <POINTOPOINT,MULTICAST,NOARP> mtu 1500 qdisc ...
+ link/ether 73:01:23:45:df:e3 brd ff:ff:ff:ff:ff:ff
 46: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq ...
  link/ether 72:05:77:82:21:e0 brd ff:ff:ff:ff:ff:ff
+47: wlan1: <BROADCAST,MULTICAST> mtu 1500 qdisc ...
+ link/ether 6a:16:81:ff:82:9b brd ff:ff:ff:ff:ff:ff
 """
     mac_address = get_hardware_address(self.mock_ad, "wlan0")
     asserts.assert_equal(mac_address, "72:05:77:82:21:E0")
@@ -140,7 +144,7 @@
     asserts.assert_equal(ip_addresses, [])
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
-  def test_get_ipv6_addresses_success(
+  def test_get_non_tentative_ipv6_addresses_success(
       self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = """
@@ -152,7 +156,7 @@
     inet6 fe80::3aff:2199:2d8e:20d1/64 scope link noprefixroute
         valid_lft forever preferred_lft forever
 """
-    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    ip_addresses = get_non_tentative_ipv6_addresses(self.mock_ad, "wlan0")
     asserts.assert_equal(ip_addresses,
                          ["fe80::10a3:5dff:fe52:de32",
                           "2001:b400:e53f:164e:9c1e:780e:d1:4658",
@@ -163,7 +167,7 @@
           self, mock_adb_shell: MagicMock
   ) -> None:
     mock_adb_shell.return_value = ""
-    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    ip_addresses = get_non_tentative_ipv6_addresses(self.mock_ad, "wlan0")
     asserts.assert_equal(ip_addresses, [])
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
index a66dacd..673d9e6 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/BpfDumpTest.java
@@ -17,18 +17,13 @@
 package com.android.net.module.util;
 
 import static android.system.OsConstants.EPERM;
-import static android.system.OsConstants.R_OK;
-
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import android.annotation.Nullable;
 import android.system.ErrnoException;
-import android.system.Os;
 import android.util.Pair;
 
 import androidx.test.filters.SmallTest;
@@ -38,7 +33,6 @@
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.mockito.MockitoSession;
 
 import java.io.PrintWriter;
 import java.io.StringWriter;
@@ -129,9 +123,14 @@
         assertTrue(dump.contains("key=789, val=123"));
     }
 
-    private String getDumpMapStatus(final IBpfMap<Struct.S32, Struct.S32> map) {
+    private String getDumpMapStatus(final IBpfMap<Struct.S32, Struct.S32> map,
+            @Nullable final BpfDump.Dependencies deps) {
         final StringWriter sw = new StringWriter();
-        BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath");
+        if (deps == null) {
+            BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath");
+        } else {
+            BpfDump.dumpMapStatus(map, new PrintWriter(sw), "mapName", "mapPath", deps);
+        }
         return sw.toString();
     }
 
@@ -139,25 +138,34 @@
     public void testGetMapStatus() {
         final IBpfMap<Struct.S32, Struct.S32> map =
                 new TestBpfMap<>(Struct.S32.class, Struct.S32.class);
-        assertEquals("mapName: OK\n", getDumpMapStatus(map));
+        assertEquals("mapName: OK\n", getDumpMapStatus(map, null /* deps */));
     }
 
     @Test
-    public void testGetMapStatusNull() {
-        final MockitoSession session = mockitoSession()
-                .spyStatic(Os.class)
-                .startMocking();
-        try {
-            // Os.access succeeds
-            doReturn(true).when(() -> Os.access("mapPath", R_OK));
-            assertEquals("mapName: NULL(map is pinned to mapPath)\n", getDumpMapStatus(null));
+    public void testGetMapStatusNull_accessSucceed() {
+        // Os.access succeeds
+        assertEquals("mapName: NULL(map is pinned to mapPath)\n",
+                getDumpMapStatus(null /* map */,
+                        new BpfDump.Dependencies() {
+                            @Override
+                            public boolean access(String path, int mode) {
+                                return true;
+                            }
+                        })
+        );
+    }
 
-            // Os.access throws EPERM
-            doThrow(new ErrnoException("", EPERM)).when(() -> Os.access("mapPath", R_OK));
-            assertEquals("mapName: NULL(map is not pinned to mapPath: Operation not permitted)\n",
-                    getDumpMapStatus(null));
-        } finally {
-            session.finishMocking();
-        }
+    @Test
+    public void testGetMapStatusNull_accessThrow() {
+        // Os.access throws EPERM
+        assertEquals("mapName: NULL(map is not pinned to mapPath: Operation not permitted)\n",
+                getDumpMapStatus(null /* map */,
+                        new BpfDump.Dependencies(){
+                            @Override
+                            public boolean access(String path, int mode) throws ErrnoException {
+                                throw new ErrnoException("", EPERM);
+                            }
+                        })
+        );
     }
 }
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
index 13710b1..08cab03 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
@@ -290,7 +290,7 @@
     @Test
     public void testCreateGetLinkMessage() {
         final String expectedHexBytes =
-                "20000000120001006824000000000000"    // struct nlmsghdr
+                "20000000120005006824000000000000"    // struct nlmsghdr
                 + "00000000080000000000000000000000"; // struct ifinfomsg
         final String interfaceName = "wlan0";
         final int interfaceIndex = 8;
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index ec486fb..b59ccc6 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -25,7 +25,6 @@
     ],
     defaults: [
         "framework-connectivity-test-defaults",
-        "lib_mockito_extended",
     ],
     libs: [
         "androidx.annotation_annotation",
@@ -36,6 +35,7 @@
         "collector-device-lib",
         "kotlin-reflect",
         "libnanohttpd",
+        "mockito-target-minus-junit4",
         "net-tests-utils-host-device-common",
         "net-utils-device-common",
         "net-utils-device-common-async",
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
index ae0de79..c9d2527 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/CarrierConfigRule.kt
@@ -18,20 +18,28 @@
 
 import android.Manifest.permission.MODIFY_PHONE_STATE
 import android.Manifest.permission.READ_PHONE_STATE
+import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.pm.PackageManager
 import android.os.ConditionVariable
+import android.os.ParcelFileDescriptor
 import android.os.PersistableBundle
+import android.os.Process
 import android.telephony.CarrierConfigManager
 import android.telephony.CarrierConfigManager.ACTION_CARRIER_CONFIG_CHANGED
+import android.telephony.SubscriptionManager
 import android.telephony.SubscriptionManager.EXTRA_SUBSCRIPTION_INDEX
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.CarrierPrivilegesCallback
 import android.util.Log
 import androidx.test.platform.app.InstrumentationRegistry
-import com.android.modules.utils.build.SdkLevel.isAtLeastU
+import com.android.modules.utils.build.SdkLevel
 import com.android.testutils.runAsShell
 import com.android.testutils.tryTest
+import java.security.MessageDigest
 import kotlin.test.assertNotNull
 import kotlin.test.assertTrue
 import org.junit.rules.TestRule
@@ -46,12 +54,20 @@
  * configuration automatically on teardown.
  */
 class CarrierConfigRule : TestRule {
+    private val HEX_CHARS: CharArray = charArrayOf(
+        '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
+    )
+
     private val context by lazy { InstrumentationRegistry.getInstrumentation().context }
+    private val uiAutomation by lazy { InstrumentationRegistry.getInstrumentation().uiAutomation }
     private val ccm by lazy { context.getSystemService(CarrierConfigManager::class.java) }
 
     // Map of (subId) -> (original values of overridden settings)
     private val originalConfigs = mutableMapOf<Int, PersistableBundle>()
 
+    // Map of (subId) -> (original values of carrier service package)
+    private val originalCarrierServicePackages = mutableMapOf<Int, String?>()
+
     override fun apply(base: Statement, description: Description): Statement {
         return CarrierConfigStatement(base, description)
     }
@@ -118,6 +134,177 @@
         }
     }
 
+    private fun runShellCommand(cmd: String) {
+        val fd: ParcelFileDescriptor = uiAutomation.executeShellCommand(cmd)
+        fd.close() // Don't care about the output.
+    }
+
+    /**
+     * Converts a byte array into a String of hexadecimal characters.
+     *
+     * @param bytes an array of bytes
+     * @return hex string representation of bytes array
+     */
+    private fun bytesToHexString(bytes: ByteArray?): String? {
+        if (bytes == null) return null
+
+        val ret = StringBuilder(2 * bytes.size)
+
+        for (i in bytes.indices) {
+            var b: Int
+            b = 0x0f and (bytes[i].toInt() shr 4)
+            ret.append(HEX_CHARS[b])
+            b = 0x0f and bytes[i].toInt()
+            ret.append(HEX_CHARS[b])
+        }
+
+        return ret.toString()
+    }
+
+    private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
+        if (!SdkLevel.isAtLeastT()) {
+            throw UnsupportedOperationException(
+                "Acquiring carrier privilege requires at least T SDK"
+            )
+        }
+
+        fun getCertHash(): String {
+            val pkgInfo = context.packageManager.getPackageInfo(
+                context.opPackageName,
+                PackageManager.GET_SIGNATURES
+            )
+            val digest = MessageDigest.getInstance("SHA-256")
+            val certHash = digest.digest(pkgInfo.signatures!![0]!!.toByteArray())
+            return bytesToHexString(certHash)!!
+        }
+
+        val tm = context.getSystemService(TelephonyManager::class.java)!!
+
+        val cv = ConditionVariable()
+        val cpb = PrivilegeWaiterCallback(cv)
+        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
+        // T. This means the lambda will compile as a private method of this class taking a
+        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
+        // including private methods, this would fail with a link error when running on S-.
+        // To solve this, make the lambda serializable, which causes the compiler to emit a
+        // synthetic class instead of a synthetic method.
+        tryTest @JvmSerializableLambda {
+            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
+            }
+            // Wait for the callback to be registered
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't register CarrierPrivilegesCallback")
+            if (cpb.hasPrivilege == hold) {
+                if (hold) {
+                    Log.w(TAG, "Package ${context.opPackageName} already is privileged")
+                } else {
+                    Log.w(TAG, "Package ${context.opPackageName} already isn't privileged")
+                }
+                return@tryTest
+            }
+            if (hold) {
+                addConfigOverrides(subId, PersistableBundle().also {
+                    it.putStringArray(CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
+                        arrayOf(getCertHash()))
+                })
+            } else {
+                cleanUpNow()
+            }
+        } cleanup @JvmSerializableLambda {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.unregisterCarrierPrivilegesCallback(cpb)
+            }
+        }
+    }
+
+    /**
+     * Acquires carrier privilege on the given subscription ID.
+     */
+    fun acquireCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(true, subId)
+
+    /**
+     * Drops carrier privilege from the given subscription ID.
+     */
+    fun dropCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(false, subId)
+
+    /**
+     * Sets the carrier service package override for the given subscription ID. A null argument will
+     * clear any previously-set override.
+     */
+    fun setCarrierServicePackageOverride(subId: Int, pkg: String?) {
+        if (!SdkLevel.isAtLeastU()) {
+            throw UnsupportedOperationException(
+                "Setting carrier service package override requires at least U SDK"
+            )
+        }
+
+        val tm = context.getSystemService(TelephonyManager::class.java)!!
+
+        val cv = ConditionVariable()
+        val cpb = CarrierServiceChangedWaiterCallback(cv)
+        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
+        // T. This means the lambda will compile as a private method of this class taking a
+        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
+        // including private methods, this would fail with a link error when running on S-.
+        // To solve this, make the lambda serializable, which causes the compiler to emit a
+        // synthetic class instead of a synthetic method.
+        tryTest @JvmSerializableLambda {
+            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
+            }
+            // Wait for the callback to be registered
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't register CarrierPrivilegesCallback")
+            if (cpb.pkgName == pkg) {
+                Log.w(TAG, "Carrier service package was already $pkg")
+                return@tryTest
+            }
+            if (!originalCarrierServicePackages.contains(subId)) {
+                originalCarrierServicePackages.put(subId, cpb.pkgName)
+            }
+            cv.close()
+            runAsShell(MODIFY_PHONE_STATE) {
+                if (null == pkg) {
+                    // There is a bug in clear-carrier-service-package-override where not adding
+                    // the -s argument will use the wrong slot index : b/299604822
+                    runShellCommand("cmd phone clear-carrier-service-package-override" +
+                            " -s $subId")
+                } else {
+                    runShellCommand("cmd phone set-carrier-service-package-override $pkg" +
+                            " -s $subId")
+                }
+            }
+            assertTrue(cv.block(CARRIER_CONFIG_CHANGE_TIMEOUT_MS),
+                "Can't modify carrier service package")
+        } cleanup @JvmSerializableLambda {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
+                tm.unregisterCarrierPrivilegesCallback(cpb)
+            }
+        }
+    }
+
+    private class PrivilegeWaiterCallback(private val cv: ConditionVariable) :
+        CarrierPrivilegesCallback {
+        var hasPrivilege = false
+        override fun onCarrierPrivilegesChanged(p: MutableSet<String>, uids: MutableSet<Int>) {
+            hasPrivilege = uids.contains(Process.myUid())
+            cv.open()
+        }
+    }
+
+    private class CarrierServiceChangedWaiterCallback(private val cv: ConditionVariable) :
+        CarrierPrivilegesCallback {
+        var pkgName: String? = null
+        override fun onCarrierPrivilegesChanged(p: MutableSet<String>, u: MutableSet<Int>) {}
+        override fun onCarrierServiceChanged(pkgName: String?, uid: Int) {
+            this.pkgName = pkgName
+            cv.open()
+        }
+    }
+
     /**
      * Cleanup overrides that were added by the test case.
      *
@@ -138,6 +325,10 @@
             }
             originalConfigs.clear()
         }
+        originalCarrierServicePackages.forEach { (subId, pkg) ->
+            setCarrierServicePackageOverride(subId, pkg)
+        }
+        originalCarrierServicePackages.clear()
     }
 }
 
@@ -145,7 +336,7 @@
     subId: Int,
     keys: Set<String>
 ): PersistableBundle {
-    return if (isAtLeastU()) {
+    return if (SdkLevel.isAtLeastU()) {
         // This method is U+
         getConfigForSubId(subId, *keys.toTypedArray())
     } else {
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
index 07f3be5..60285a8 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -223,7 +223,7 @@
             // Echo the current pid, and replace it (with exec) with the tcpdump process, so the
             // tcpdump pid is known.
             writer.write(
-                "echo $$; exec su 0 tcpdump -n -i any -U -xx".encodeToByteArray()
+                "echo $$; exec su 0 tcpdump -n -i any -l -xx".encodeToByteArray()
             )
         }
         val reader = FileReader(stdout.fileDescriptor).buffered()
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt b/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt
new file mode 100644
index 0000000..a6e7ead
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PollingUtils.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils
+
+private const val POLLING_INTERVAL_MS: Int = 100
+
+/** Calls condition() until it returns true or timeout occurs. */
+fun pollingCheck(timeout_ms: Long, condition: () -> Boolean): Boolean {
+    var polling_time = 0
+    do {
+        Thread.sleep(POLLING_INTERVAL_MS.toLong())
+        polling_time += POLLING_INTERVAL_MS
+        if (condition()) return true
+    } while (polling_time < timeout_ms)
+    return false
+}
diff --git a/staticlibs/testutils/host/python/apf_test_base.py b/staticlibs/testutils/host/python/apf_test_base.py
index 2552aa3..33b3838 100644
--- a/staticlibs/testutils/host/python/apf_test_base.py
+++ b/staticlibs/testutils/host/python/apf_test_base.py
@@ -60,10 +60,10 @@
     self.client_ipv4_addresses = apf_utils.get_ipv4_addresses(
         self.clientDevice, self.client_iface_name
     )
-    self.server_ipv6_addresses = apf_utils.get_ipv6_addresses(
+    self.server_ipv6_addresses = apf_utils.get_non_tentative_ipv6_addresses(
         self.serverDevice, self.server_iface_name
     )
-    self.client_ipv6_addresses = apf_utils.get_ipv6_addresses(
+    self.client_ipv6_addresses = apf_utils.get_non_tentative_ipv6_addresses(
         self.clientDevice, self.client_iface_name
     )
 
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index 55ac860..1648d36 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -18,6 +18,7 @@
 from mobly.controllers import android_device
 from mobly.controllers.android_device_lib.adb import AdbError
 from net_tests_utils.host.python import adb_utils, assert_utils
+import functools
 
 
 class PatternNotFoundException(Exception):
@@ -115,12 +116,12 @@
   else:
     return []
 
-def get_ipv6_addresses(
+def get_non_tentative_ipv6_addresses(
     ad: android_device.AndroidDevice, iface_name: str
 ) -> list[str]:
-  """Retrieves the IPv6 addresses of a given interface on an Android device.
+  """Retrieves the non-tentative IPv6 addresses of a given interface on an Android device.
 
-  This function executes an ADB shell command (`ip -6 address show`) to get the
+  This function executes an ADB shell command (`ip -6 address show -tentative`) to get the
   network interface information and extracts the IPv6 address from the output.
   If devices have no IPv6 address, raise PatternNotFoundException.
 
@@ -138,7 +139,7 @@
   #         valid_lft forever preferred_lft forever
   #     inet6 fe80::1233:aadb:3d32:1234/64 scope link
   #         valid_lft forever preferred_lft forever
-  output = adb_utils.adb_shell(ad, f"ip -6 address show {iface_name}")
+  output = adb_utils.adb_shell(ad, f"ip -6 address show -tentative {iface_name}")
   pattern = r"inet6\s+([0-9a-fA-F:]+)\/\d+"
   matches = re.findall(pattern, output)
 
@@ -162,14 +163,18 @@
   """
 
   # Run the "ip link" command and get its output.
-  ip_link_output = adb_utils.adb_shell(ad, f"ip link show {iface_name}")
+  ip_link_output = adb_utils.adb_shell(ad, f"ip link")
 
   # Regular expression to extract the MAC address.
   # Parse hardware address from ip link output like below:
+  # 45: rmnet29: <POINTOPOINT,MULTICAST,NOARP> mtu 1500 qdisc ...
+  #    link/ether 73:01:23:45:df:e3 brd ff:ff:ff:ff:ff:ff
   # 46: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq ...
   #    link/ether 72:05:77:82:21:e0 brd ff:ff:ff:ff:ff:ff
-  pattern = r"link/ether (([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})"
-  match = re.search(pattern, ip_link_output)
+  # 47: wlan1: <BROADCAST,MULTICAST> mtu 1500 qdisc ...
+  #    link/ether 6a:16:81:ff:82:9b brd ff:ff:ff:ff:ff:ff"
+  pattern = rf"{iface_name}:.*?link/ether (([0-9a-fA-F]{{2}}:){{5}}[0-9a-fA-F]{{2}})"
+  match = re.search(pattern, ip_link_output, re.DOTALL)
 
   if match:
     return match.group(1).upper()  # Extract the MAC address string.
@@ -396,6 +401,20 @@
       f" {expected_version}",
   )
 
+def at_least_B():
+  def decorator(test_function):
+    @functools.wraps(test_function)
+    def wrapper(self, *args, **kwargs):
+      asserts.abort_class_if(
+        (not hasattr(self, 'client')) or (not hasattr(self.client, 'isAtLeastB')),
+        "client device is not B+"
+      )
+
+      asserts.abort_class_if(not self.client.isAtLeastB(), "not B+")
+      return test_function(self, *args, **kwargs)
+    return wrapper
+  return decorator
+
 class AdbOutputHandler:
   def __init__(self, ad, cmd):
     self._ad = ad
diff --git a/staticlibs/testutils/host/python/multi_devices_test_base.py b/staticlibs/testutils/host/python/multi_devices_test_base.py
index 677329a..72bac0c 100644
--- a/staticlibs/testutils/host/python/multi_devices_test_base.py
+++ b/staticlibs/testutils/host/python/multi_devices_test_base.py
@@ -53,3 +53,4 @@
         raise_on_exception=True,
     )
     self.client = self.clientDevice.connectivity_multi_devices_snippet
+    self.server = self.serverDevice.connectivity_multi_devices_snippet
diff --git a/tests/common/java/android/net/NetworkAgentConfigTest.kt b/tests/common/java/android/net/NetworkAgentConfigTest.kt
index fe869f8..d640a73 100644
--- a/tests/common/java/android/net/NetworkAgentConfigTest.kt
+++ b/tests/common/java/android/net/NetworkAgentConfigTest.kt
@@ -20,7 +20,6 @@
 import androidx.test.runner.AndroidJUnit4
 import com.android.modules.utils.build.SdkLevel.isAtLeastS
 import com.android.modules.utils.build.SdkLevel.isAtLeastT
-import com.android.modules.utils.build.SdkLevel.isAtLeastV
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.assertParcelingIsLossless
 import org.junit.Assert.assertEquals
@@ -48,9 +47,6 @@
                 setLocalRoutesExcludedForVpn(true)
                 setVpnRequiresValidation(true)
             }
-            if (isAtLeastV()) {
-                setSkipNativeNetworkCreation(true)
-            }
         }.build()
         assertParcelingIsLossless(config)
     }
@@ -75,9 +71,6 @@
                 setLocalRoutesExcludedForVpn(true)
                 setVpnRequiresValidation(true)
             }
-            if (isAtLeastV()) {
-                setSkipNativeNetworkCreation(true)
-            }
         }.build()
 
         assertTrue(config.isExplicitlySelected())
@@ -86,9 +79,6 @@
         assertFalse(config.isPartialConnectivityAcceptable())
         assertTrue(config.isUnvalidatedConnectivityAcceptable())
         assertEquals("TEST_NETWORK", config.getLegacyTypeName())
-        if (isAtLeastV()) {
-            assertTrue(config.shouldSkipNativeNetworkCreation())
-        }
         if (isAtLeastT()) {
             assertTrue(config.areLocalRoutesExcludedForVpn())
             assertTrue(config.isVpnValidationRequired())
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 0ac9ce1..0b4375a 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -27,7 +27,7 @@
     // Note that some of the test helper apps (e.g., CtsHostsideNetworkCapTestsAppSdk33) override
     // this with older SDK versions.
     // Also note that unlike android_test targets, "current" does not work: the target SDK is set to
-    // something like "VanillaIceCream" instead of 100000. This means that the tests will not run on
+    // something like "VanillaIceCream" instead of 10000. This means that the tests will not run on
     // released devices with errors such as "Requires development platform VanillaIceCream but this
     // is a release platform".
     target_sdk_version: "10000",
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index 3430196..6c92b74 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -28,6 +28,7 @@
 import static android.net.ConnectivityManager.BLOCKED_REASON_NONE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_BACKGROUND;
 import static android.net.ConnectivityManager.TYPE_VPN;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.os.Process.INVALID_UID;
@@ -1024,7 +1025,10 @@
         checkTrafficOnVpn();
 
         final Network vpnNetwork = mCM.getActiveNetwork();
-        myUidCallback.expectAvailableThenValidatedCallbacks(vpnNetwork, TIMEOUT_MS);
+        myUidCallback.eventuallyExpect(CallbackEntry.NETWORK_CAPS_UPDATED,
+                NETWORK_CALLBACK_TIMEOUT_MS,
+                entry -> entry.getNetwork().equals(vpnNetwork)
+                        && entry.getCaps().hasCapability(NET_CAPABILITY_VALIDATED));
         assertEquals(vpnNetwork, mCM.getActiveNetwork());
         assertNotEqual(defaultNetwork, vpnNetwork);
         maybeExpectVpnTransportInfo(vpnNetwork);
@@ -1843,11 +1847,11 @@
         final DetailedBlockedStatusCallback remoteUidCallback = new DetailedBlockedStatusCallback();
 
         // Create a TUN interface
-        final FileDescriptor tunFd = runWithShellPermissionIdentity(() -> {
+        final ParcelFileDescriptor tunFd = runWithShellPermissionIdentity(() -> {
             final TestNetworkManager tnm = mTestContext.getSystemService(TestNetworkManager.class);
             final TestNetworkInterface iface = tnm.createTunInterface(List.of(
                     TEST_IP4_DST_ADDR, TEST_IP6_DST_ADDR));
-            return iface.getFileDescriptor().getFileDescriptor();
+            return iface.getFileDescriptor();
         }, MANAGE_TEST_NETWORKS);
 
         // Create a remote UDP socket
@@ -1861,7 +1865,7 @@
             remoteUidCallback.expectAvailableCallbacksWithBlockedReasonNone(network);
 
             // The remote UDP socket can receive packets coming from the TUN interface
-            checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_PASS);
+            checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_PASS);
 
             // Lockdown uid that has the remote UDP socket
             runWithShellPermissionIdentity(() -> {
@@ -1877,7 +1881,7 @@
             if (SdkLevel.isAtLeastT()) {
                 // On T and above, lockdown rule drop packets not coming from lo regardless of the
                 // VPN connectivity.
-                checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_BLOCK);
+                checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_BLOCK);
             }
 
             // Start the VPN that has default routes. This VPN should have interface filtering rule
@@ -1889,9 +1893,9 @@
                     null /* proxyInfo */, null /* underlyingNetworks */,
                     false /* isAlwaysMetered */);
 
-            checkBlockIncomingPacket(tunFd, remoteUdpFd, EXPECT_BLOCK);
+            checkBlockIncomingPacket(tunFd.getFileDescriptor(), remoteUdpFd, EXPECT_BLOCK);
         }, /* cleanup */ () -> {
-                Os.close(tunFd);
+                tunFd.close();
             }, /* cleanup */ () -> {
                 Os.close(remoteUdpFd);
             }, /* cleanup */ () -> {
diff --git a/tests/cts/multidevices/Android.bp b/tests/cts/multidevices/Android.bp
index a082a95..00fb934 100644
--- a/tests/cts/multidevices/Android.bp
+++ b/tests/cts/multidevices/Android.bp
@@ -29,6 +29,7 @@
     libs: [
         "absl-py",
         "mobly",
+        "scapy",
         "net-tests-utils-host-python-common",
     ],
     test_suites: [
@@ -42,9 +43,4 @@
         // Package the snippet with the mobly test
         ":connectivity_multi_devices_snippet",
     ],
-    version: {
-        py3: {
-            embedded_launcher: true,
-        },
-    },
 }
diff --git a/tests/cts/multidevices/apfv6_test.py b/tests/cts/multidevices/apfv6_test.py
index fc732d2..b82a3be 100644
--- a/tests/cts/multidevices/apfv6_test.py
+++ b/tests/cts/multidevices/apfv6_test.py
@@ -13,6 +13,9 @@
 #  limitations under the License.
 
 from mobly import asserts
+from scapy.layers.inet import IP, ICMP, IPOption_Router_Alert
+from scapy.layers.l2 import Ether
+from scapy.contrib.igmpv3 import IGMPv3, IGMPv3mq, IGMPv3mr, IGMPv3gr
 from net_tests_utils.host.python import apf_test_base, apf_utils, adb_utils, assert_utils, packet_utils
 
 APFV6_VERSION = 6000
@@ -82,3 +85,62 @@
         self.send_packet_and_expect_reply_received(
             arp_request, "DROPPED_ARP_REQUEST_REPLIED", arp_reply
         )
+
+    @apf_utils.at_least_B()
+    def test_ipv4_icmp_echo_request_offload(self):
+        eth = Ether(src=self.server_mac_address, dst=self.client_mac_address)
+        ip = IP(src=self.server_ipv4_addresses[0], dst=self.client_ipv4_addresses[0])
+        icmp = ICMP(id=1, seq=123)
+        echo_request = bytes(eth/ip/icmp/b"hello").hex()
+
+        eth = Ether(src=self.client_mac_address, dst=self.server_mac_address)
+        ip = IP(src=self.client_ipv4_addresses[0], dst=self.server_ipv4_addresses[0])
+        icmp = ICMP(type=0, id=1, seq=123)
+        expected_echo_reply = bytes(eth/ip/icmp/b"hello").hex()
+        self.send_packet_and_expect_reply_received(
+            echo_request, "DROPPED_IPV4_PING_REQUEST_REPLIED", expected_echo_reply
+        )
+
+    @apf_utils.at_least_B()
+    def test_igmpv3_general_query_offload(self):
+        ether = Ether(src=self.server_mac_address, dst='01:00:5e:00:00:01')
+        ip = IP(
+            src=self.server_ipv4_addresses[0],
+            dst='224.0.0.1',
+            options=[IPOption_Router_Alert()]
+        )
+        igmp = IGMPv3(type=0x11)/IGMPv3mq()
+        igmpv3_general_query = bytes(ether/ip/igmp).hex()
+
+        mcast_addrs = ['239.0.0.1', '239.0.0.2', '239.0.0.3']
+
+        for addr in mcast_addrs:
+            adb_utils.adb_shell(
+                self.clientDevice,
+                f'ip addr add {addr}/32 dev {self.client_iface_name} autojoin'
+            )
+
+        ether = Ether(src=self.client_mac_address, dst='01:00:5e:00:00:16')
+        ip = IP(
+            src=self.client_ipv4_addresses[0],
+            dst='224.0.0.22',
+            options=[IPOption_Router_Alert()],
+            id=0,
+            flags="DF"
+        )
+        igmpv3_hdr = IGMPv3(type=0x22)
+        mcast_records = []
+        for addr in mcast_addrs:
+            mcast_records.append(IGMPv3gr(rtype=2, maddr=addr))
+
+        igmp = IGMPv3mr(records=mcast_records)
+        expected_igmpv3_report = bytes(ether/ip/igmpv3_hdr/igmp).hex()
+        self.send_packet_and_expect_reply_received(
+            igmpv3_general_query, "DROPPED_IGMP_V3_GENERAL_QUERY_REPLIED", expected_igmpv3_report
+        )
+
+        for addr in mcast_addrs:
+            adb_utils.adb_shell(
+                self.clientDevice,
+                f'ip addr del {addr}/32 dev {self.client_iface_name}'
+            )
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 252052e..e1c6bf1 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -33,6 +33,8 @@
 import android.net.wifi.WifiInfo
 import android.net.wifi.WifiManager
 import android.net.wifi.WifiSsid
+import android.os.Build.VERSION.CODENAME
+import android.os.Build.VERSION.SDK_INT
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.compatibility.common.util.PropertyUtil
 import com.android.modules.utils.build.SdkLevel
@@ -62,6 +64,17 @@
         cbHelper.unregisterAll()
     }
 
+    private fun isAtLeastPreReleaseCodename(codeName: String): Boolean {
+        // Special case "REL", which means the build is not a pre-release build.
+        if ("REL".equals(CODENAME)) {
+            return false
+        }
+
+        // Otherwise lexically compare them. Return true if the build codename is equal to or
+        // greater than the requested codename.
+        return CODENAME.compareTo(codeName) >= 0
+    }
+
     @Rpc(description = "Check whether the device has wifi feature.")
     fun hasWifiFeature() = pm.hasSystemFeature(FEATURE_WIFI)
 
@@ -77,6 +90,11 @@
     @Rpc(description = "Return whether the Sdk level is at least V.")
     fun isAtLeastV() = SdkLevel.isAtLeastV()
 
+    @Rpc(description = "Check whether the device is at least B.")
+    fun isAtLeastB(): Boolean {
+        return SDK_INT >= 36 || (SDK_INT == 35 && isAtLeastPreReleaseCodename("Baklava"))
+    }
+
     @Rpc(description = "Return the API level that the VSR requirement must be fulfilled.")
     fun getVsrApiLevel() = PropertyUtil.getVsrApiLevel()
 
diff --git a/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
index f8c9351..3816537 100644
--- a/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
@@ -21,8 +21,8 @@
 import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
+import android.content.pm.PackageManager.FEATURE_WIFI_DIRECT
 import android.net.MacAddress
-import android.net.wifi.WifiManager
 import android.net.wifi.p2p.WifiP2pConfig
 import android.net.wifi.p2p.WifiP2pDevice
 import android.net.wifi.p2p.WifiP2pDeviceList
@@ -44,10 +44,6 @@
 
 class Wifip2pMultiDevicesSnippet : Snippet {
     private val context by lazy { InstrumentationRegistry.getInstrumentation().getTargetContext() }
-    private val wifiManager by lazy {
-        context.getSystemService(WifiManager::class.java)
-                ?: fail("Could not get WifiManager service")
-    }
     private val wifip2pManager by lazy {
         context.getSystemService(WifiP2pManager::class.java)
                 ?: fail("Could not get WifiP2pManager service")
@@ -84,7 +80,7 @@
     }
 
     @Rpc(description = "Check whether the device supports Wi-Fi P2P.")
-    fun isP2pSupported() = wifiManager.isP2pSupported()
+    fun isP2pSupported() = context.packageManager.hasSystemFeature(FEATURE_WIFI_DIRECT)
 
     @Rpc(description = "Start Wi-Fi P2P")
     fun startWifiP2p() {
diff --git a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
index c6a1b09..81afabc 100644
--- a/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
+++ b/tests/cts/net/src/android/net/cts/ApfIntegrationTest.kt
@@ -36,6 +36,7 @@
 import android.net.apf.ApfConstants.IPV6_NEXT_HEADER_OFFSET
 import android.net.apf.ApfConstants.IPV6_SRC_ADDR_OFFSET
 import android.net.apf.ApfCounterTracker
+import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_NS_INVALID
 import android.net.apf.ApfCounterTracker.Counter.DROPPED_IPV6_NS_REPLIED_NON_DAD
 import android.net.apf.ApfCounterTracker.Counter.FILTER_AGE_16384THS
 import android.net.apf.ApfCounterTracker.Counter.PASSED_IPV6_ICMP
@@ -86,6 +87,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.SkipPresubmit
 import com.android.testutils.TestableNetworkCallback
+import com.android.testutils.pollingCheck
 import com.android.testutils.waitForIdle
 import com.google.common.truth.Expect
 import com.google.common.truth.Truth.assertThat
@@ -98,6 +100,7 @@
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.TimeoutException
 import kotlin.random.Random
+import kotlin.test.assertEquals
 import kotlin.test.assertFailsWith
 import kotlin.test.assertNotNull
 import org.junit.After
@@ -111,7 +114,6 @@
 
 private const val TAG = "ApfIntegrationTest"
 private const val TIMEOUT_MS = 2000L
-private const val POLLING_INTERVAL_MS: Int = 100
 private const val RCV_BUFFER_SIZE = 1480
 private const val PING_HEADER_LENGTH = 8
 
@@ -129,16 +131,6 @@
         private val powerManager = context.getSystemService(PowerManager::class.java)!!
         private val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG)
 
-        fun pollingCheck(condition: () -> Boolean, timeout_ms: Int): Boolean {
-            var polling_time = 0
-            do {
-                Thread.sleep(POLLING_INTERVAL_MS.toLong())
-                polling_time += POLLING_INTERVAL_MS
-                if (condition()) return true
-            } while (polling_time < timeout_ms)
-            return false
-        }
-
         fun turnScreenOff() {
             if (!wakeLock.isHeld()) wakeLock.acquire()
             runShellCommandOrThrow("input keyevent KEYCODE_SLEEP")
@@ -160,7 +152,9 @@
                 // This is a workaround for b/366037029.
                 Thread.sleep(2000L)
             } else {
-                val result = pollingCheck({ powerManager.isInteractive() }, timeout_ms = 2000)
+                val result = pollingCheck(timeout_ms = 2000) {
+                    powerManager.isInteractive()
+                }
                 assertThat(result).isEqualTo(interactive)
             }
         }
@@ -471,18 +465,18 @@
         assertThat(readResult).isEqualTo(program)
     }
 
-    fun ApfV4GeneratorBase<*>.addPassIfNotIcmpv6EchoReply() {
+    fun ApfV4GeneratorBase<*>.addPassIfNotIcmpv6EchoReply(skipPacketLabel: Short) {
         // If not IPv6 -> PASS
         addLoad16intoR0(ETH_ETHERTYPE_OFFSET)
-        addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), BaseApfGenerator.PASS_LABEL)
+        addJumpIfR0NotEquals(ETH_P_IPV6.toLong(), skipPacketLabel)
 
         // If not ICMPv6 -> PASS
         addLoad8intoR0(IPV6_NEXT_HEADER_OFFSET)
-        addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), BaseApfGenerator.PASS_LABEL)
+        addJumpIfR0NotEquals(IPPROTO_ICMPV6.toLong(), skipPacketLabel)
 
         // If not echo reply -> PASS
         addLoad8intoR0(ICMP6_TYPE_OFFSET)
-        addJumpIfR0NotEquals(0x81, BaseApfGenerator.PASS_LABEL)
+        addJumpIfR0NotEquals(0x81, skipPacketLabel)
     }
 
     // APF integration is mostly broken before V
@@ -518,21 +512,35 @@
                 caps.maximumApfProgramSize
         )
 
+        val skipPacketLabel = gen.uniqueLabel
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(skipPacketLabel)
 
         // if not data matches -> PASS
         gen.addLoadImmediate(R0, ICMP6_TYPE_OFFSET + PING_HEADER_LENGTH)
-        gen.addJumpIfBytesAtR0NotEqual(data, BaseApfGenerator.PASS_LABEL)
+        gen.addJumpIfBytesAtR0NotEqual(data, skipPacketLabel)
 
         // else DROP
-        gen.addJump(BaseApfGenerator.DROP_LABEL)
+        // Warning: the program abuse DROPPED_IPV6_NS_INVALID/PASSED_IPV6_ICMP for debugging purpose
+        gen.addCountAndDrop(DROPPED_IPV6_NS_INVALID)
+            .defineLabel(skipPacketLabel)
+            .addCountAndPass(PASSED_IPV6_ICMP)
+            .addCountTrampoline()
 
         val program = gen.generate()
         installAndVerifyProgram(program)
 
+        val counterBefore = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_INVALID
+        )
         packetReader.sendPing(data, payloadSize)
         packetReader.expectPingDropped()
+        val counterAfter = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_INVALID
+        )
+        assertEquals(counterBefore + 1, counterAfter)
     }
 
     fun clearApfMemory() = installProgram(ByteArray(caps.maximumApfProgramSize))
@@ -557,7 +565,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         val counterRegion = 500
@@ -575,6 +583,13 @@
 
         val program = gen.generate()
         assertThat(program.size).isLessThan(counterRegion)
+        val randomProgram = ByteArray(1) { 0 } +
+                ByteArray(counterRegion - 1).also { Random.nextBytes(it) }
+        // There are known firmware bugs where they calculate the number of non-zero bytes within
+        // the program to determine the program length. Modify the test to first install a longer
+        // program before installing a program that do the program length check. This should help us
+        // catch these types of firmware bugs in CTS. (b/395545572)
+        installAndVerifyProgram(randomProgram)
         installAndVerifyProgram(program)
 
         // Trigger the program by sending a ping and waiting on the reply.
@@ -616,7 +631,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         val counterRegion = 500
@@ -659,7 +674,7 @@
         )
 
         // If not ICMPv6 Echo Reply -> PASS
-        gen.addPassIfNotIcmpv6EchoReply()
+        gen.addPassIfNotIcmpv6EchoReply(BaseApfGenerator.PASS_LABEL)
 
         // Store all prefilled memory slots in counter region [500, 520)
         gen.addLoadFromMemory(R0, MemorySlot.FILTER_AGE_16384THS)
@@ -781,6 +796,10 @@
         val program = gen.generate()
         installAndVerifyProgram(program)
 
+        val counterBefore = ApfCounterTracker.getCounterValue(
+            readProgram(),
+            DROPPED_IPV6_NS_REPLIED_NON_DAD
+        )
         packetReader.sendPing(payload, payloadSize, expectReplyCount = numOfPacketToTransmit)
         val replyPayloads = try {
             packetReader.expectPingReply(TIMEOUT_MS * 2)
@@ -789,9 +808,16 @@
         }
 
         val apfCounterTracker = ApfCounterTracker()
-        apfCounterTracker.updateCountersFromData(readProgram())
+        val apfRam = readProgram()
+        apfCounterTracker.updateCountersFromData(apfRam)
         Log.i(TAG, "counter map: ${apfCounterTracker.counters}")
 
+        val counterAfter = ApfCounterTracker.getCounterValue(
+            apfRam,
+            DROPPED_IPV6_NS_REPLIED_NON_DAD
+        )
+        assertEquals(counterBefore + 1, counterAfter)
+
         assertThat(replyPayloads.size).isEqualTo(expectReplyPayloads.size)
 
         // Sort the payload list before comparison to ensure consistency.
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index aa7d618..87c2b9e 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -90,7 +90,6 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK;
-import static android.net.cts.util.CtsNetUtils.ConnectivityActionReceiver;
 import static android.net.cts.util.CtsNetUtils.HTTP_PORT;
 import static android.net.cts.util.CtsNetUtils.NETWORK_CALLBACK_ACTION;
 import static android.net.cts.util.CtsNetUtils.TEST_HOST;
@@ -111,6 +110,7 @@
 import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_LOCKDOWN_VPN;
 import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_NONE;
 import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_EXPORTED;
+import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_NOT_EXPORTED;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 import static com.android.testutils.MiscAsserts.assertEventuallyTrue;
@@ -178,6 +178,7 @@
 import android.os.Binder;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.ConditionVariable;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.MessageQueue;
@@ -1229,42 +1230,43 @@
      * {@link #testRegisterNetworkCallback} except that a {@code PendingIntent} is used instead
      * of a {@code NetworkCallback}.
      */
-    @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
     @Test
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     public void testRegisterNetworkCallback_withPendingIntent() {
-        assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
+        final ConditionVariable received = new ConditionVariable();
 
-        // Create a ConnectivityActionReceiver that has an IntentFilter for our locally defined
-        // action, NETWORK_CALLBACK_ACTION.
-        final IntentFilter filter = new IntentFilter();
-        filter.addAction(NETWORK_CALLBACK_ACTION);
+        // Register a callback with intent and a request for any Internet-providing network,
+        // which should match the currently connected network.
+        final BroadcastReceiver receiver = new BroadcastReceiver() {
+            @Override
+            public void onReceive(final Context context, final Intent intent) {
+                received.open();
+            }
+        };
 
-        final ConnectivityActionReceiver receiver = new ConnectivityActionReceiver(
-                mCm, ConnectivityManager.TYPE_WIFI, NetworkInfo.State.CONNECTED);
-        final int flags = SdkLevel.isAtLeastT() ? RECEIVER_EXPORTED : 0;
-        mContext.registerReceiver(receiver, filter, flags);
+        final int flags = SdkLevel.isAtLeastT() ? RECEIVER_NOT_EXPORTED : 0;
+        mContext.registerReceiver(receiver, new IntentFilter(NETWORK_CALLBACK_ACTION), flags);
 
         // Create a broadcast PendingIntent for NETWORK_CALLBACK_ACTION.
         final Intent intent = new Intent(NETWORK_CALLBACK_ACTION)
                 .setPackage(mContext.getPackageName());
-        // While ConnectivityService would put extra info such as network or request id before
-        // broadcasting the inner intent. The MUTABLE flag needs to be added accordingly.
         final PendingIntent pendingIntent = PendingIntent.getBroadcast(mContext, 0 /*requestCode*/,
                 intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
 
-        // We will register for a WIFI network being available or lost.
-        mCm.registerNetworkCallback(makeWifiNetworkRequest(), pendingIntent);
+        // Register for a network providing Internet being available or lost.
+        final NetworkRequest nr = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build();
+        mCm.registerNetworkCallback(nr, pendingIntent);
 
         try {
-            mCtsNetUtils.ensureWifiConnected();
-
-            // Now we expect to get the Intent delivered notifying of the availability of the wifi
-            // network even if it was already connected as a state-based action when the callback
-            // is registered.
-            assertTrue("Did not receive expected Intent " + intent + " for TRANSPORT_WIFI",
-                    receiver.waitForState());
-        } catch (InterruptedException e) {
-            fail("Broadcast receiver or NetworkCallback wait was interrupted.");
+            // Wait for delivery of the Intent notifying of the availability of the
+            // INTERNET-providing network. Test setup makes sure it's already connected.
+            assertTrue("Did not receive expected Intent " + intent + " for INTERNET",
+                    received.block(NETWORK_CALLBACK_TIMEOUT_MS));
         } finally {
             mCm.unregisterNetworkCallback(pendingIntent);
             pendingIntent.cancel();
@@ -1272,6 +1274,33 @@
         }
     }
 
+    // Up to R ConnectivityService can't be updated through mainline, and there was a bug
+    // where registering a callback with a canceled pending intent would crash the system.
+    @Test
+    // Running this test without aosp/3482151 will likely crash the device.
+    @ConnectivityModuleTest
+    @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testRegisterNetworkCallback_pendingIntent_classNotFound() {
+        final Intent intent = new Intent()
+                .setClassName(mContext.getPackageName(), "NonExistent");
+        final PendingIntent pi = PendingIntent.getActivity(mContext, /* requestCode */ 1,
+                intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE);
+
+        final NetworkRequest nr = new NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_INTERNET)
+                .build();
+        try {
+            // Before the fix delivered through Mainline, this used to crash the system, because
+            // trying to send the pending intent would throw which would prompt ConnectivityService
+            // to release the wake lock, but it would still send a finished notification at which
+            // point CS would try to release the wake lock again and crash.
+            mCm.registerNetworkCallback(nr, pi);
+        } finally {
+            mCm.unregisterNetworkCallback(pi);
+            pi.cancel();
+        }
+    }
+
     private void runIdenticalPendingIntentsRequestTest(boolean useListen) throws Exception {
         assumeTrue(mPackageManager.hasSystemFeature(FEATURE_WIFI));
 
@@ -1377,12 +1406,20 @@
     }
 
     @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     @Test
     public void testRegisterNetworkRequest_identicalPendingIntents() throws Exception {
         runIdenticalPendingIntentsRequestTest(false /* useListen */);
     }
 
     @AppModeFull(reason = "Cannot get WifiManager in instant app mode")
+    // This test is flaky before aosp/3482151 which fixed the issue in the ConnectivityService
+    // code. Unfortunately this means T can't be fixed, so don't run this test with a module
+    // that hasn't been updated.
+    @ConnectivityModuleTest
     @Test
     public void testRegisterNetworkCallback_identicalPendingIntents() throws Exception {
         runIdenticalPendingIntentsRequestTest(true /* useListen */);
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 8fcc703..7d23003 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -15,7 +15,6 @@
  */
 package android.net.cts
 
-import android.Manifest.permission.MODIFY_PHONE_STATE
 import android.Manifest.permission.NETWORK_SETTINGS
 import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
 import android.app.Instrumentation
@@ -80,12 +79,10 @@
 import android.net.cts.NetworkAgentTest.TestableQosCallback.CallbackEntry.OnQosSessionLost
 import android.net.wifi.WifiInfo
 import android.os.Build
-import android.os.ConditionVariable
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.Looper
 import android.os.Message
-import android.os.PersistableBundle
 import android.os.Process
 import android.os.SystemClock
 import android.platform.test.annotations.AppModeFull
@@ -94,19 +91,15 @@
 import android.system.OsConstants.IPPROTO_TCP
 import android.system.OsConstants.IPPROTO_UDP
 import android.system.OsConstants.SOCK_DGRAM
-import android.telephony.CarrierConfigManager
 import android.telephony.SubscriptionManager
 import android.telephony.TelephonyManager
-import android.telephony.TelephonyManager.CarrierPrivilegesCallback
 import android.telephony.data.EpsBearerQosSessionAttributes
 import android.util.ArraySet
 import android.util.DebugUtils.valueToString
-import android.util.Log
 import androidx.test.InstrumentationRegistry
 import com.android.compatibility.common.util.SystemUtil.runShellCommand
 import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity
 import com.android.compatibility.common.util.ThrowingSupplier
-import com.android.compatibility.common.util.UiccUtil
 import com.android.modules.utils.build.SdkLevel
 import com.android.net.module.util.ArrayTrackRecord
 import com.android.net.module.util.NetworkStackConstants.ETHER_MTU
@@ -151,7 +144,6 @@
 import java.net.InetSocketAddress
 import java.net.Socket
 import java.nio.ByteBuffer
-import java.security.MessageDigest
 import java.time.Duration
 import java.util.Arrays
 import java.util.Random
@@ -172,6 +164,7 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.ArgumentMatchers.argThat
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.doReturn
@@ -707,102 +700,6 @@
         doTestAllowedUids(transports, uid, expectUidsPresent, specifier, transportInfo)
     }
 
-    private fun setHoldCarrierPrivilege(hold: Boolean, subId: Int) {
-        fun getCertHash(): String {
-            val pkgInfo = realContext.packageManager.getPackageInfo(
-                realContext.opPackageName,
-                PackageManager.GET_SIGNATURES
-            )
-            val digest = MessageDigest.getInstance("SHA-256")
-            val certHash = digest.digest(pkgInfo.signatures!![0]!!.toByteArray())
-            return UiccUtil.bytesToHexString(certHash)!!
-        }
-
-        val tm = realContext.getSystemService(TelephonyManager::class.java)!!
-
-        val cv = ConditionVariable()
-        val cpb = PrivilegeWaiterCallback(cv)
-        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
-        // T. This means the lambda will compile as a private method of this class taking a
-        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
-        // including private methods, this would fail with a link error when running on S-.
-        // To solve this, make the lambda serializable, which causes the compiler to emit a
-        // synthetic class instead of a synthetic method.
-        tryTest @JvmSerializableLambda {
-            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
-                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
-            }
-            // Wait for the callback to be registered
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't register CarrierPrivilegesCallback")
-            if (cpb.hasPrivilege == hold) {
-                if (hold) {
-                    Log.w(TAG, "Package ${realContext.opPackageName} already is privileged")
-                } else {
-                    Log.w(TAG, "Package ${realContext.opPackageName} already isn't privileged")
-                }
-                return@tryTest
-            }
-            if (hold) {
-                carrierConfigRule.addConfigOverrides(subId, PersistableBundle().also {
-                    it.putStringArray(CarrierConfigManager.KEY_CARRIER_CERTIFICATE_STRING_ARRAY,
-                        arrayOf(getCertHash()))
-                })
-            } else {
-                carrierConfigRule.cleanUpNow()
-            }
-        } cleanup @JvmSerializableLambda {
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
-                tm.unregisterCarrierPrivilegesCallback(cpb)
-            }
-        }
-    }
-
-    private fun acquireCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(true, subId)
-    private fun dropCarrierPrivilege(subId: Int) = setHoldCarrierPrivilege(false, subId)
-
-    private fun setCarrierServicePackageOverride(subId: Int, pkg: String?) {
-        val tm = realContext.getSystemService(TelephonyManager::class.java)!!
-
-        val cv = ConditionVariable()
-        val cpb = CarrierServiceChangedWaiterCallback(cv)
-        // The lambda below is capturing |cpb|, whose type inherits from a class that appeared in
-        // T. This means the lambda will compile as a private method of this class taking a
-        // PrivilegeWaiterCallback argument. As JUnit uses reflection to enumerate all methods
-        // including private methods, this would fail with a link error when running on S-.
-        // To solve this, make the lambda serializable, which causes the compiler to emit a
-        // synthetic class instead of a synthetic method.
-        tryTest @JvmSerializableLambda {
-            val slotIndex = SubscriptionManager.getSlotIndex(subId)!!
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
-                tm.registerCarrierPrivilegesCallback(slotIndex, { it.run() }, cpb)
-            }
-            // Wait for the callback to be registered
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't register CarrierPrivilegesCallback")
-            if (cpb.pkgName == pkg) {
-                Log.w(TAG, "Carrier service package was already $pkg")
-                return@tryTest
-            }
-            cv.close()
-            runAsShell(MODIFY_PHONE_STATE) {
-                if (null == pkg) {
-                    // There is a bug is clear-carrier-service-package-override where not adding
-                    // the -s argument will use the wrong slot index : b/299604822
-                    runShellCommand("cmd phone clear-carrier-service-package-override" +
-                            " -s $subId")
-                } else {
-                    // -s could set the subId, but this test works with the default subId.
-                    runShellCommand("cmd phone set-carrier-service-package-override $pkg")
-                }
-            }
-            assertTrue(cv.block(DEFAULT_TIMEOUT_MS), "Can't modify carrier service package")
-        } cleanup @JvmSerializableLambda {
-            runAsShell(READ_PRIVILEGED_PHONE_STATE) @JvmSerializableLambda {
-                tm.unregisterCarrierPrivilegesCallback(cpb)
-            }
-        }
-    }
-
     private fun String.execute() = runShellCommand(this).trim()
 
     @Test
@@ -855,8 +752,8 @@
             if (!SdkLevel.isAtLeastU()) return@tryTest
             // Acquiring carrier privilege is necessary to override the carrier service package.
             val defaultSlotIndex = SubscriptionManager.getSlotIndex(defaultSubId)
-            acquireCarrierPrivilege(defaultSubId)
-            setCarrierServicePackageOverride(defaultSubId, servicePackage)
+            carrierConfigRule.acquireCarrierPrivilege(defaultSubId)
+            carrierConfigRule.setCarrierServicePackageOverride(defaultSubId, servicePackage)
             val actualServicePackage: String? = runAsShell(READ_PRIVILEGED_PHONE_STATE) {
                 tm.getCarrierServicePackageNameForLogicalSlot(defaultSlotIndex)
             }
@@ -895,10 +792,6 @@
                     expectUidsPresent = false)
             doTestAllowedUidsWithSubId(defaultSubId, intArrayOf(TRANSPORT_CELLULAR, TRANSPORT_WIFI),
                     uid, expectUidsPresent = false)
-        } cleanupStep {
-            if (SdkLevel.isAtLeastU()) setCarrierServicePackageOverride(defaultSubId, null)
-        } cleanup {
-            if (SdkLevel.isAtLeastU()) dropCarrierPrivilege(defaultSubId)
         }
     }
 
@@ -1055,6 +948,47 @@
         callback.expect<Lost>(agent.network!!)
     }
 
+    fun doTestOemVpnType(type: Int) {
+        val mySessionId = "MySession12345"
+        val nc = NetworkCapabilities().apply {
+            addTransportType(TRANSPORT_TEST)
+            addTransportType(TRANSPORT_VPN)
+            addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+            removeCapability(NET_CAPABILITY_NOT_VPN)
+            setTransportInfo(VpnTransportInfo(type, mySessionId))
+        }
+
+        val agent = createNetworkAgent(initialNc = nc)
+        agent.register()
+        agent.markConnected()
+
+        val request = NetworkRequest.Builder()
+            .clearCapabilities()
+            .addTransportType(TRANSPORT_VPN)
+            .removeCapability(NET_CAPABILITY_NOT_VPN)
+            .build()
+        val callback = TestableNetworkCallback()
+        registerNetworkCallback(request, callback)
+
+        callback.expectAvailableThenValidatedCallbacks(agent.network!!)
+
+        var vpnNc = mCM.getNetworkCapabilities(agent.network!!)
+        assertNotNull(vpnNc)
+        assertEquals(type, (vpnNc!!.transportInfo as VpnTransportInfo).type)
+
+        agent.unregister()
+        callback.expect<Lost>(agent.network!!)
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    fun testOemVpnTypes() {
+        // TODO: why is this necessary given the @IgnoreUpTo above?
+        assumeTrue(SdkLevel.isAtLeastB())
+        doTestOemVpnType(VpnManager.TYPE_VPN_OEM_SERVICE)
+        doTestOemVpnType(VpnManager.TYPE_VPN_OEM_LEGACY)
+    }
+
     private fun unregister(agent: TestableNetworkAgent) {
         agent.unregister()
         agent.eventuallyExpect<OnNetworkUnwanted>()
@@ -1066,7 +1000,20 @@
     fun testAgentStartsInConnecting() {
         val mockContext = mock(Context::class.java)
         val mockCm = mock(ConnectivityManager::class.java)
+        val mockedResult = ConnectivityManager.MockHelpers.registerNetworkAgentResult(
+            mock(Network::class.java),
+            mock(INetworkAgentRegistry::class.java)
+        )
         doReturn(mockCm).`when`(mockContext).getSystemService(Context.CONNECTIVITY_SERVICE)
+        doReturn(mockedResult).`when`(mockCm).registerNetworkAgent(
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            any(),
+            anyInt()
+        )
         val agent = createNetworkAgent(mockContext)
         agent.register()
         verify(mockCm).registerNetworkAgent(
@@ -1987,25 +1934,3 @@
         doTestNativeNetworkCreation(expectCreatedImmediately = true, intArrayOf(TRANSPORT_VPN))
     }
 }
-
-// Subclasses of CarrierPrivilegesCallback can't be inline, or they'll be compiled as
-// inner classes of the test class and will fail resolution on R as the test harness
-// uses reflection to list all methods and classes
-class PrivilegeWaiterCallback(private val cv: ConditionVariable) :
-        CarrierPrivilegesCallback {
-    var hasPrivilege = false
-    override fun onCarrierPrivilegesChanged(p: MutableSet<String>, uids: MutableSet<Int>) {
-        hasPrivilege = uids.contains(Process.myUid())
-        cv.open()
-    }
-}
-
-class CarrierServiceChangedWaiterCallback(private val cv: ConditionVariable) :
-        CarrierPrivilegesCallback {
-    var pkgName: String? = null
-    override fun onCarrierPrivilegesChanged(p: MutableSet<String>, u: MutableSet<Int>) {}
-    override fun onCarrierServiceChanged(pkgName: String?, uid: Int) {
-        this.pkgName = pkgName
-        cv.open()
-    }
-}
diff --git a/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
index a9af34f..f43b927 100644
--- a/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkReservationTest.kt
@@ -19,6 +19,8 @@
 import android.Manifest.permission.CONNECTIVITY_USE_RESTRICTED_NETWORKS
 import android.Manifest.permission.MANAGE_TEST_NETWORKS
 import android.Manifest.permission.NETWORK_SETTINGS
+import android.bluetooth.BluetoothManager
+import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE
 import android.net.ConnectivityManager
 import android.net.L2capNetworkSpecifier
 import android.net.L2capNetworkSpecifier.HEADER_COMPRESSION_6LOWPAN
@@ -41,6 +43,7 @@
 import android.os.HandlerThread
 import android.platform.test.annotations.AppModeFull
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
@@ -48,12 +51,15 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.Unavailable
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.TestableNetworkOfferCallback
+import com.android.testutils.pollingCheck
 import com.android.testutils.runAsShell
 import kotlin.test.assertContains
 import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
 import org.junit.After
+import org.junit.Assume.assumeTrue
 import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
@@ -93,6 +99,8 @@
     private val provider = NetworkProvider(context, handlerThread.looper, TAG)
 
     private val registeredCallbacks = ArrayList<TestableNetworkCallback>()
+    private val bm = context.getSystemService(BluetoothManager::class.java)!!
+    private var disableBluetoothInTearDown = false
 
     @Before
     fun setUp() {
@@ -101,6 +109,34 @@
         }
     }
 
+    private fun enableBluetooth() {
+        val adapter = bm.adapter
+        assertNotNull(adapter)
+        if (adapter.isEnabled()) return
+
+        runShellCommandOrThrow("svc bluetooth enable")
+        val bluetoothEnabled = pollingCheck(TIMEOUT_MS) {
+            adapter.isEnabled()
+        }
+        assertTrue(bluetoothEnabled)
+        // Only disable Bluetooth in tear down when it hasn't already been enabled.
+        disableBluetoothInTearDown = true
+    }
+
+    private fun disableBluetooth() {
+        // adapter can't actually be null here, because this function does not run unless
+        // disableBluetoothInTearDown is true. Just in case, refrain from throwing an exception in
+        // tearDown.
+        val adapter = bm.adapter
+        if (adapter == null) return
+
+        runShellCommandOrThrow("svc bluetooth disable")
+        // Wait for #isEnabled() to return false; ignore failures.
+        pollingCheck(TIMEOUT_MS) {
+            !adapter.isEnabled()
+        }
+    }
+
     @After
     fun tearDown() {
         registeredCallbacks.forEach { cm.unregisterNetworkCallback(it) }
@@ -110,6 +146,10 @@
         }
         handlerThread.quitSafely()
         handlerThread.join()
+
+        if (disableBluetoothInTearDown) {
+            disableBluetooth()
+        }
     }
 
     fun NetworkCapabilities.copyWithReservationId(resId: Int) = NetworkCapabilities(this).also {
@@ -158,6 +198,9 @@
 
     @Test
     fun testReserveL2capNetwork() {
+        assumeTrue(context.packageManager.hasSystemFeature(FEATURE_BLUETOOTH_LE))
+        enableBluetooth()
+
         val l2capReservationSpecifier = L2capNetworkSpecifier.Builder()
                 .setRole(ROLE_SERVER)
                 .setHeaderCompression(HEADER_COMPRESSION_6LOWPAN)
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 75b2814..27cba3a 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
@@ -160,11 +160,15 @@
         @Override
         public void onStopTetheringSucceeded() {
             mHistory.add(new CallbackValue.OnStopTetheringSucceeded());
+            // Call the parent method so that the coverage linter sees it: http://b/385014495
+            TetheringManager.StopTetheringCallback.super.onStopTetheringSucceeded();
         }
 
         @Override
         public void onStopTetheringFailed(final int error) {
             mHistory.add(new CallbackValue.OnStopTetheringFailed(error));
+            // Call the parent method so that the coverage linter sees it: http://b/385014495
+            TetheringManager.StopTetheringCallback.super.onStopTetheringFailed(error);
         }
 
         /**
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index 9e49926..7d6a213 100644
--- a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
+++ b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
@@ -32,6 +32,7 @@
 import static android.net.TetheringManager.TETHERING_VIRTUAL;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.TetheringManager.TETHER_ERROR_DUPLICATE_REQUEST;
 import static android.net.TetheringManager.TETHER_ERROR_ENTITLEMENT_UNKNOWN;
 import static android.net.TetheringManager.TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION;
 import static android.net.TetheringManager.TETHER_ERROR_NO_ERROR;
@@ -73,7 +74,6 @@
 import android.net.wifi.SoftApConfiguration;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiSsid;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.PersistableBundle;
 import android.os.ResultReceiver;
@@ -86,7 +86,6 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.modules.utils.build.SdkLevel;
-import com.android.net.flags.Flags;
 import com.android.testutils.ParcelUtils;
 import com.android.testutils.com.android.testutils.CarrierConfigRule;
 
@@ -235,6 +234,30 @@
 
     }
 
+    @Test
+    public void testStartTetheringDuplicateRequestRejected() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+            tetherEventCallback.expectNoTetheringActive();
+
+            final String[] wifiRegexs = mTM.getTetherableWifiRegexs();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback);
+            mTetherChangeReceiver.expectTethering(true /* active */, wifiRegexs);
+
+            final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+            runAsShell(TETHER_PRIVILEGED, () -> {
+                mTM.startTethering(new TetheringRequest.Builder(TETHERING_WIFI).build(),
+                        c -> c.run() /* executor */, startTetheringCallback);
+                startTetheringCallback.expectTetheringFailed(TETHER_ERROR_DUPLICATE_REQUEST);
+            });
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
     private SoftApConfiguration createSoftApConfiguration(@NonNull String ssid) {
         SoftApConfiguration config;
         if (SdkLevel.isAtLeastT()) {
@@ -381,17 +404,20 @@
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
             tetherEventCallback.expectNoTetheringActive();
 
-            SoftApConfiguration softApConfig = createSoftApConfiguration("SSID");
+            SoftApConfiguration softApConfig = SdkLevel.isAtLeastB()
+                    ? createSoftApConfiguration("SSID") : null;
             final TetheringInterface tetheredIface =
                     mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
             assertNotNull(tetheredIface);
-            assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
-            final String wifiTetheringIface = tetheredIface.getInterface();
+            if  (SdkLevel.isAtLeastB()) {
+                assertEquals(softApConfig, tetheredIface.getSoftApConfiguration());
+            }
 
             mCtsTetheringUtils.stopWifiTethering(tetherEventCallback);
 
-            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            if (!SdkLevel.isAtLeastB()) {
+                final String wifiTetheringIface = tetheredIface.getInterface();
                 try {
                     final int ret = runAsShell(TETHER_PRIVILEGED,
                             () -> mTM.tether(wifiTetheringIface));
@@ -456,32 +482,75 @@
     }
 
     @Test
-    public void testStopTetheringRequest() throws Exception {
-        assumeTrue(isTetheringWithSoftApConfigEnabled());
+    public void testStopTetheringRequestNoMatchFailure() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            final StartTetheringCallback startTetheringCallback = new StartTetheringCallback();
+            mTM.startTethering(new TetheringRequest.Builder(TETHERING_VIRTUAL).build(),
+                    c -> c.run(), startTetheringCallback);
+
+            // Stopping a non-matching request should have no effect
+            TetheringRequest nonMatchingRequest = new TetheringRequest.Builder(TETHERING_VIRTUAL)
+                    .setInterfaceName("iface")
+                    .build();
+            mCtsTetheringUtils.stopTethering(nonMatchingRequest, false /* success */);
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
+    }
+
+    @Test
+    public void testStopTetheringRequestMatchSuccess() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
         final TestTetheringEventCallback tetherEventCallback =
                 mCtsTetheringUtils.registerTetheringEventCallback();
         try {
             tetherEventCallback.assumeWifiTetheringSupported(mContext);
 
-            // stopTethering without any tethering active should fail.
-            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
-            mCtsTetheringUtils.stopTethering(request, false /* expectSuccess */);
+            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes("This is one config"
+                            .getBytes(StandardCharsets.UTF_8))).build();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
 
-            // Start wifi tethering
-            mCtsTetheringUtils.startWifiTethering(tetherEventCallback);
-
-            // stopTethering should succeed now that there's a request.
-            mCtsTetheringUtils.stopTethering(request, true /* expectSuccess */);
+            // Stopping the active request should stop tethering.
+            TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .build();
+            mCtsTetheringUtils.stopTethering(request, true /* success */);
             tetherEventCallback.expectNoTetheringActive();
         } finally {
-            mCtsTetheringUtils.stopAllTethering();
             mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
         }
     }
 
-    private boolean isTetheringWithSoftApConfigEnabled() {
-        return Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM
-                && Flags.tetheringWithSoftApConfig();
+    @Test
+    public void testStopTetheringRequestFuzzyMatchSuccess() throws Exception {
+        assumeTrue(SdkLevel.isAtLeastB());
+        final TestTetheringEventCallback tetherEventCallback =
+                mCtsTetheringUtils.registerTetheringEventCallback();
+        try {
+            tetherEventCallback.assumeWifiTetheringSupported(mContext);
+
+            SoftApConfiguration softApConfig = new SoftApConfiguration.Builder()
+                    .setWifiSsid(WifiSsid.fromBytes("This is one config"
+                            .getBytes(StandardCharsets.UTF_8))).build();
+            mCtsTetheringUtils.startWifiTethering(tetherEventCallback, softApConfig);
+
+            // Stopping with a fuzzy matching request should stop tethering.
+            final LinkAddress localAddr = new LinkAddress("192.168.24.5/24");
+            final LinkAddress clientAddr = new LinkAddress("192.168.24.100/24");
+            TetheringRequest fuzzyMatchingRequest = new TetheringRequest.Builder(TETHERING_WIFI)
+                    .setSoftApConfiguration(softApConfig)
+                    .setShouldShowEntitlementUi(true)
+                    .setStaticIpv4Addresses(localAddr, clientAddr)
+                    .build();
+            mCtsTetheringUtils.stopTethering(fuzzyMatchingRequest, true /* success */);
+            tetherEventCallback.expectNoTetheringActive();
+        } finally {
+            mCtsTetheringUtils.unregisterTetheringEventCallback(tetherEventCallback);
+        }
     }
 
     @Test
@@ -494,7 +563,7 @@
         startTetheringCallback.expectTetheringFailed(TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION);
 
         // WRITE_SETTINGS not sufficient
-        if (isTetheringWithSoftApConfigEnabled()) {
+        if (SdkLevel.isAtLeastB()) {
             runAsShell(WRITE_SETTINGS, () -> {
                 mTM.startTethering(new TetheringRequest.Builder(TETHERING_WIFI).build(),
                         c -> c.run() /* executor */, startTetheringCallback);
@@ -636,7 +705,7 @@
 
     @Test
     public void testLegacyTetherApisThrowUnsupportedOperationExceptionAfterV() {
-        assumeTrue(Build.VERSION.SDK_INT > Build.VERSION_CODES.VANILLA_ICE_CREAM);
+        assumeTrue(SdkLevel.isAtLeastB());
         assertThrows(UnsupportedOperationException.class, () -> mTM.tether("iface"));
         assertThrows(UnsupportedOperationException.class, () -> mTM.untether("iface"));
     }
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
index 437eb81..de39215 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -55,11 +55,11 @@
 import com.android.networkstack.apishim.TelephonyManagerShimImpl
 import com.android.server.BpfNetMaps
 import com.android.server.ConnectivityService
-import com.android.server.L2capNetworkProvider
 import com.android.server.NetworkAgentWrapper
 import com.android.server.TestNetIdManager
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator
 import com.android.server.connectivity.ConnectivityResources
+import com.android.server.connectivity.InterfaceTracker
 import com.android.server.connectivity.MockableSystemProperties
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.PermissionMonitor
@@ -114,6 +114,8 @@
     @Mock
     private lateinit var netd: INetd
     @Mock
+    private lateinit var interfaceTracker: InterfaceTracker
+    @Mock
     private lateinit var dnsResolver: IDnsResolver
     @Mock
     private lateinit var systemConfigManager: SystemConfigManager
@@ -140,11 +142,15 @@
 
         private val realContext get() = InstrumentationRegistry.getInstrumentation().context
         private val httpProbeUrl get() =
-            realContext.getResources().getString(com.android.server.net.integrationtests.R.string
-                    .config_captive_portal_http_url)
+            realContext.getResources().getString(
+                com.android.server.net.integrationtests.R.string
+                    .config_captive_portal_http_url
+            )
         private val httpsProbeUrl get() =
-            realContext.getResources().getString(com.android.server.net.integrationtests.R.string
-                    .config_captive_portal_https_url)
+            realContext.getResources().getString(
+                com.android.server.net.integrationtests.R.string
+                    .config_captive_portal_https_url
+            )
 
         private class InstrumentationServiceConnection : ServiceConnection {
             override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -166,12 +172,19 @@
         fun setUpClass() {
             val intent = Intent(realContext, NetworkStackInstrumentationService::class.java)
             intent.action = INetworkStackInstrumentation::class.qualifiedName
-            assertTrue(realContext.bindService(intent, InstrumentationServiceConnection(),
-                    BIND_AUTO_CREATE or BIND_IMPORTANT),
-                    "Error binding to instrumentation service")
-            assertTrue(bindingCondition.block(SERVICE_BIND_TIMEOUT_MS),
+            assertTrue(
+                realContext.bindService(
+                    intent,
+                    InstrumentationServiceConnection(),
+                    BIND_AUTO_CREATE or BIND_IMPORTANT
+                ),
+                    "Error binding to instrumentation service"
+            )
+            assertTrue(
+                bindingCondition.block(SERVICE_BIND_TIMEOUT_MS),
                     "Timed out binding to instrumentation service " +
-                            "after $SERVICE_BIND_TIMEOUT_MS ms")
+                            "after $SERVICE_BIND_TIMEOUT_MS ms"
+            )
         }
     }
 
@@ -201,7 +214,8 @@
         // We don't test the actual notification value strings, so just return an empty array.
         // It doesn't matter what the values are as long as it's not null.
         doReturn(emptyArray<String>()).`when`(resources).getStringArray(
-                R.array.network_switch_type_name)
+                R.array.network_switch_type_name
+        )
         doReturn(1).`when`(resources).getInteger(R.integer.config_networkAvoidBadWifi)
         doReturn(R.array.config_networkSupportedKeepaliveCount).`when`(resources)
                 .getIdentifier(eq("config_networkSupportedKeepaliveCount"), eq("array"), any())
@@ -223,7 +237,13 @@
     }
 
     private inner class TestConnectivityService(deps: Dependencies) : ConnectivityService(
-            context, dnsResolver, log, netd, deps, PermissionMonitorDependencies())
+            context,
+        dnsResolver,
+        log,
+        netd,
+        deps,
+        PermissionMonitorDependencies()
+    )
 
     private inner class TestDependencies : ConnectivityService.Dependencies() {
         override fun getNetworkStack() = networkStackClient
@@ -231,7 +251,11 @@
             mock(ProxyTracker::class.java)
         override fun getSystemProperties() = mock(MockableSystemProperties::class.java)
         override fun makeNetIdManager() = TestNetIdManager()
-        override fun getBpfNetMaps(context: Context?, netd: INetd?) = mock(BpfNetMaps::class.java)
+        override fun getBpfNetMaps(
+            context: Context?,
+            netd: INetd?,
+                                   interfaceTracker: InterfaceTracker?
+        ) = mock(BpfNetMaps::class.java)
                 .also {
                     doReturn(PERMISSION_INTERNET).`when`(it).getNetPermForUid(anyInt())
                 }
@@ -241,13 +265,17 @@
             c: Context,
             h: Handler,
             r: Runnable
-        ) = MultinetworkPolicyTracker(c, h, r,
+        ) = MultinetworkPolicyTracker(
+            c,
+            h,
+            r,
             object : MultinetworkPolicyTracker.Dependencies() {
                 override fun getResourcesForActiveSubId(
                     connResources: ConnectivityResources,
                     activeSubId: Int
                 ) = resources
-            })
+            }
+        )
 
         override fun makeHandlerThread(tag: String): HandlerThread =
             super.makeHandlerThread(tag).also { handlerThreads.add(it) }
@@ -259,13 +287,18 @@
                 listener: BiConsumer<Int, Int>,
                 handler: Handler
         ): CarrierPrivilegeAuthenticator {
-            return CarrierPrivilegeAuthenticator(context,
+            return CarrierPrivilegeAuthenticator(
+                context,
                     object : CarrierPrivilegeAuthenticator.Dependencies() {
                         override fun makeHandlerThread(): HandlerThread =
                                 super.makeHandlerThread().also { handlerThreads.add(it) }
                     },
-                    tm, TelephonyManagerShimImpl.newInstance(tm),
-                    requestRestrictedWifiEnabled, listener, handler)
+                    tm,
+                TelephonyManagerShimImpl.newInstance(tm),
+                    requestRestrictedWifiEnabled,
+                listener,
+                handler
+            )
         }
 
         override fun makeSatelliteAccessController(
@@ -273,7 +306,8 @@
             updateSatellitePreferredUid: Consumer<MutableSet<Int>>?,
             connectivityServiceInternalHandler: Handler
         ): SatelliteAccessController? = mock(
-            SatelliteAccessController::class.java)
+            SatelliteAccessController::class.java
+        )
 
         override fun makeL2capNetworkProvider(context: Context) = null
     }
@@ -305,8 +339,12 @@
         nsInstrumentation.addHttpResponse(HttpResponse(httpProbeUrl, responseCode = 204))
         nsInstrumentation.addHttpResponse(HttpResponse(httpsProbeUrl, responseCode = 204))
 
-        val na = NetworkAgentWrapper(TRANSPORT_CELLULAR, LinkProperties(), null /* ncTemplate */,
-                context)
+        val na = NetworkAgentWrapper(
+            TRANSPORT_CELLULAR,
+            LinkProperties(),
+            null /* ncTemplate */,
+                context
+        )
         networkStackClient.verifyNetworkMonitorCreated(na.network, TEST_TIMEOUT_MS)
 
         na.addCapability(NET_CAPABILITY_INTERNET)
@@ -344,7 +382,8 @@
                     |  "user-portal-url": "https://login.capport.android.com",
                     |  "venue-info-url": "https://venueinfo.capport.android.com"
                     |}
-                """.trimMargin()))
+                """.trimMargin()
+        ))
 
         // Tests will fail if a non-mocked query is received: mock the HTTPS probe, but not the
         // HTTP probe as it should not be sent.
@@ -398,8 +437,10 @@
                 BpfUtils.BPF_CGROUP_INET_EGRESS,
                 BpfUtils.BPF_CGROUP_INET_SOCK_CREATE
         ).forEach {
-            val ret = SystemUtil.runShellCommand(InstrumentationRegistry.getInstrumentation(),
-                    "cmd connectivity bpf-get-cgroup-program-id $it").trim()
+            val ret = SystemUtil.runShellCommand(
+                InstrumentationRegistry.getInstrumentation(),
+                    "cmd connectivity bpf-get-cgroup-program-id $it"
+            ).trim()
 
             assertTrue(Integer.parseInt(ret) > 0, "Unexpected output $ret for type $it")
         }
diff --git a/tests/unit/java/com/android/server/BpfNetMapsTest.java b/tests/unit/java/com/android/server/BpfNetMapsTest.java
index fd92672..1d2e8b0 100644
--- a/tests/unit/java/com/android/server/BpfNetMapsTest.java
+++ b/tests/unit/java/com/android/server/BpfNetMapsTest.java
@@ -109,6 +109,7 @@
 import com.android.net.module.util.bpf.IngressDiscardKey;
 import com.android.net.module.util.bpf.IngressDiscardValue;
 import com.android.net.module.util.bpf.LocalNetAccessKey;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
@@ -170,6 +171,8 @@
     @Mock INetd mNetd;
     @Mock BpfNetMaps.Dependencies mDeps;
     @Mock Context mContext;
+
+    @Mock InterfaceTracker mInterfaceTracker;
     private final IBpfMap<S32, U32> mConfigurationMap = new TestBpfMap<>(S32.class, U32.class);
     private final IBpfMap<S32, UidOwnerValue> mUidOwnerMap =
             new TestBpfMap<>(S32.class, UidOwnerValue.class);
@@ -188,6 +191,7 @@
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         doReturn(TEST_IF_INDEX).when(mDeps).getIfIndex(TEST_IF_NAME);
+        doReturn(TEST_IF_INDEX).when(mInterfaceTracker).getInterfaceIndex(TEST_IF_NAME);
         doReturn(TEST_IF_NAME).when(mDeps).getIfName(TEST_IF_INDEX);
         doReturn(0).when(mDeps).synchronizeKernelRCU();
         BpfNetMaps.setConfigurationMapForTest(mConfigurationMap);
@@ -202,7 +206,7 @@
         BpfNetMaps.setDataSaverEnabledMapForTest(mDataSaverEnabledMap);
         mDataSaverEnabledMap.updateEntry(DATA_SAVER_ENABLED_KEY, new U8(DATA_SAVER_DISABLED));
         BpfNetMaps.setIngressDiscardMapForTest(mIngressDiscardMap);
-        mBpfNetMaps = new BpfNetMaps(mContext, mNetd, mDeps);
+        mBpfNetMaps = new BpfNetMaps(mContext, mNetd, mDeps, mInterfaceTracker);
     }
 
     @Test
@@ -266,6 +270,18 @@
 
     @Test
     @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddLocalNetAccessWithNullInterfaceAfterV() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, null,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        // As we tried to add null interface, it would be skipped and map should be empty.
+        assertTrue(mLocalNetAccessMap.isEmpty());
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testAddLocalNetAccessAfterVWithIncorrectInterface() throws Exception {
         assertTrue(mLocalNetAccessMap.isEmpty());
 
@@ -303,6 +319,13 @@
     }
 
     @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testGetLocalNetAccessWithNullInterfaceAfterV() throws Exception {
+        assertTrue(mBpfNetMaps.getLocalNetAccess(160, null,
+                Inet4Address.getByName("100.68.0.0"), 0, 0));
+    }
+
+    @Test
     @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testRemoveLocalNetAccessBeforeV() {
         assertThrows(UnsupportedOperationException.class, () ->
@@ -350,6 +373,25 @@
     }
 
     @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemoveLocalNetAccessAfterVWithNullInterface() throws Exception {
+        assertTrue(mLocalNetAccessMap.isEmpty());
+
+        mBpfNetMaps.addLocalNetAccess(160, TEST_IF_NAME,
+                Inet4Address.getByName("196.68.0.0"), 0, 0, true);
+
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+        assertNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("100.68.0.0"), 0, 0)));
+
+        mBpfNetMaps.removeLocalNetAccess(160, null,
+                Inet4Address.getByName("196.68.0.0"), 0, 0);
+        assertNotNull(mLocalNetAccessMap.getValue(new LocalNetAccessKey(160, TEST_IF_INDEX,
+                Inet4Address.getByName("196.68.0.0"), 0, 0)));
+    }
+
+    @Test
     @IgnoreAfter(Build.VERSION_CODES.VANILLA_ICE_CREAM)
     public void testAddUidToLocalNetBlockMapBeforeV() {
         assertThrows(UnsupportedOperationException.class, () ->
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index e4ff635..c28a0f8 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -163,10 +163,7 @@
 import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_HIGH;
 import static android.telephony.DataConnectionRealTimeInfo.DC_POWER_STATE_LOW;
 
-import static com.android.server.ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK;
 import static com.android.net.module.util.DeviceConfigUtils.TETHERING_MODULE_NAME;
-import static com.android.server.ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS;
-import static com.android.server.ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION;
 import static com.android.server.ConnectivityService.MAX_NETWORK_REQUESTS_PER_SYSTEM_UID;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_MOBILE_DATA_PREFERERRED;
 import static com.android.server.ConnectivityService.PREFERENCE_ORDER_OEM;
@@ -177,9 +174,6 @@
 import static com.android.server.ConnectivityServiceTestUtils.transportToLegacyType;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackRegister;
 import static com.android.server.NetworkAgentWrapper.CallbackType.OnQosCallbackUnregister;
-import static com.android.server.connectivity.ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN;
-import static com.android.server.connectivity.ConnectivityFlags.DELAY_DESTROY_SOCKETS;
-import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.ConcurrentUtils.await;
 import static com.android.testutils.ConcurrentUtils.durationOf;
@@ -419,6 +413,7 @@
 import com.android.server.connectivity.ClatCoordinator;
 import com.android.server.connectivity.ConnectivityFlags;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.connectivity.InterfaceTracker;
 import com.android.server.connectivity.KeepaliveTracker;
 import com.android.server.connectivity.MultinetworkPolicyTracker;
 import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies;
@@ -2185,32 +2180,30 @@
                 case ConnectivityFlags.REQUEST_RESTRICTED_WIFI:
                 case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS:
                 case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS:
-                case KEY_DESTROY_FROZEN_SOCKETS_VERSION:
+                case ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN:
+                case ConnectivityService.KEY_DESTROY_FROZEN_SOCKETS_VERSION:
                     return true;
                 default:
-                    return super.isFeatureEnabled(context, name);
+                    // This is a unit test and must never depend on actual device flag values.
+                    throw new UnsupportedOperationException("Unknown flag " + name
+                            + ", update this test");
             }
         }
 
         @Override
         public boolean isFeatureNotChickenedOut(Context context, String name) {
             switch (name) {
-                case ALLOW_SYSUI_CONNECTIVITY_REPORTS:
-                    return true;
-                case ALLOW_SATALLITE_NETWORK_FALLBACK:
-                    return true;
-                case INGRESS_TO_VPN_ADDRESS_FILTERING:
-                    return true;
-                case BACKGROUND_FIREWALL_CHAIN:
-                    return true;
-                case DELAY_DESTROY_SOCKETS:
-                    return true;
+                case ConnectivityService.ALLOW_SYSUI_CONNECTIVITY_REPORTS:
+                case ConnectivityService.ALLOW_SATALLITE_NETWORK_FALLBACK:
+                case ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING:
+                case ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN:
+                case ConnectivityFlags.DELAY_DESTROY_SOCKETS:
                 case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS:
-                    return true;
                 case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS:
                     return true;
                 default:
-                    return super.isFeatureNotChickenedOut(context, name);
+                    throw new UnsupportedOperationException("Unknown flag " + name
+                            + ", update this test");
             }
         }
 
@@ -2270,7 +2263,8 @@
         }
 
         @Override
-        public BpfNetMaps getBpfNetMaps(Context context, INetd netd) {
+        public BpfNetMaps getBpfNetMaps(Context context, INetd netd,
+                InterfaceTracker interfaceTracker) {
             return mBpfNetMaps;
         }
 
@@ -2451,6 +2445,10 @@
 
     @After
     public void tearDown() throws Exception {
+        // Don't attempt to tear down if setUp didn't even get as far as creating the service.
+        // Otherwise, exceptions here will mask the actual exception in setUp, making failures
+        // harder to diagnose.
+        if (mService == null) return;
         unregisterDefaultNetworkCallbacks();
         maybeTearDownEnterpriseNetwork();
         setAlwaysOnNetworks(false);
@@ -4044,7 +4042,7 @@
 
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI, callbacks);
 
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             assertEquals("onNetworkCreated", eventOrder.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         } else {
             assertNull(eventOrder.poll());
@@ -4057,7 +4055,7 @@
         // connected.
         // TODO: fix this bug, file the request before connecting, and remove the waitForIdle.
         mWiFiAgent.connectWithoutInternet();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             assertEquals("onNetworkCreated", eventOrder.poll(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         } else {
             waitForIdle();
@@ -7945,8 +7943,8 @@
         // Simple connection with initial LP should have updated ifaces.
         mCellAgent.connect(false);
         waitForIdle();
-        List<Network> allNetworks = mService.shouldCreateNetworksImmediately()
-                ? cellAndWifi() : onlyCell();
+        List<Network> allNetworks = mService.shouldCreateNetworksImmediately(
+                mCellAgent.getNetworkCapabilities()) ? cellAndWifi() : onlyCell();
         expectNotifyNetworkStatus(allNetworks, onlyCell(), MOBILE_IFNAME);
         reset(mStatsManager);
 
@@ -8258,7 +8256,7 @@
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
         final int netId = mCellAgent.getNetwork().netId;
         waitForIdle();
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         } else {
             verify(mMockDnsResolver, never()).setResolverConfiguration(any());
@@ -8278,7 +8276,7 @@
         mCellAgent.sendLinkProperties(cellLp);
         mCellAgent.connect(false);
         waitForIdle();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             // CS tells dnsresolver about the empty DNS config for this network.
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         }
@@ -8401,7 +8399,7 @@
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR);
         final int netId = mCellAgent.getNetwork().netId;
         waitForIdle();
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         } else {
             verify(mMockDnsResolver, never()).setResolverConfiguration(any());
@@ -8424,7 +8422,7 @@
         mCellAgent.sendLinkProperties(cellLp);
         mCellAgent.connect(false);
         waitForIdle();
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mCellAgent.getNetworkCapabilities())) {
             verify(mMockDnsResolver, times(1)).createNetworkCache(netId);
         }
         verify(mMockDnsResolver, atLeastOnce()).setResolverConfiguration(
@@ -16069,13 +16067,13 @@
 
         final TestNetworkAgentWrapper workAgent =
                 makeEnterpriseNetworkAgent(profileNetworkPreference.getPreferenceEnterpriseId());
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
             expectNativeNetworkCreated(workAgent.getNetwork().netId, INetd.PERMISSION_SYSTEM,
                     null /* iface */, inOrder);
         }
         if (connectWorkProfileAgentAhead) {
             workAgent.connect(false);
-            if (!mService.shouldCreateNetworksImmediately()) {
+            if (!mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
                 expectNativeNetworkCreated(workAgent.getNetwork().netId, INetd.PERMISSION_SYSTEM,
                         null /* iface */, inOrder);
             }
@@ -16118,7 +16116,7 @@
 
         if (!connectWorkProfileAgentAhead) {
             workAgent.connect(false);
-            if (!mService.shouldCreateNetworksImmediately()) {
+            if (!mService.shouldCreateNetworksImmediately(workAgent.getNetworkCapabilities())) {
                 inOrder.verify(mMockNetd).networkCreate(
                         nativeNetworkConfigPhysical(workAgent.getNetwork().netId,
                                 INetd.PERMISSION_SYSTEM));
@@ -18829,7 +18827,7 @@
     }
 
     private void verifyMtuSetOnWifiInterfaceOnlyUpToT(int mtu) throws Exception {
-        if (!mService.shouldCreateNetworksImmediately()) {
+        if (!mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             verify(mMockNetd, times(1)).interfaceSetMtu(WIFI_IFNAME, mtu);
         } else {
             verify(mMockNetd, never()).interfaceSetMtu(eq(WIFI_IFNAME), anyInt());
@@ -18837,7 +18835,7 @@
     }
 
     private void verifyMtuSetOnWifiInterfaceOnlyStartingFromU(int mtu) throws Exception {
-        if (mService.shouldCreateNetworksImmediately()) {
+        if (mService.shouldCreateNetworksImmediately(mWiFiAgent.getNetworkCapabilities())) {
             verify(mMockNetd, times(1)).interfaceSetMtu(WIFI_IFNAME, mtu);
         } else {
             verify(mMockNetd, never()).interfaceSetMtu(eq(WIFI_IFNAME), anyInt());
diff --git a/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java b/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java
new file mode 100644
index 0000000..8a9ada0
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/InterfaceTrackerTest.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.doReturn;
+
+import android.content.Context;
+import android.os.Build;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.testutils.ConnectivityModuleTest;
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@RunWith(DevSdkIgnoreRunner.class)
+@SmallTest
+@ConnectivityModuleTest
+public class InterfaceTrackerTest {
+    private static final String TAG = "InterfaceTrackerTest";
+    private static final String TEST_IF_NAME = "wlan10";
+    private static final String TEST_INCORRECT_IF_NAME = "wlan20";
+    private static final int TEST_IF_INDEX = 7;
+
+    @Rule
+    public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
+
+    private InterfaceTracker mInterfaceTracker;
+
+    @Mock Context mContext;
+    @Mock InterfaceTracker.Dependencies mDeps;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        doReturn(TEST_IF_INDEX).when(mDeps).getIfIndex(TEST_IF_NAME);
+        mInterfaceTracker = new InterfaceTracker(mContext, mDeps);
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingInterface_InterfaceNameIndexMappingAdded() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingNullInterface_InterfaceNameIndexMappingNotAdded() {
+        mInterfaceTracker.addInterface(null);
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testAddingIncorrectInterface_InterfaceNameIndexMappingNotAdded() {
+        mInterfaceTracker.addInterface(TEST_INCORRECT_IF_NAME);
+
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_INCORRECT_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingInterface_InterfaceNameIndexMappingRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+        mInterfaceTracker.removeInterface(TEST_IF_NAME);
+        assertEquals(0, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingNullInterface_InterfaceNameIndexMappingNotRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        mInterfaceTracker.removeInterface(null);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+    @Test
+    @IgnoreUpTo(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+    public void testRemovingIncorrectInterface_InterfaceNameIndexMappingNotRemoved() {
+        mInterfaceTracker.addInterface(TEST_IF_NAME);
+        mInterfaceTracker.removeInterface(TEST_INCORRECT_IF_NAME);
+        assertEquals(TEST_IF_INDEX, mInterfaceTracker.getInterfaceIndex(TEST_IF_NAME));
+    }
+
+}
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt
new file mode 100644
index 0000000..67fb428
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/mdns/DiscoveryExecutorTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity.mdns
+
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.testing.TestableLooper
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val DEFAULT_TIMEOUT = 2000L
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class DiscoveryExecutorTest {
+    private val thread = HandlerThread(DiscoveryExecutorTest::class.simpleName).apply { start() }
+    private val handler by lazy { Handler(thread.looper) }
+    private val testableLooper by lazy { TestableLooper(thread.looper) }
+
+    @After
+    fun tearDown() {
+        thread.quitSafely()
+        thread.join()
+    }
+
+    @Test
+    fun testCheckAndRunOnHandlerThread() {
+        val executor = DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        try {
+            val future = CompletableFuture<Boolean>()
+            executor.checkAndRunOnHandlerThread { future.complete(true) }
+            testableLooper.processAllMessages()
+            assertTrue(future.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+
+        // Create a DiscoveryExecutor with the null defaultLooper and verify the task can execute
+        // normally.
+        val executor2 = DiscoveryExecutor(
+                null /* defaultLooper */,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        val future2 = CompletableFuture<Boolean>()
+        executor2.checkAndRunOnHandlerThread { future2.complete(true) }
+        assertTrue(future2.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        executor2.shutDown()
+    }
+
+    private fun verifyExecute(executor: DiscoveryExecutor) {
+        try {
+            val future = CompletableFuture<Boolean>()
+            executor.execute { future.complete(true) }
+            assertFalse(future.isDone)
+            testableLooper.processAllMessages()
+            assertTrue(future.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+
+    @Test
+    fun testExecute() {
+        verifyExecute(DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        ))
+    }
+
+    @Test
+    fun testExecute_RealtimeScheduler() {
+        verifyExecute(DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build()
+        ))
+    }
+
+    @Test
+    fun testExecuteDelayed() {
+        val executor = DiscoveryExecutor(
+                testableLooper.looper,
+                MdnsFeatureFlags.newBuilder().build()
+        )
+        try {
+            // Verify the executeDelayed method
+            val future = CompletableFuture<Boolean>()
+            // Schedule a task with 999 ms delay
+            executor.executeDelayed({ future.complete(true) }, 999L)
+            testableLooper.processAllMessages()
+            assertFalse(future.isDone)
+
+            // 500 ms have elapsed but do not exceed the target time (999 ms)
+            // The function should not be executed.
+            testableLooper.moveTimeForward(500L)
+            testableLooper.processAllMessages()
+            assertFalse(future.isDone)
+
+            // 500 ms have elapsed again and have exceeded the target time (999 ms).
+            // The function should be executed.
+            testableLooper.moveTimeForward(500L)
+            testableLooper.processAllMessages()
+            assertTrue(future.get(500L, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+
+    @Test
+    fun testExecuteDelayed_RealtimeScheduler() {
+        val executor = DiscoveryExecutor(
+                thread.looper,
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build()
+        )
+        try {
+            // Verify the executeDelayed method
+            val future = CompletableFuture<Boolean>()
+            // Schedule a task with 50ms delay
+            executor.executeDelayed({ future.complete(true) }, 50L)
+            assertTrue(future.get(500L, TimeUnit.MILLISECONDS))
+        } finally {
+            testableLooper.destroy()
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
index e6e6ecc..087617a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -99,6 +99,13 @@
     network = TEST_NETWORK_1
 }
 
+private val GOOGLEZONE_SERVICE = NsdServiceInfo("TestServiceName", "_GOOglezone._tcp").apply {
+    subtypes = setOf(TEST_SUBTYPE)
+    port = 12345
+    hostAddresses = listOf(TEST_ADDR)
+    network = TEST_NETWORK_1
+}
+
 private val SERVICE_1_SUBTYPE = NsdServiceInfo("TestServiceName", "_advertisertest._tcp").apply {
     subtypes = setOf(TEST_SUBTYPE)
     port = 12345
@@ -183,6 +190,10 @@
     "5:_otherprioritytest._tcp"
 )
 
+private val SERVICES_DENY_LIST = arrayOf(
+    "_googlezone._tcp",
+)
+
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsAdvertiserTest {
@@ -247,6 +258,9 @@
         doReturn(SERVICES_PRIORITY_LIST).`when`(resources).getStringArray(
             R.array.config_nsdOffloadServicesPriority
         )
+        doReturn(SERVICES_DENY_LIST).`when`(resources).getStringArray(
+            R.array.config_nsdOffloadServicesDenyList
+        )
         ConnectivityResources.setResourcesContextForTest(context)
     }
 
@@ -524,6 +538,44 @@
     }
 
     @Test
+    fun testAddService_NoOffloadForServiceTypeInDenyList() {
+        val advertiser =
+            MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
+        postSync {
+            advertiser.addOrUpdateService(
+                SERVICE_ID_1,
+                GOOGLEZONE_SERVICE,
+                DEFAULT_ADVERTISING_OPTION,
+                TEST_CLIENT_UID_1
+            )
+        }
+        val socketCbCaptor = ArgumentCaptor.forClass(SocketCallback::class.java)
+        verify(socketProvider).requestSocket(eq(SERVICE_1.network), socketCbCaptor.capture())
+
+        val socketCb = socketCbCaptor.value
+        postSync { socketCb.onSocketCreated(TEST_SOCKETKEY_1, mockSocket1, listOf(TEST_LINKADDR)) }
+
+        val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
+        verify(mockDeps).makeAdvertiser(
+            eq(mockSocket1),
+            eq(listOf(TEST_LINKADDR)),
+            eq(thread.looper),
+            any(),
+            intAdvCbCaptor1.capture(),
+            eq(TEST_HOSTNAME),
+            any(),
+            any()
+        )
+
+        doReturn(false).`when`(mockInterfaceAdvertiser1).isProbing(SERVICE_ID_1)
+        postSync {
+            intAdvCbCaptor1.value.onServiceProbingSucceeded(mockInterfaceAdvertiser1, SERVICE_ID_1)
+        }
+
+        verify(cb, never()).onOffloadStartOrUpdate(eq(TEST_INTERFACE1), any())
+    }
+
+    @Test
     fun testAddService_NoSubtypeForGoogleCastOffload() {
         val advertiser =
             MdnsAdvertiser(thread.looper, socketProvider, cb, mockDeps, sharedlog, flags, context)
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
index 71a3274..758b822 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -19,8 +19,6 @@
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
@@ -35,12 +33,10 @@
 import android.net.Network;
 import android.os.Handler;
 import android.os.HandlerThread;
-import android.testing.TestableLooper;
 import android.text.TextUtils;
 import android.util.Pair;
 
 import com.android.net.module.util.SharedLog;
-import com.android.server.connectivity.mdns.MdnsDiscoveryManager.DiscoveryExecutor;
 import com.android.server.connectivity.mdns.MdnsSocketClientBase.SocketCreationCallback;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -60,9 +56,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.TimeUnit;
 
 /** Tests for {@link MdnsDiscoveryManager}. */
 @DevSdkIgnoreRunner.MonitorThreadLeak
@@ -435,45 +429,6 @@
     }
 
     @Test
-    public void testDiscoveryExecutor() throws Exception {
-        final TestableLooper testableLooper = new TestableLooper(thread.getLooper());
-        final DiscoveryExecutor executor = new DiscoveryExecutor(testableLooper.getLooper());
-        try {
-            // Verify the checkAndRunOnHandlerThread method
-            final CompletableFuture<Boolean> future1 = new CompletableFuture<>();
-            executor.checkAndRunOnHandlerThread(()-> future1.complete(true));
-            assertTrue(future1.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS));
-
-            // Verify the execute method
-            final CompletableFuture<Boolean> future2 = new CompletableFuture<>();
-            executor.execute(()-> future2.complete(true));
-            testableLooper.processAllMessages();
-            assertTrue(future2.get(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS));
-
-            // Verify the executeDelayed method
-            final CompletableFuture<Boolean> future3 = new CompletableFuture<>();
-            // Schedule a task with 999 ms delay
-            executor.executeDelayed(()-> future3.complete(true), 999L);
-            testableLooper.processAllMessages();
-            assertFalse(future3.isDone());
-
-            // 500 ms have elapsed but do not exceed the target time (999 ms)
-            // The function should not be executed.
-            testableLooper.moveTimeForward(500L);
-            testableLooper.processAllMessages();
-            assertFalse(future3.isDone());
-
-            // 500 ms have elapsed again and have exceeded the target time (999 ms).
-            // The function should be executed.
-            testableLooper.moveTimeForward(500L);
-            testableLooper.processAllMessages();
-            assertTrue(future3.get(500L, TimeUnit.MILLISECONDS));
-        } finally {
-            testableLooper.destroy();
-        }
-    }
-
-    @Test
     public void testRemoveServicesAfterAllListenersUnregistered() throws IOException {
         final MdnsFeatureFlags mdnsFeatureFlags = MdnsFeatureFlags.newBuilder()
                 .setIsCachedServicesRemovalEnabled(true)
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
index 0a8f108..2ebe87a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
@@ -21,6 +21,7 @@
 import android.os.HandlerThread
 import com.android.net.module.util.ArrayTrackRecord
 import com.android.server.connectivity.mdns.MdnsServiceCache.CacheKey
+import com.android.server.connectivity.mdns.MdnsServiceCache.CachedService
 import com.android.server.connectivity.mdns.MdnsServiceCacheTest.ExpiredRecord.ExpiredEvent.ServiceRecordExpired
 import com.android.server.connectivity.mdns.util.MdnsUtils
 import com.android.testutils.DevSdkIgnoreRule
@@ -208,7 +209,10 @@
     @Test
     fun testServiceExpiredAndSendCallbacks() {
         val serviceCache = MdnsServiceCache(
-                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+                thread.looper,
+            makeFlags(isExpiredServicesRemovalEnabled = true),
+            clock
+        )
         // Register service expired callbacks
         val callback1 = ExpiredRecord()
         val callback2 = ExpiredRecord()
@@ -218,12 +222,21 @@
         doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
 
         // Add multiple services with different ttl time.
-        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1,
-                DEFAULT_TTL_TIME_MS))
-        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1,
-                DEFAULT_TTL_TIME_MS + 20L))
-        addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_3, SERVICE_TYPE_2,
-                DEFAULT_TTL_TIME_MS + 10L))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(
+            SERVICE_NAME_1,
+            SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS
+        ))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(
+            SERVICE_NAME_2,
+            SERVICE_TYPE_1,
+                DEFAULT_TTL_TIME_MS + 20L
+        ))
+        addOrUpdateService(serviceCache, cacheKey2, createResponse(
+            SERVICE_NAME_3,
+            SERVICE_TYPE_2,
+                DEFAULT_TTL_TIME_MS + 10L
+        ))
 
         // Check the service expiration immediately. Should be no callback.
         assertEquals(2, getServices(serviceCache, cacheKey1).size)
@@ -252,48 +265,65 @@
     @Test
     fun testRemoveExpiredServiceWhenGetting() {
         val serviceCache = MdnsServiceCache(
-                thread.looper, makeFlags(isExpiredServicesRemovalEnabled = true), clock)
+                thread.looper,
+            makeFlags(isExpiredServicesRemovalEnabled = true),
+            clock
+        )
 
         doReturn(TEST_ELAPSED_REALTIME_MS).`when`(clock).elapsedRealtime()
-        addOrUpdateService(serviceCache, cacheKey1,
-                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 1L /* ttlTime */))
+        addOrUpdateService(
+            serviceCache,
+            cacheKey1,
+                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 1L /* ttlTime */)
+        )
         doReturn(TEST_ELAPSED_REALTIME_MS + 2L).`when`(clock).elapsedRealtime()
         assertNull(getService(serviceCache, SERVICE_NAME_1, cacheKey1))
 
-        addOrUpdateService(serviceCache, cacheKey2,
-                createResponse(SERVICE_NAME_2, SERVICE_TYPE_2, 3L /* ttlTime */))
+        addOrUpdateService(
+            serviceCache,
+            cacheKey2,
+                createResponse(SERVICE_NAME_2, SERVICE_TYPE_2, 3L /* ttlTime */)
+        )
         doReturn(TEST_ELAPSED_REALTIME_MS + 4L).`when`(clock).elapsedRealtime()
         assertEquals(0, getServices(serviceCache, cacheKey2).size)
     }
 
     @Test
     fun testInsertResponseAndSortList() {
-        val responses = ArrayList<MdnsResponse>()
-        val response1 = createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 100L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response1, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(1, responses.size)
-        assertEquals(response1, responses[0])
+        val services = ArrayList<CachedService>()
+        val service1 = CachedService(
+                createResponse(SERVICE_NAME_1, SERVICE_TYPE_1, 100L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service1, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(1, services.size)
+        assertEquals(service1, services[0])
 
-        val response2 = createResponse(SERVICE_NAME_2, SERVICE_TYPE_1, 50L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response2, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(2, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response1, responses[1])
+        val service2 = CachedService(
+                createResponse(SERVICE_NAME_2, SERVICE_TYPE_1, 50L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service2, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(2, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service1, services[1])
 
-        val response3 = createResponse(SERVICE_NAME_3, SERVICE_TYPE_1, 75L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response3, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(3, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response3, responses[1])
-        assertEquals(response1, responses[2])
+        val service3 = CachedService(
+                createResponse(SERVICE_NAME_3, SERVICE_TYPE_1, 75L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service3, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(3, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service3, services[1])
+        assertEquals(service1, services[2])
 
-        val response4 = createResponse("service-instance-4", SERVICE_TYPE_1, 125L /* ttlTime */)
-        MdnsServiceCache.insertResponseAndSortList(responses, response4, TEST_ELAPSED_REALTIME_MS)
-        assertEquals(4, responses.size)
-        assertEquals(response2, responses[0])
-        assertEquals(response3, responses[1])
-        assertEquals(response1, responses[2])
-        assertEquals(response4, responses[3])
+        val service4 = CachedService(
+                createResponse("service-instance-4", SERVICE_TYPE_1, 125L /* ttlTime */)
+        )
+        MdnsServiceCache.insertServiceAndSortList(services, service4, TEST_ELAPSED_REALTIME_MS)
+        assertEquals(4, services.size)
+        assertEquals(service2, services[0])
+        assertEquals(service3, services[1])
+        assertEquals(service1, services[2])
+        assertEquals(service4, services[3])
     }
 
     @Test
@@ -334,8 +364,11 @@
     ): MdnsResponse {
         val serviceName = "$serviceInstanceName.$serviceType".split(".").toTypedArray()
         val response = MdnsResponse(
-                0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
-                socketKey.interfaceIndex, socketKey.network)
+                0 /* now */,
+            "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
+                socketKey.interfaceIndex,
+            socketKey.network
+        )
 
         // Set PTR record
         val pointerRecord = MdnsPointerRecord(
@@ -343,7 +376,8 @@
                 TEST_ELAPSED_REALTIME_MS /* receiptTimeMillis */,
                 false /* cacheFlush */,
                 ttlTime /* ttlMillis */,
-                serviceName)
+                serviceName
+        )
         response.addPointerRecord(pointerRecord)
 
         // Set SRV record.
@@ -355,7 +389,8 @@
                 0 /* servicePriority */,
                 0 /* serviceWeight */,
                 12345 /* port */,
-                arrayOf("hostname"))
+                arrayOf("hostname")
+        )
         response.serviceRecord = serviceRecord
         return response
     }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index 67f9d9c..b9c0d2f 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -127,6 +127,8 @@
     private SharedLog mockSharedLog;
     @Mock
     private MdnsServiceTypeClient.Dependencies mockDeps;
+    @Mock
+    private Scheduler mockScheduler;
     @Captor
     private ArgumentCaptor<MdnsServiceInfo> serviceInfoCaptor;
 
@@ -145,6 +147,7 @@
     private Message delayMessage = null;
     private Handler realHandler = null;
     private MdnsFeatureFlags featureFlags = MdnsFeatureFlags.newBuilder().build();
+    private Message message = null;
 
     @Before
     @SuppressWarnings("DoNotMock")
@@ -244,10 +247,21 @@
             return true;
         }).when(mockDeps).sendMessage(any(Handler.class), any(Message.class));
 
-        client = makeMdnsServiceTypeClient();
+        doAnswer(inv -> {
+            realHandler = (Handler) inv.getArguments()[0];
+            return mockScheduler;
+        }).when(mockDeps).createScheduler(any(Handler.class));
+
+        doAnswer(inv -> {
+            message = (Message) inv.getArguments()[0];
+            latestDelayMs = (long) inv.getArguments()[1];
+            return null;
+        }).when(mockScheduler).sendDelayedMessage(any(), anyLong());
+
+        client = makeMdnsServiceTypeClient(featureFlags);
     }
 
-    private MdnsServiceTypeClient makeMdnsServiceTypeClient() {
+    private MdnsServiceTypeClient makeMdnsServiceTypeClient(MdnsFeatureFlags featureFlags) {
         return new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
                 mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
                 serviceCache, featureFlags);
@@ -1926,9 +1940,7 @@
 
     @Test
     public void testSendQueryWithKnownAnswers() throws Exception {
-        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
-                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
-                serviceCache,
+        client = makeMdnsServiceTypeClient(
                 MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
 
         doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
@@ -1990,9 +2002,7 @@
 
     @Test
     public void testSendQueryWithSubTypeWithKnownAnswers() throws Exception {
-        client = new MdnsServiceTypeClient(SERVICE_TYPE, mockSocketClient, currentThreadExecutor,
-                mockDecoderClock, socketKey, mockSharedLog, thread.getLooper(), mockDeps,
-                serviceCache,
+        client = makeMdnsServiceTypeClient(
                 MdnsFeatureFlags.newBuilder().setIsQueryWithKnownAnswerEnabled(true).build());
 
         doCallRealMethod().when(mockDeps).getDatagramPacketsFromMdnsPacket(
@@ -2114,6 +2124,73 @@
         assertEquals(9680L, latestDelayMs);
     }
 
+    @Test
+    public void sendQueries_AccurateDelayCallback() {
+        client = makeMdnsServiceTypeClient(
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build());
+
+        final int numOfQueriesBeforeBackoff = 2;
+        final MdnsSearchOptions searchOptions = MdnsSearchOptions.newBuilder()
+                .addSubtype(SUBTYPE)
+                .setQueryMode(AGGRESSIVE_QUERY_MODE)
+                .setNumOfQueriesBeforeBackoff(numOfQueriesBeforeBackoff)
+                .build();
+        startSendAndReceive(mockListenerOne, searchOptions);
+        verify(mockScheduler, times(1)).removeDelayedMessage(EVENT_START_QUERYTASK);
+
+        // Verify that the first query has been sent.
+        verifyAndSendQuery(0 /* index */, 0 /* timeInMs */, true /* expectsUnicastResponse */,
+                true /* multipleSocketDiscovery */, 1 /* scheduledCount */,
+                1 /* sendMessageCount */, true /* useAccurateDelayCallback */);
+
+        // Verify that the second query has been sent
+        verifyAndSendQuery(1 /* index */, 0 /* timeInMs */, false /* expectsUnicastResponse */,
+                true /* multipleSocketDiscovery */, 2 /* scheduledCount */,
+                2 /* sendMessageCount */, true /* useAccurateDelayCallback */);
+
+        // Verify that the third query has been sent
+        verifyAndSendQuery(2 /* index */, TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS,
+                false /* expectsUnicastResponse */, true /* multipleSocketDiscovery */,
+                3 /* scheduledCount */, 3 /* sendMessageCount */,
+                true /* useAccurateDelayCallback */);
+
+        // In backoff mode, the current scheduled task will be canceled and reschedule if the
+        // 0.8 * smallestRemainingTtl is larger than time to next run.
+        long currentTime = TEST_TTL / 2 + TEST_ELAPSED_REALTIME;
+        doReturn(currentTime).when(mockDecoderClock).elapsedRealtime();
+        doReturn(true).when(mockScheduler).hasDelayedMessage(EVENT_START_QUERYTASK);
+        processResponse(createResponse(
+                "service-instance-1", "192.0.2.123", 5353,
+                SERVICE_TYPE_LABELS,
+                Collections.emptyMap(), TEST_TTL), socketKey);
+        // Verify that the message removal occurred.
+        verify(mockScheduler, times(6)).removeDelayedMessage(EVENT_START_QUERYTASK);
+        assertNotNull(message);
+        verifyAndSendQuery(3 /* index */, (long) (TEST_TTL / 2 * 0.8) /* timeInMs */,
+                true /* expectsUnicastResponse */, true /* multipleSocketDiscovery */,
+                5 /* scheduledCount */, 4 /* sendMessageCount */,
+                true /* useAccurateDelayCallback */);
+
+        // Stop sending packets.
+        stopSendAndReceive(mockListenerOne);
+        verify(mockScheduler, times(8)).removeDelayedMessage(EVENT_START_QUERYTASK);
+    }
+
+    @Test
+    public void testTimerFdCloseProperly() {
+        client = makeMdnsServiceTypeClient(
+                MdnsFeatureFlags.newBuilder().setIsAccurateDelayCallbackEnabled(true).build());
+
+        // Start query
+        startSendAndReceive(mockListenerOne, MdnsSearchOptions.newBuilder().build());
+        verify(mockScheduler, times(1)).removeDelayedMessage(EVENT_START_QUERYTASK);
+
+        // Stop query and verify the close() method has been called.
+        stopSendAndReceive(mockListenerOne);
+        verify(mockScheduler, times(2)).removeDelayedMessage(EVENT_START_QUERYTASK);
+        verify(mockScheduler).close();
+    }
+
     private static MdnsServiceInfo matchServiceName(String name) {
         return argThat(info -> info.getServiceInstanceName().equals(name));
     }
@@ -2127,9 +2204,22 @@
 
     private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse,
             boolean multipleSocketDiscovery, int scheduledCount) {
-        // Dispatch the message
-        if (delayMessage != null && realHandler != null) {
-            dispatchMessage();
+        verifyAndSendQuery(index, timeInMs, expectsUnicastResponse,
+                multipleSocketDiscovery, scheduledCount, index + 1 /* sendMessageCount */,
+                false /* useAccurateDelayCallback */);
+    }
+
+    private void verifyAndSendQuery(int index, long timeInMs, boolean expectsUnicastResponse,
+            boolean multipleSocketDiscovery, int scheduledCount, int sendMessageCount,
+            boolean useAccurateDelayCallback) {
+        if (useAccurateDelayCallback && message != null && realHandler != null) {
+            runOnHandler(() -> realHandler.dispatchMessage(message));
+            message = null;
+        } else {
+            // Dispatch the message
+            if (delayMessage != null && realHandler != null) {
+                dispatchMessage();
+            }
         }
         assertEquals(timeInMs, latestDelayMs);
         currentThreadExecutor.getAndClearLastScheduledRunnable().run();
@@ -2152,11 +2242,15 @@
                         eq(socketKey), eq(false));
             }
         }
-        verify(mockDeps, times(index + 1))
+        verify(mockDeps, times(sendMessageCount))
                 .sendMessage(any(Handler.class), any(Message.class));
         // Verify the task has been scheduled.
-        verify(mockDeps, times(scheduledCount))
-                .sendMessageDelayed(any(Handler.class), any(Message.class), anyLong());
+        if (useAccurateDelayCallback) {
+            verify(mockScheduler, times(scheduledCount)).sendDelayedMessage(any(), anyLong());
+        } else {
+            verify(mockDeps, times(scheduledCount))
+                    .sendMessageDelayed(any(Handler.class), any(Message.class), anyLong());
+        }
     }
 
     private static String[] getTestServiceName(String instanceName) {
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt
index 5bf6e04..84c9835 100644
--- a/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalNetworkProtectionTest.kt
@@ -38,6 +38,7 @@
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito.never
+import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
 
 private const val LONG_TIMEOUT_MS = 5_000
@@ -45,6 +46,7 @@
 private const val PREFIX_LENGTH_IPV6 = 32
 private const val WIFI_IFNAME = "wlan0"
 private const val WIFI_IFNAME_2 = "wlan1"
+private const val WIFI_IFNAME_3 = "wlan2"
 
 private val wifiNc = NetworkCapabilities.Builder()
         .addTransportType(TRANSPORT_WIFI)
@@ -78,6 +80,20 @@
         LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()
     )
 
+    private val LOCAL_IPV6_IP_ADDRESS_2_PREFIX =
+            IpPrefix("2601:19b:67f:e220:1cf1:35ff:fe8c:db87/64")
+    private val LOCAL_IPV6_LINK_ADDRESS_2 = LinkAddress(
+            LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getAddress(),
+            LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getPrefixLength()
+    )
+
+    private val LOCAL_IPV6_IP_ADDRESS_3_PREFIX =
+            IpPrefix("fe80::/10")
+    private val LOCAL_IPV6_LINK_ADDRESS_3 = LinkAddress(
+            LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getAddress(),
+            LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getPrefixLength()
+    )
+
     private val LOCAL_IPV4_IP_ADDRESS_PREFIX_1 = IpPrefix("10.0.0.184/24")
     private val LOCAL_IPV4_LINK_ADDRRESS_1 =
         LinkAddress(
@@ -190,7 +206,7 @@
     }
 
     @Test
-    fun testStackedLinkPropertiesWithDifferentLinkAddresses_AddressAddedInBpfMap() {
+    fun testAddingThenRemovingStackedLinkProperties_AddressAddedThenRemovedInBpfMap() {
         val nr = nr(TRANSPORT_WIFI)
         val cb = TestableNetworkCallback()
         cm.requestNetwork(nr, cb)
@@ -230,49 +246,6 @@
         )
         // As both addresses are in stacked links, so no address should be removed from the map.
         verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
-    }
-
-    @Test
-    fun testRemovingStackedLinkProperties_AddressRemovedInBpfMap() {
-        val nr = nr(TRANSPORT_WIFI)
-        val cb = TestableNetworkCallback()
-        cm.requestNetwork(nr, cb)
-
-        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
-        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
-        // populating stacked link
-        wifiLp.addStackedLink(wifiLp2)
-        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
-        wifiAgent.connect()
-        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
-
-        // Multicast and Broadcast address should always be populated in local_net_access map
-        verifyPopulationOfMulticastAndBroadcastAddress()
-        // Verifying IPv6 address should be populated in local_net_access map
-        verify(bpfNetMaps).addLocalNetAccess(
-                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
-                eq(WIFI_IFNAME),
-                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
-                eq(0),
-                eq(0),
-                eq(false)
-        )
-
-        // Multicast and Broadcast address should always be populated on stacked link
-        // in local_net_access map
-        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
-        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
-        // in local_net_access map
-        verify(bpfNetMaps).addLocalNetAccess(
-                eq(PREFIX_LENGTH_IPV4 + 8),
-                eq(WIFI_IFNAME_2),
-                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
-                eq(0),
-                eq(0),
-                eq(false)
-        )
-        // As both addresses are in stacked links, so no address should be removed from the map.
-        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
 
         // replacing link properties without stacked links
         val wifiLp_3 = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
@@ -290,6 +263,107 @@
     }
 
     @Test
+    fun testChangeStackedLinkProperties_AddressReplacedBpfMap() {
+        val nr = nr(TRANSPORT_WIFI)
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(nr, cb)
+
+        val wifiLp = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS)
+        val wifiLp2 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_1)
+        // populating stacked link
+        wifiLp.addStackedLink(wifiLp2)
+        val wifiAgent = Agent(nc = wifiNc, lp = wifiLp)
+        wifiAgent.connect()
+        cb.expectAvailableCallbacks(wifiAgent.network, validated = false)
+
+        // Multicast and Broadcast address should always be populated in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress()
+        // Verifying IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+
+        // Multicast and Broadcast address should always be populated on stacked link
+        // in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_2)
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
+        // in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // As both addresses are in stacked links, so no address should be removed from the map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(any(), any(), any(), any(), any())
+
+        // replacing link properties multiple stacked links
+        val wifiLp_3 = lp(WIFI_IFNAME, LOCAL_IPV6_LINK_ADDRESS_2)
+        val wifiLp_4 = lp(WIFI_IFNAME_2, LOCAL_IPV4_LINK_ADDRRESS_2)
+        val wifiLp_5 = lp(WIFI_IFNAME_3, LOCAL_IPV6_LINK_ADDRESS_3)
+        wifiLp_3.addStackedLink(wifiLp_4)
+        wifiLp_3.addStackedLink(wifiLp_5)
+        wifiAgent.sendLinkProperties(wifiLp_3)
+        cb.expect<LinkPropertiesChanged>(wifiAgent.network)
+
+        // Multicast and Broadcast address should always be populated on stacked link
+        // in local_net_access map
+        verifyPopulationOfMulticastAndBroadcastAddress(WIFI_IFNAME_3)
+        // Verifying new base IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_2_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying IPv4 matching prefix(10.0.0.0/8) should be populated as part of stacked link
+        // in local_net_access map
+        verify(bpfNetMaps, times(2)).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying newly stacked IPv6 address should be populated in local_net_access map
+        verify(bpfNetMaps).addLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME_3),
+                eq(LOCAL_IPV6_IP_ADDRESS_3_PREFIX.getAddress()),
+                eq(0),
+                eq(0),
+                eq(false)
+        )
+        // Verifying old base IPv6 address should be removed from local_net_access map
+        verify(bpfNetMaps).removeLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV6 + LOCAL_IPV6_IP_ADDRESS_PREFIX.getPrefixLength()),
+                eq(WIFI_IFNAME),
+                eq(LOCAL_IPV6_IP_ADDRESS_PREFIX.getAddress()),
+                eq(0),
+                eq(0)
+        )
+        // As both stacked links is had same prefix, 10.0.0.0/8 should not be removed from
+        // local_net_access map.
+        verify(bpfNetMaps, never()).removeLocalNetAccess(
+                eq(PREFIX_LENGTH_IPV4 + 8),
+                eq(WIFI_IFNAME_2),
+                eq(InetAddresses.parseNumericAddress("10.0.0.0")),
+                eq(0),
+                eq(0)
+        )
+    }
+
+    @Test
     fun testChangeLinkPropertiesWithLinkAddressesInSameRange_AddressIntactInBpfMap() {
         val nr = nr(TRANSPORT_WIFI)
         val cb = TestableNetworkCallback()
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 557bfd6..d7e781e 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -68,6 +68,7 @@
 import com.android.server.connectivity.CarrierPrivilegeAuthenticator
 import com.android.server.connectivity.ClatCoordinator
 import com.android.server.connectivity.ConnectivityFlags
+import com.android.server.connectivity.InterfaceTracker
 import com.android.server.connectivity.MulticastRoutingCoordinatorService
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.MultinetworkPolicyTrackerTestDependencies
@@ -193,6 +194,7 @@
     val connResources = makeMockConnResources(sysResources, packageManager)
 
     val netd = mock<INetd>()
+    val interfaceTracker = mock<InterfaceTracker>()
     val bpfNetMaps = mock<BpfNetMaps>().also {
         doReturn(PERMISSION_INTERNET).`when`(it).getNetPermForUid(anyInt())
     }
@@ -279,7 +281,11 @@
 
     inner class CSDeps : ConnectivityService.Dependencies() {
         override fun getResources(ctx: Context) = connResources
-        override fun getBpfNetMaps(context: Context, netd: INetd) = this@CSTest.bpfNetMaps
+        override fun getBpfNetMaps(
+            context: Context,
+            netd: INetd,
+            interfaceTracker: InterfaceTracker
+        ) = this@CSTest.bpfNetMaps
         override fun getClatCoordinator(netd: INetd?) = this@CSTest.clatCoordinator
         override fun getNetworkStack() = this@CSTest.networkStack
 
diff --git a/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt b/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt
index 8431194..7ebe384 100644
--- a/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt
+++ b/tests/unit/java/com/android/server/net/HeaderCompressionUtilsTest.kt
@@ -17,12 +17,14 @@
 package com.android.server.net
 
 import android.os.Build
+import com.android.internal.util.HexDump
 import com.android.testutils.ConnectivityModuleTest
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
-import com.android.internal.util.HexDump
 import com.google.common.truth.Truth.assertThat
-
+import java.io.IOException
+import java.nio.BufferUnderflowException
+import kotlin.test.assertFailsWith
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -184,6 +186,83 @@
     }
 
     @Test
+    fun testHeaderDecompression_invalidPacket() {
+        // 1-byte packet
+        var input = "60"
+        assertFailsWith(BufferUnderflowException::class) { decompressHex(input) }
+
+        // Short packet -- incomplete header
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7b2b" +
+                 "44" +                               // next header
+                 "1234"                               // source
+        assertFailsWith(BufferUnderflowException::class) { decompressHex(input) }
+
+        // Packet starts with 0b111 instead of 0b011
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "fb2b" +
+                 "44" +                               // next header
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "abcdef01"                           // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option NH = 1. Note that the packet is not valid as the code should throw as soon
+        // as the illegal option is encountered.
+        // TF: 11, NH: 1, HLIM: 11, CID: 0, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7f2b" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option CID = 1.
+        // TF: 11, NH: 0, HLIM: 11, CID: 1, SAC: 0, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7bab00" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option SAC = 1.
+        // TF: 11, NH: 0, HLIM: 11, CID: 0, SAC: 1, SAM: 10, M: 1, DAC: 0, DAM: 11
+        input  = "7b6b" +
+                 "1234" +                             // source
+                 "89" +                               // dest
+                 "e0"                                 // Hop-by-hop options NHC
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Illegal option DAC = 1.
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 10, M: 0, DAC: 1, DAM: 10
+        input  = "7226" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "1234" +                             // source
+                 "abcd" +                             // dest
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+
+        // Unsupported option DAM = 11
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 10, M: 0, DAC: 0, DAM: 11
+        input  = "7223" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "1234" +                             // source
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+
+        // Unsupported option SAM = 11
+        // TF: 10, NH: 0, HLIM: 10, CID: 0, SAC: 0, SAM: 11, M: 0, DAC: 0, DAM: 10
+        input  = "7232" +
+                 "cc" +                               // traffic class
+                 "43" +                               // next header
+                 "abcd" +                             // dest
+                 "abcdef"                             // payload
+        assertFailsWith(IOException::class) { decompressHex(input) }
+    }
+
+    @Test
     fun testHeaderCompression() {
         val input  = "60120304000011fffe800000000000000000000000000001fe800000000000000000000000000002"
         val output = "60000102030411fffe800000000000000000000000000001fe800000000000000000000000000002"
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 697bf9e..c5929f1 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -65,7 +65,6 @@
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 import static android.text.format.DateUtils.WEEK_IN_MILLIS;
 
-import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
 import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
 import static com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf;
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_POLL;
@@ -96,6 +95,7 @@
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 40842f1..0c38f93 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -16,6 +16,9 @@
     },
     {
       "name": "ThreadNetworkTrelDisabledTests"
+    },
+    {
+      "name": "ThreadBorderRouterIntegrationTests"
     }
   ]
 }
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 73a6bda..b649716 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -227,8 +227,8 @@
      * specific error:
      *
      * <ul>
-     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not
-     *       attached to Thread network
+     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not a
+     *       Border Router or not attached to Thread network
      *   <li>{@link ThreadNetworkException#ERROR_BUSY} when ephemeral key mode is already activated
      *       on the device, caller can recover from this error when the ephemeral key mode gets
      *       deactivated
@@ -267,7 +267,8 @@
      * connection will be terminated.
      *
      * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The call will
-     * always succeed if the device is not in ephemeral key mode.
+     * always succeed if the device is not in ephemeral key mode. It returns an error {@link
+     * ThreadNetworkException#ERROR_FAILED_PRECONDITION} if this device is not a Border Router.
      *
      * @param executor the executor to execute {@code receiver}
      * @param receiver the receiver to receive the result of this operation
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index af16d19..7063357 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -559,7 +559,7 @@
             // The persistent setting keeps the desired enabled state, thus it's set regardless
             // the otDaemon set enabled state operation succeeded or not, so that it can recover
             // to the desired value after reboot.
-            mPersistentSettings.put(ThreadPersistentSettings.THREAD_ENABLED.key, isEnabled);
+            mPersistentSettings.put(ThreadPersistentSettings.KEY_THREAD_ENABLED, isEnabled);
         }
 
         try {
@@ -743,7 +743,7 @@
     private boolean shouldEnableThread() {
         return !mForceStopOtDaemonEnabled
                 && !mUserRestricted
-                && mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED);
+                && mPersistentSettings.get(ThreadPersistentSettings.KEY_THREAD_ENABLED);
     }
 
     private void requestUpstreamNetwork() {
@@ -879,10 +879,8 @@
                         .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED);
         final var scoreBuilder = new NetworkScore.Builder();
 
-        if (isBorderRouterMode()) {
-            netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK);
-            scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK);
-        }
+        netCapsBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK);
+        scoreBuilder.setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK);
 
         return new NetworkAgent(
                 mContext,
@@ -890,7 +888,7 @@
                 LOG.getTag(),
                 netCapsBuilder.build(),
                 getTunIfLinkProperties(),
-                isBorderRouterMode() ? newLocalNetworkConfig() : null,
+                newLocalNetworkConfig(),
                 scoreBuilder.build(),
                 new NetworkAgentConfig.Builder().build(),
                 mNetworkProvider) {
@@ -899,9 +897,8 @@
             @Override
             public void onNetworkUnwanted() {
                 LOG.i("Thread network is unwanted by ConnectivityService");
-                if (!isBorderRouterMode()) {
-                    leave(false /* eraseDataset */, new LoggingOperationReceiver("leave"));
-                }
+                // TODO(b/374037595): leave() the current network when the new APIs for mobile
+                // is available
             }
         };
     }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
index 2cd34e8..a96d06e 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
@@ -16,7 +16,8 @@
 
 package com.android.server.thread;
 
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
 
 import android.annotation.Nullable;
 import android.annotation.StringDef;
@@ -28,11 +29,13 @@
 import android.location.Address;
 import android.location.Geocoder;
 import android.location.Location;
+import android.location.LocationListener;
 import android.location.LocationManager;
 import android.net.thread.IOperationReceiver;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.ActiveCountryCodeChangedCallback;
 import android.os.Build;
+import android.os.Bundle;
 import android.sysprop.ThreadNetworkProperties;
 import android.telephony.SubscriptionInfo;
 import android.telephony.SubscriptionManager;
@@ -115,6 +118,13 @@
             new ArrayMap();
     private final ThreadPersistentSettings mPersistentSettings;
 
+    @Nullable private LocationListener mLocationListener;
+    @Nullable private WifiCountryCodeCallback mWifiCountryCodeCallback;
+    @Nullable private BroadcastReceiver mTelephonyBroadcastReceiver;
+
+    /** Indicates whether the Thread co-processor supports setting the country code. */
+    private boolean mIsCpSettingCountryCodeSupported = true;
+
     @Nullable private CountryCodeInfo mCurrentCountryCodeInfo;
     @Nullable private CountryCodeInfo mLocationCountryCodeInfo;
     @Nullable private CountryCodeInfo mOverrideCountryCodeInfo;
@@ -267,13 +277,40 @@
         updateCountryCode(false /* forceUpdate */);
     }
 
+    private synchronized void unregisterAllCountryCodeCallbacks() {
+        unregisterGeocoderCountryCodeCallback();
+        unregisterWifiCountryCodeCallback();
+        unregisterTelephonyCountryCodeCallback();
+    }
+
     private synchronized void registerGeocoderCountryCodeCallback() {
         if ((mGeocoder != null) && isLocationUseForCountryCodeEnabled()) {
+            mLocationListener =
+                    new LocationListener() {
+                        public void onLocationChanged(Location location) {
+                            setCountryCodeFromGeocodingLocation(location);
+                        }
+
+                        // not used yet
+                        public void onProviderDisabled(String provider) {}
+
+                        public void onProviderEnabled(String provider) {}
+
+                        public void onStatusChanged(String provider, int status, Bundle extras) {}
+                    };
+
             mLocationManager.requestLocationUpdates(
                     LocationManager.PASSIVE_PROVIDER,
                     TIME_BETWEEN_LOCATION_UPDATES_MS,
                     DISTANCE_BETWEEN_LOCALTION_UPDATES_METERS,
-                    location -> setCountryCodeFromGeocodingLocation(location));
+                    mLocationListener);
+        }
+    }
+
+    private synchronized void unregisterGeocoderCountryCodeCallback() {
+        if (mLocationListener != null) {
+            mLocationManager.removeUpdates(mLocationListener);
+            mLocationListener = null;
         }
     }
 
@@ -313,8 +350,16 @@
 
     private synchronized void registerWifiCountryCodeCallback() {
         if (mWifiManager != null) {
+            mWifiCountryCodeCallback = new WifiCountryCodeCallback();
             mWifiManager.registerActiveCountryCodeChangedCallback(
-                    r -> r.run(), new WifiCountryCodeCallback());
+                    r -> r.run(), mWifiCountryCodeCallback);
+        }
+    }
+
+    private synchronized void unregisterWifiCountryCodeCallback() {
+        if ((mWifiManager != null) && (mWifiCountryCodeCallback != null)) {
+            mWifiManager.unregisterActiveCountryCodeChangedCallback(mWifiCountryCodeCallback);
+            mWifiCountryCodeCallback = null;
         }
     }
 
@@ -353,7 +398,7 @@
             return;
         }
 
-        BroadcastReceiver broadcastReceiver =
+        mTelephonyBroadcastReceiver =
                 new BroadcastReceiver() {
                     @Override
                     public void onReceive(Context context, Intent intent) {
@@ -377,11 +422,18 @@
                 };
 
         mContext.registerReceiver(
-                broadcastReceiver,
+                mTelephonyBroadcastReceiver,
                 new IntentFilter(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED),
                 Context.RECEIVER_EXPORTED);
     }
 
+    private synchronized void unregisterTelephonyCountryCodeCallback() {
+        if (mTelephonyBroadcastReceiver != null) {
+            mContext.unregisterReceiver(mTelephonyBroadcastReceiver);
+            mTelephonyBroadcastReceiver = null;
+        }
+    }
+
     private synchronized void updateTelephonyCountryCodeFromSimCard() {
         List<SubscriptionInfo> subscriptionInfoList =
                 mSubscriptionManager.getActiveSubscriptionInfoList();
@@ -496,7 +548,7 @@
             return mLocationCountryCodeInfo;
         }
 
-        String settingsCountryCode = mPersistentSettings.get(THREAD_COUNTRY_CODE);
+        String settingsCountryCode = mPersistentSettings.get(KEY_COUNTRY_CODE);
         if (settingsCountryCode != null) {
             return new CountryCodeInfo(settingsCountryCode, COUNTRY_CODE_SOURCE_SETTINGS);
         }
@@ -514,13 +566,17 @@
             public void onSuccess() {
                 synchronized ("ThreadNetworkCountryCode.this") {
                     mCurrentCountryCodeInfo = countryCodeInfo;
-                    mPersistentSettings.put(
-                            THREAD_COUNTRY_CODE.key, countryCodeInfo.getCountryCode());
+                    mPersistentSettings.put(KEY_COUNTRY_CODE, countryCodeInfo.getCountryCode());
                 }
             }
 
             @Override
             public void onError(int otError, String message) {
+                if (otError == ERROR_UNSUPPORTED_FEATURE) {
+                    mIsCpSettingCountryCodeSupported = false;
+                    unregisterAllCountryCodeCallbacks();
+                }
+
                 LOG.e(
                         "Error "
                                 + otError
@@ -547,6 +603,11 @@
             return;
         }
 
+        if (!mIsCpSettingCountryCodeSupported) {
+            LOG.i("Thread co-processor doesn't support setting the country code");
+            return;
+        }
+
         LOG.i("Set country code: " + countryCodeInfo);
         mThreadNetworkControllerService.setCountryCode(
                 countryCodeInfo.getCountryCode().toUpperCase(Locale.ROOT),
@@ -593,6 +654,7 @@
     /** Dumps the current state of this ThreadNetworkCountryCode object. */
     public synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
         pw.println("---- Dump of ThreadNetworkCountryCode begin ----");
+        pw.println("mIsCpSettingCountryCodeSupported: " + mIsCpSettingCountryCodeSupported);
         pw.println("mOverrideCountryCodeInfo        : " + mOverrideCountryCodeInfo);
         pw.println("mTelephonyCountryCodeSlotInfoMap: " + mTelephonyCountryCodeSlotInfoMap);
         pw.println("mTelephonyCountryCodeInfo       : " + mTelephonyCountryCodeInfo);
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index 746b587..fd6dec7 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -40,6 +40,8 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
 
 /**
  * Store persistent data for Thread network settings. These are key (string) / value pairs that are
@@ -53,54 +55,55 @@
     /** File name used for storing settings. */
     private static final String FILE_NAME = "ThreadPersistentSettings.xml";
 
-    /** Current config store data version. This will be incremented for any additions. */
+    /** Current config store data version. This MUST be incremented for any incompatible changes. */
     private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
 
     /**
      * Stores the version of the data. This can be used to handle migration of data if some
      * non-backward compatible change introduced.
      */
-    private static final String VERSION_KEY = "version";
+    private static final String KEY_VERSION = "version";
 
-    /******** Thread persistent setting keys ***************/
-    /** Stores the Thread feature toggle state, true for enabled and false for disabled. */
-    public static final Key<Boolean> THREAD_ENABLED = new Key<>("thread_enabled", true);
+    /**
+     * Saves the boolean flag for Thread being enabled. The value defaults to resource overlay value
+     * {@code R.bool.config_thread_default_enabled}.
+     */
+    public static final Key<Boolean> KEY_THREAD_ENABLED = new Key<>("thread_enabled");
+
+    /**
+     * Saves the boolean flag for border router being enabled. The value defaults to resource
+     * overlay value {@code R.bool.config_thread_border_router_default_enabled}.
+     */
+    private static final Key<Boolean> KEY_CONFIG_BORDER_ROUTER_ENABLED =
+            new Key<>("config_border_router_enabled");
+
+    /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
+    private static final Key<Boolean> KEY_CONFIG_NAT64_ENABLED = new Key<>("config_nat64_enabled");
+
+    /**
+     * Stores the Thread DHCPv6-PD feature toggle state, true for enabled and false for disabled.
+     */
+    private static final Key<Boolean> KEY_CONFIG_DHCP6_PD_ENABLED =
+            new Key<>("config_dhcp6_pd_enabled");
 
     /**
      * Indicates that Thread was enabled (i.e. via the setEnabled() API) when the airplane mode is
      * turned on in settings. When this value is {@code true}, the current airplane mode state will
      * be ignored when evaluating the Thread enabled state.
      */
-    public static final Key<Boolean> THREAD_ENABLED_IN_AIRPLANE_MODE =
-            new Key<>("thread_enabled_in_airplane_mode", false);
+    public static final Key<Boolean> KEY_THREAD_ENABLED_IN_AIRPLANE_MODE =
+            new Key<>("thread_enabled_in_airplane_mode");
 
     /** Stores the Thread country code, null if no country code is stored. */
-    public static final Key<String> THREAD_COUNTRY_CODE = new Key<>("thread_country_code", null);
-
-    /**
-     * Saves the boolean flag for border router being enabled. The value defaults to {@code true} if
-     * this config is missing.
-     */
-    private static final Key<Boolean> CONFIG_BORDER_ROUTER_ENABLED =
-            new Key<>("config_border_router_enabled", true);
-
-    /** Stores the Thread NAT64 feature toggle state, true for enabled and false for disabled. */
-    private static final Key<Boolean> CONFIG_NAT64_ENABLED =
-            new Key<>("config_nat64_enabled", false);
-
-    /**
-     * Stores the Thread DHCPv6-PD feature toggle state, true for enabled and false for disabled.
-     */
-    private static final Key<Boolean> CONFIG_DHCP6_PD_ENABLED =
-            new Key<>("config_dhcp6_pd_enabled", false);
-
-    /******** Thread persistent setting keys ***************/
+    public static final Key<String> KEY_COUNTRY_CODE = new Key<>("thread_country_code");
 
     @GuardedBy("mLock")
     private final AtomicFile mAtomicFile;
 
     private final Object mLock = new Object();
 
+    private final Map<String, Object> mDefaultValues = new HashMap<>();
+
     @GuardedBy("mLock")
     private final PersistableBundle mSettings = new PersistableBundle();
 
@@ -116,19 +119,22 @@
     ThreadPersistentSettings(AtomicFile atomicFile, ConnectivityResources resources) {
         mAtomicFile = atomicFile;
         mResources = resources;
+
+        mDefaultValues.put(
+                KEY_THREAD_ENABLED.key,
+                mResources.get().getBoolean(R.bool.config_thread_default_enabled));
+        mDefaultValues.put(
+                KEY_CONFIG_BORDER_ROUTER_ENABLED.key,
+                mResources.get().getBoolean(R.bool.config_thread_border_router_default_enabled));
+        mDefaultValues.put(KEY_CONFIG_NAT64_ENABLED.key, false);
+        mDefaultValues.put(KEY_CONFIG_DHCP6_PD_ENABLED.key, false);
+        mDefaultValues.put(KEY_THREAD_ENABLED_IN_AIRPLANE_MODE.key, false);
+        mDefaultValues.put(KEY_COUNTRY_CODE.key, null);
     }
 
     /** Initialize the settings by reading from the settings file. */
     public void initialize() {
         readFromStoreFile();
-        synchronized (mLock) {
-            if (!mSettings.containsKey(THREAD_ENABLED.key)) {
-                LOG.i("\"thread_enabled\" is missing in settings file, using default value");
-                put(
-                        THREAD_ENABLED.key,
-                        mResources.get().getBoolean(R.bool.config_thread_default_enabled));
-            }
-        }
     }
 
     private void putObject(String key, @Nullable Object value) {
@@ -173,25 +179,17 @@
         return (T) value;
     }
 
-    /**
-     * Store a value to the stored settings.
-     *
-     * @param key One of the settings keys.
-     * @param value Value to be stored.
-     */
-    public <T> void put(String key, @Nullable T value) {
-        putObject(key, value);
+    /** Stores a value to the stored settings. */
+    public <T> void put(Key<T> key, @Nullable T value) {
+        putObject(key.key, value);
         writeToStoreFile();
     }
 
-    /**
-     * Retrieve a value from the stored settings.
-     *
-     * @param key One of the settings keys.
-     * @return value stored in settings, defValue if the key does not exist.
-     */
+    /** Retrieves a value from the stored settings. */
+    @Nullable
     public <T> T get(Key<T> key) {
-        return getObject(key.key, key.defaultValue);
+        T defaultValue = (T) mDefaultValues.get(key.key);
+        return getObject(key.key, defaultValue);
     }
 
     /**
@@ -204,9 +202,9 @@
         if (getConfiguration().equals(configuration)) {
             return false;
         }
-        putObject(CONFIG_BORDER_ROUTER_ENABLED.key, configuration.isBorderRouterEnabled());
-        putObject(CONFIG_NAT64_ENABLED.key, configuration.isNat64Enabled());
-        putObject(CONFIG_DHCP6_PD_ENABLED.key, configuration.isDhcpv6PdEnabled());
+        put(KEY_CONFIG_BORDER_ROUTER_ENABLED, configuration.isBorderRouterEnabled());
+        put(KEY_CONFIG_NAT64_ENABLED, configuration.isNat64Enabled());
+        put(KEY_CONFIG_DHCP6_PD_ENABLED, configuration.isDhcpv6PdEnabled());
         writeToStoreFile();
         return true;
     }
@@ -214,9 +212,9 @@
     /** Retrieve the {@link ThreadConfiguration} from the persistent settings. */
     public ThreadConfiguration getConfiguration() {
         return new ThreadConfiguration.Builder()
-                .setBorderRouterEnabled(get(CONFIG_BORDER_ROUTER_ENABLED))
-                .setNat64Enabled(get(CONFIG_NAT64_ENABLED))
-                .setDhcpv6PdEnabled(get(CONFIG_DHCP6_PD_ENABLED))
+                .setBorderRouterEnabled(get(KEY_CONFIG_BORDER_ROUTER_ENABLED))
+                .setNat64Enabled(get(KEY_CONFIG_NAT64_ENABLED))
+                .setDhcpv6PdEnabled(get(KEY_CONFIG_DHCP6_PD_ENABLED))
                 .build();
     }
 
@@ -225,18 +223,11 @@
      *
      * @param <T> Type of the value.
      */
-    public static class Key<T> {
-        public final String key;
-        public final T defaultValue;
+    public static final class Key<T> {
+        @VisibleForTesting final String key;
 
-        private Key(String key, T defaultValue) {
+        private Key(String key) {
             this.key = key;
-            this.defaultValue = defaultValue;
-        }
-
-        @Override
-        public String toString() {
-            return "[Key: " + key + ", DefaultValue: " + defaultValue + "]";
         }
     }
 
@@ -247,7 +238,7 @@
             synchronized (mLock) {
                 bundleToWrite = new PersistableBundle(mSettings);
             }
-            bundleToWrite.putInt(VERSION_KEY, CURRENT_SETTINGS_STORE_DATA_VERSION);
+            bundleToWrite.putInt(KEY_VERSION, CURRENT_SETTINGS_STORE_DATA_VERSION);
             bundleToWrite.writeToStream(outputStream);
             synchronized (mLock) {
                 writeToAtomicFile(mAtomicFile, outputStream.toByteArray());
@@ -267,7 +258,7 @@
             final ByteArrayInputStream inputStream = new ByteArrayInputStream(readData);
             final PersistableBundle bundleRead = PersistableBundle.readFromStream(inputStream);
             // Version unused for now. May be needed in the future for handling migrations.
-            bundleRead.remove(VERSION_KEY);
+            bundleRead.remove(KEY_VERSION);
             synchronized (mLock) {
                 mSettings.putAll(bundleRead);
             }
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index 520a434..2f2a5d1 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -326,12 +326,13 @@
     private static LinkAddress newLinkAddress(
             Ipv6AddressInfo addressInfo, boolean hasActiveOmrAddress) {
         // Mesh-local addresses and OMR address have the same scope, to distinguish them we set
-        // mesh-local addresses as deprecated when there is an active OMR address.
+        // mesh-local addresses as deprecated when there is an active OMR address. If OMR address
+        // is missing, only ML-EID in mesh-local addresses will be set preferred.
         // For OMR address and link-local address we only use the value isPreferred set by
         // ot-daemon.
         boolean isPreferred = addressInfo.isPreferred;
-        if (addressInfo.isMeshLocal && hasActiveOmrAddress) {
-            isPreferred = false;
+        if (addressInfo.isMeshLocal) {
+            isPreferred = (!hasActiveOmrAddress && addressInfo.isMeshLocalEid);
         }
 
         final long deprecationTimeMillis =
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 2630d21..901dee7 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -51,7 +51,6 @@
     libs: [
         "android.test.base.stubs",
         "android.test.runner.stubs",
-        "framework-connectivity-module-api-stubs-including-flagged",
     ],
     // Test coverage system runs on different devices. Need to
     // compile for all architectures.
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index e954d3b..89d2ce5 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -57,13 +57,4 @@
         <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
 
-    <!--
-        This doesn't override a read-only flag, to run the tests locally with `epskc_enabled` flag
-        enabled, set the flag to `is_fixed_read_only: false`. This should be removed after the
-        `epskc_enabled` flag is rolled out.
-    -->
-    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
-        <option name="flag-value"
-                value="thread_network/com.android.net.thread.flags.epskc_enabled=true"/>
-    </target_preparer>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 2d487ca..2d68119 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -49,6 +49,8 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeFalse;
+import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
@@ -94,12 +96,15 @@
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -118,6 +123,7 @@
 /** CTS tests for {@link ThreadNetworkController}. */
 @LargeTest
 @RequiresThreadFeature
+@RunWith(Parameterized.class)
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
     private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
@@ -134,8 +140,6 @@
     private static final String MESHCOP_SERVICE_TYPE = "_meshcop._udp";
     private static final String THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
-    private static final ThreadConfiguration DEFAULT_CONFIG =
-            new ThreadConfiguration.Builder().build();
     private static final SparseIntArray CHANNEL_MAX_POWERS =
             new SparseIntArray() {
                 {
@@ -161,6 +165,22 @@
     private final List<Consumer<ThreadConfiguration>> mConfigurationCallbacksToCleanUp =
             new ArrayList<>();
 
+    public final boolean mIsBorderRouterEnabled;
+    private final ThreadConfiguration mDefaultConfig;
+
+    @Parameterized.Parameters
+    public static Collection configArguments() {
+        return Arrays.asList(new Object[][] {{false}, {true}});
+    }
+
+    public ThreadNetworkControllerTest(boolean isBorderRouterEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
+        mDefaultConfig =
+                new ThreadConfiguration.Builder()
+                        .setBorderRouterEnabled(isBorderRouterEnabled)
+                        .build();
+    }
+
     @Before
     public void setUp() throws Exception {
         mController =
@@ -175,8 +195,10 @@
         mHandlerThread.start();
 
         setEnabledAndWait(mController, true);
-        setConfigurationAndWait(mController, DEFAULT_CONFIG);
-        deactivateEphemeralKeyModeAndWait(mController);
+        setConfigurationAndWait(mController, mDefaultConfig);
+        if (mDefaultConfig.isBorderRouterEnabled()) {
+            deactivateEphemeralKeyModeAndWait(mController);
+        }
     }
 
     @After
@@ -185,7 +207,7 @@
         setEnabledAndWait(mController, true);
         leaveAndWait(mController);
         tearDownTestNetwork();
-        setConfigurationAndWait(mController, DEFAULT_CONFIG);
+        setConfigurationAndWait(mController, mDefaultConfig);
         for (Consumer<ThreadConfiguration> configurationCallback :
                 mConfigurationCallbacksToCleanUp) {
             try {
@@ -197,7 +219,9 @@
             }
         }
         mConfigurationCallbacksToCleanUp.clear();
-        deactivateEphemeralKeyModeAndWait(mController);
+        if (mDefaultConfig.isBorderRouterEnabled()) {
+            deactivateEphemeralKeyModeAndWait(mController);
+        }
     }
 
     @Test
@@ -573,7 +597,7 @@
                     @Override
                     public void onActiveOperationalDatasetChanged(
                             ActiveOperationalDataset activeDataset) {
-                        if (activeDataset.equals(activeDataset2)) {
+                        if (Objects.equals(activeDataset, activeDataset2)) {
                             dataset2IsApplied.complete(true);
                         }
                     }
@@ -843,6 +867,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         joinRandomizedDatasetAndWait(mController);
         CompletableFuture<Void> startFuture = new CompletableFuture<>();
 
@@ -861,6 +886,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         dropAllPermissions();
 
         assertThrows(
@@ -874,6 +900,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
 
         assertThrows(
@@ -885,6 +912,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
         Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
 
@@ -897,6 +925,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         joinRandomizedDatasetAndWait(mController);
         CompletableFuture<Void> future1 = new CompletableFuture<>();
         CompletableFuture<Void> future2 = new CompletableFuture<>();
@@ -945,6 +974,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         dropAllPermissions();
 
         assertThrows(
@@ -956,9 +986,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void deactivateEphemeralKeyMode_notBorderRouter_failsWithFailedPrecondition()
             throws Exception {
-        setConfigurationAndWait(
-                mController,
-                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
+        assumeFalse(mDefaultConfig.isBorderRouterEnabled());
         grantPermissions(THREAD_NETWORK_PRIVILEGED);
         CompletableFuture<Void> future = new CompletableFuture<>();
 
@@ -975,6 +1003,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
         CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
         CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
@@ -1011,6 +1040,7 @@
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
             throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
         CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
         CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
@@ -1050,6 +1080,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
         joinRandomizedDatasetAndWait(mController);
 
@@ -1068,6 +1099,7 @@
     @Test
     @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
     public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+        assumeTrue(mDefaultConfig.isBorderRouterEnabled());
         EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
         Triple<Integer, String, Instant> epskc1;
         try {
@@ -1173,7 +1205,7 @@
                 THREAD_NETWORK_PRIVILEGED,
                 () -> registerConfigurationCallback(mController, mExecutor, callback));
         assertThat(getConfigFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
-                .isEqualTo(DEFAULT_CONFIG);
+                .isEqualTo(mDefaultConfig);
     }
 
     @Test
@@ -1216,7 +1248,7 @@
             setFuture1.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
             setFuture2.get(ENABLED_TIMEOUT_MILLIS, MILLISECONDS);
 
-            listener.expectConfiguration(DEFAULT_CONFIG);
+            listener.expectConfiguration(mDefaultConfig);
             listener.expectConfiguration(config1);
             listener.expectConfiguration(config2);
             listener.expectNoMoreConfiguration();
@@ -1296,6 +1328,7 @@
     }
 
     @Test
+    @Ignore("b/333649897, b/332195449: The 3 meshcop tests are flaky in different environments")
     public void meshcopService_threadEnabledButNotJoined_discoveredButNoNetwork() throws Exception {
         setUpTestNetwork();
 
@@ -1348,6 +1381,7 @@
     }
 
     @Test
+    @Ignore("b/333649897, b/332195449: The 3 meshcop tests are flaky in different environments")
     public void meshcopService_threadDisabled_notDiscovered() throws Exception {
         setUpTestNetwork();
         CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
index b6d0d31..5be8f49 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkManagerTest.java
@@ -19,12 +19,10 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assume.assumeFalse;
-import static org.junit.Assume.assumeNotNull;
 import static org.junit.Assume.assumeTrue;
 
 import android.content.Context;
 import android.content.pm.PackageManager;
-import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkManager;
 import android.os.Build;
 
@@ -41,8 +39,6 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.util.List;
-
 /** Tests for {@link ThreadNetworkManager}. */
 @SmallTest
 @RunWith(AndroidJUnit4.class)
@@ -90,21 +86,4 @@
 
         assertThat(mManager).isNotNull();
     }
-
-    @Test
-    public void getManager_noThreadFeature_returnsNull() {
-        assumeFalse(mPackageManager.hasSystemFeature("android.hardware.thread_network"));
-
-        assertThat(mManager).isNull();
-    }
-
-    @Test
-    @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
-    public void getAllThreadNetworkControllers_managerIsNotNull_returnsNotEmptyList() {
-        assumeNotNull(mManager);
-
-        List<ThreadNetworkController> controllers = mManager.getAllThreadNetworkControllers();
-
-        assertThat(controllers).isNotEmpty();
-    }
 }
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
index 798a51e..8a72017 100644
--- a/thread/tests/integration/Android.bp
+++ b/thread/tests/integration/Android.bp
@@ -64,6 +64,26 @@
 }
 
 android_test {
+    name: "ThreadBorderRouterIntegrationTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTestBorderRouter.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults",
+    ],
+    test_suites: [
+        "mts-tethering",
+        "general-tests",
+    ],
+    srcs: [
+        "borderrouter/**/*.java",
+        "borderrouter/**/*.kt",
+    ],
+    compile_multilib: "both",
+}
+
+android_test {
     name: "ThreadNetworkTrelDisabledTests",
     platform_apis: true,
     manifest: "AndroidManifest.xml",
diff --git a/thread/tests/integration/AndroidTestBorderRouter.xml b/thread/tests/integration/AndroidTestBorderRouter.xml
new file mode 100644
index 0000000..644e839
--- /dev/null
+++ b/thread/tests/integration/AndroidTestBorderRouter.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2025 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ -->
+
+<configuration description="Config for Thread Border Router integration tests">
+    <option name="test-tag" value="ThreadBorderRouterIntegrationTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <!--
+        Only run tests if the device under test is SDK version 34 (Android 14) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk34ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.DeviceFeatureModuleController">
+        <option name="required-feature" value="android.hardware.thread_network" />
+    </object>
+
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <!-- Install test -->
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadBorderRouterIntegrationTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="com.android.thread.tests.integration" />
+    </test>
+
+    <!-- Enable TREL for integration tests -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/TrelFeature__enabled=true"/>
+    </target_preparer>
+</configuration>
diff --git a/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java
new file mode 100644
index 0000000..292079f
--- /dev/null
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRouterIntegrationTest.java
@@ -0,0 +1,286 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
+import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6Addresses;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
+import static android.net.thread.utils.IntegrationTestUtils.getPrefixesFromNetData;
+import static android.net.thread.utils.IntegrationTestUtils.getThreadNetwork;
+import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
+import static android.net.thread.utils.IntegrationTestUtils.waitFor;
+import static android.os.SystemClock.elapsedRealtime;
+
+import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.InetAddresses;
+import android.net.IpPrefix;
+import android.net.LinkProperties;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
+import android.net.thread.utils.ThreadNetworkControllerWrapper;
+import android.net.thread.utils.ThreadStateListener;
+import android.os.HandlerThread;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.google.common.collect.FluentIterable;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** Tests for E2E Border Router integration with ot-daemon, ConnectivityService, etc.. */
+@LargeTest
+@RequiresThreadFeature
+@RunWith(AndroidJUnit4.class)
+public class BorderRouterIntegrationTest {
+    // The maximum time for changes to be propagated to netdata.
+    private static final Duration NET_DATA_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The maximum time for OT addresses to be propagated to the TUN interface "thread-wpan"
+    private static final Duration TUN_ADDR_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The maximum time for changes in netdata to be propagated to link properties.
+    private static final Duration LINK_PROPERTIES_UPDATE_TIMEOUT = Duration.ofSeconds(1);
+
+    // The duration between attached and OMR address shows up on thread-wpan
+    private static final Duration OMR_LINK_ADDR_TIMEOUT = Duration.ofSeconds(30);
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
+    private static final byte[] DEFAULT_DATASET_TLVS =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+    private static final ActiveOperationalDataset DEFAULT_DATASET =
+            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
+
+    private static final Inet6Address GROUP_ADDR_ALL_ROUTERS =
+            (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
+
+    private static final String TEST_NO_SLAAC_PREFIX = "9101:dead:beef:cafe::/64";
+    private static final InetAddress TEST_NO_SLAAC_PREFIX_ADDRESS =
+            InetAddresses.parseNumericAddress("9101:dead:beef:cafe::");
+
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private ExecutorService mExecutor;
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkControllerWrapper mController =
+            ThreadNetworkControllerWrapper.newInstance(mContext);
+    private OtDaemonController mOtCtl;
+    private FullThreadDevice mFtd;
+    private HandlerThread mHandlerThread;
+    private TapTestNetworkTracker mTestNetworkTracker;
+
+    @Before
+    public void setUp() throws Exception {
+        mExecutor = Executors.newSingleThreadExecutor();
+        mFtd = new FullThreadDevice(10 /* nodeId */);
+        mOtCtl = new OtDaemonController();
+        mController.setEnabledAndWait(true);
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(true).build());
+        mController.leaveAndWait();
+
+        mHandlerThread = new HandlerThread("ThreadIntegrationTest");
+        mHandlerThread.start();
+
+        mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
+        assertThat(mTestNetworkTracker).isNotNull();
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        ThreadStateListener.stopAllListeners();
+
+        if (mTestNetworkTracker != null) {
+            mTestNetworkTracker.tearDown();
+        }
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+        mController.setTestNetworkAsUpstreamAndWait(null);
+        mController.leaveAndWait();
+
+        mFtd.destroy();
+        mExecutor.shutdownNow();
+    }
+
+    @Test
+    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
+            throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        runShellCommand("stop ot-daemon");
+
+        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
+        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
+        assertThat(mOtCtl.isInterfaceUp()).isTrue();
+        assertThat(runShellCommand("ifconfig thread-wpan")).contains("UP POINTOPOINT RUNNING");
+    }
+
+    @Test
+    public void joinNetwork_tunInterfaceJoinsAllRouterMulticastGroup() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        waitFor(
+                () -> isInMulticastGroup("thread-wpan", GROUP_ADDR_ALL_ROUTERS),
+                TUN_ADDR_UPDATE_TIMEOUT);
+    }
+
+    @Test
+    public void joinNetwork_allMlAddrAreNotPreferredAndOmrIsPreferred() throws Exception {
+        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
+        mController.joinAndWait(DEFAULT_DATASET);
+        waitFor(
+                () -> getIpv6Addresses("thread-wpan").contains(mOtCtl.getOmrAddress()),
+                OMR_LINK_ADDR_TIMEOUT);
+
+        IpPrefix meshLocalPrefix = DEFAULT_DATASET.getMeshLocalPrefix();
+        var linkAddrs = FluentIterable.from(getIpv6LinkAddresses("thread-wpan"));
+        var meshLocalAddrs = linkAddrs.filter(addr -> meshLocalPrefix.contains(addr.getAddress()));
+        assertThat(meshLocalAddrs).isNotEmpty();
+        assertThat(meshLocalAddrs.allMatch(addr -> !addr.isPreferred())).isTrue();
+        assertThat(meshLocalAddrs.allMatch(addr -> addr.getDeprecationTime() <= elapsedRealtime()))
+                .isTrue();
+        var omrAddrs = linkAddrs.filter(addr -> addr.getAddress().equals(mOtCtl.getOmrAddress()));
+        assertThat(omrAddrs).hasSize(1);
+        assertThat(omrAddrs.get(0).isPreferred()).isTrue();
+        assertThat(omrAddrs.get(0).getDeprecationTime() > elapsedRealtime()).isTrue();
+    }
+
+    @Test
+    @RequiresSimulationThreadDevice
+    public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        startFtdChild(mFtd, DEFAULT_DATASET);
+        List<Inet6Address> meshLocalAddresses = mOtCtl.getMeshLocalAddresses();
+
+        for (Inet6Address address : meshLocalAddresses) {
+            assertWithMessage(
+                            "There may be duplicated replies of ping request to "
+                                    + address.getHostAddress())
+                    .that(mFtd.ping(address, 2))
+                    .isEqualTo(2);
+        }
+    }
+
+    @Test
+    public void addPrefixToNetData_routeIsAddedToTunInterface() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        // Ftd child doesn't have the ability to add a prefix, so let BR itself add a prefix.
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+        waitFor(
+                () -> {
+                    String netData = mOtCtl.executeCommand("netdata show");
+                    return getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
+                },
+                NET_DATA_UPDATE_TIMEOUT);
+
+        assertRouteAddedOrRemovedInLinkProperties(true /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    @Test
+    public void removePrefixFromNetData_routeIsRemovedFromTunInterface() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+
+        mOtCtl.executeCommand("prefix remove " + TEST_NO_SLAAC_PREFIX);
+        mOtCtl.executeCommand("netdata register");
+        waitFor(
+                () -> {
+                    String netData = mOtCtl.executeCommand("netdata show");
+                    return !getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
+                },
+                NET_DATA_UPDATE_TIMEOUT);
+
+        assertRouteAddedOrRemovedInLinkProperties(
+                false /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    @Test
+    public void toggleThreadNetwork_routeFromPreviousNetDataIsRemoved() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
+        mOtCtl.executeCommand("netdata register");
+
+        mController.leaveAndWait();
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        assertRouteAddedOrRemovedInLinkProperties(
+                false /* isAdded */, TEST_NO_SLAAC_PREFIX_ADDRESS);
+    }
+
+    private void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+    }
+
+    private void assertRouteAddedOrRemovedInLinkProperties(boolean isAdded, InetAddress addr)
+            throws Exception {
+        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+
+        waitFor(
+                () -> {
+                    try {
+                        LinkProperties lp =
+                                cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
+                        return lp != null
+                                && isAdded
+                                        == lp.getRoutes().stream().anyMatch(r -> r.matches(addr));
+                    } catch (Exception e) {
+                        return false;
+                    }
+                },
+                LINK_PROPERTIES_UPDATE_TIMEOUT);
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
similarity index 99%
rename from thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
rename to thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
index 875a4ad..1d210c6 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/BorderRoutingTest.java
@@ -14,12 +14,12 @@
  * limitations under the License.
  */
 
-package android.net.thread;
+package android.net.thread.borderrouter;
 
 import static android.net.InetAddresses.parseNumericAddress;
 import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 import static android.net.thread.utils.IntegrationTestUtils.buildIcmpv4EchoReply;
-import static android.net.thread.utils.IntegrationTestUtils.enableThreadAndJoinNetwork;
+import static android.net.thread.utils.IntegrationTestUtils.enableBorderRouterAndJoinNetwork;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv4Packet;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
@@ -92,10 +92,10 @@
 import java.util.function.Predicate;
 
 /** Integration test cases for Thread Border Routing feature. */
-@RunWith(AndroidJUnit4.class)
+@LargeTest
 @RequiresThreadFeature
 @RequiresSimulationThreadDevice
-@LargeTest
+@RunWith(AndroidJUnit4.class)
 public class BorderRoutingTest {
     private static final String TAG = BorderRoutingTest.class.getSimpleName();
     private static final int NUM_FTD = 2;
@@ -128,7 +128,7 @@
 
     @BeforeClass
     public static void beforeClass() throws Exception {
-        enableThreadAndJoinNetwork(DEFAULT_DATASET);
+        enableBorderRouterAndJoinNetwork(DEFAULT_DATASET);
     }
 
     @AfterClass
diff --git a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
similarity index 97%
rename from thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
rename to thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
index 3c9aa07..ad98305 100644
--- a/thread/tests/integration/src/android/net/thread/InternetAccessTest.kt
+++ b/thread/tests/integration/borderrouter/src/android/net/thread/borderrouter/InternetAccessTest.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package android.net.thread
+package android.net.thread.borderrouter
 
 import android.content.Context
 import android.net.DnsResolver.CLASS_IN
@@ -60,10 +60,10 @@
 import org.junit.runner.RunWith
 
 /** Integration test cases for Thread Internet Access features. */
+@LargeTest
 @RunWith(AndroidJUnit4::class)
 @RequiresThreadFeature
 @RequiresSimulationThreadDevice
-@LargeTest
 class InternetAccessTest {
     companion object {
         private val TAG = BorderRoutingTest::class.java.simpleName
@@ -167,6 +167,8 @@
         val ftd = ftds[0]
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
         dnsServer.start()
+        ftd.autoStartSrpClient()
+        ftd.waitForSrpServer()
 
         val ipv4Addresses =
             ftd.resolveHost("google.com", TYPE_A).map { extractIpv4AddressFromMappedAddress(it) }
@@ -181,6 +183,8 @@
         val ftd = ftds[0]
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET)
         dnsServer.start()
+        ftd.autoStartSrpClient()
+        ftd.waitForSrpServer()
 
         assertThat(ftd.resolveHost("google.com", TYPE_A)).isEmpty()
         assertThat(ftd.resolveHost("google.com", TYPE_AAAA)).isEmpty()
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 6c2a9bb..c4e373a 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -21,6 +21,7 @@
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost;
 import static android.net.thread.utils.IntegrationTestUtils.discoverService;
+import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWait;
 import static android.net.thread.utils.IntegrationTestUtils.joinNetworkAndWaitForOmr;
 import static android.net.thread.utils.IntegrationTestUtils.resolveService;
 import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil;
@@ -113,8 +114,10 @@
 
     @Before
     public void setUp() throws Exception {
-        mOtCtl.factoryReset();
         mController.setEnabledAndWait(true);
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(true).build();
+        mController.setConfigurationAndWait(config);
         mController.joinAndWait(DEFAULT_DATASET);
         mNsdManager = mContext.getSystemService(NsdManager.class);
 
@@ -158,6 +161,143 @@
     }
 
     @Test
+    public void advertisingProxy_borderRouterDisabled_clientServiceRemovedWhenLeaveIsCalled()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.get(0);
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+
+        CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                discoverForServiceLost(mNsdManager, "_matter._tcp", serviceLostFuture);
+        mController.leaveAndWait();
+
+        // Verify the service becomes lost.
+        try {
+            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            mNsdManager.stopServiceDiscovery(listener);
+        }
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_matter._tcp"));
+    }
+
+    @Test
+    public void advertisingProxy_borderRouterDisabled_clientServiceRemovedWhen2ndSrpServerEnabled()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *  (Cuttlefish)                |
+         *                              +------- 2nd SRP Server
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.get(0);
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+
+        FullThreadDevice srpServer2 = mFtds.get(1);
+        joinNetworkAndWait(srpServer2, DEFAULT_DATASET);
+        CompletableFuture<NsdServiceInfo> serviceLostFuture = new CompletableFuture<>();
+        NsdManager.DiscoveryListener listener =
+                discoverForServiceLost(mNsdManager, "_matter._tcp", serviceLostFuture);
+        srpServer2.setSrpServerEnabled(true);
+
+        // Verify the service becomes lost.
+        try {
+            serviceLostFuture.get(SERVICE_DISCOVERY_TIMEOUT.toMillis(), MILLISECONDS);
+        } finally {
+            mNsdManager.stopServiceDiscovery(listener);
+        }
+        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_matter._tcp"));
+    }
+
+    @Test
+    public void advertisingProxy_borderRouterDisabled_clientMleIdAddressIsAdvertised()
+            throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                            Thread
+         *  SRP Server / AD Proxy -------------- SRP Client
+         *  (Cuttlefish)
+         *
+         * </pre>
+         */
+
+        // The Border Router / SRP Server mode can only be changed when Thread is disconnected
+        mController.leaveAndWait();
+        var config = new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build();
+        mController.setConfigurationAndWait(config);
+        mController.joinAndWait(DEFAULT_DATASET);
+
+        FullThreadDevice srpClient = mFtds.getFirst();
+        joinNetworkAndWait(srpClient, DEFAULT_DATASET);
+        srpClient.setSrpHostname("thread-srp-client-host");
+        srpClient.setSrpHostAddresses(List.of(srpClient.getMlEid()));
+        srpClient.addSrpService(
+                "thread-srp-client-service",
+                "_matter._tcp",
+                List.of("_sub1", "_sub2"),
+                12345 /* port */,
+                Map.of("key1", bytes(1), "key2", bytes(2)));
+
+        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_matter._tcp");
+        assertThat(discoveredService).isNotNull();
+        NsdServiceInfo resolvedService = resolveService(mNsdManager, discoveredService);
+        assertThat(resolvedService.getServiceName()).isEqualTo("thread-srp-client-service");
+        assertThat(resolvedService.getServiceType()).isEqualTo("_matter._tcp");
+        assertThat(resolvedService.getPort()).isEqualTo(12345);
+        assertThat(resolvedService.getAttributes())
+                .comparingValuesUsing(BYTE_ARRAY_EQUALITY)
+                .containsExactly("key1", bytes(1), "key2", bytes(2));
+        assertThat(resolvedService.getHostname()).isEqualTo("thread-srp-client-host");
+        assertThat(resolvedService.getHostAddresses()).containsExactly(srpClient.getMlEid());
+    }
+
+    @Test
     public void advertisingProxy_multipleSrpClientsRegisterServices_servicesResolvableByMdns()
             throws Exception {
         /*
@@ -455,7 +595,8 @@
                 DeviceConfigUtils.getDeviceConfigPropertyBoolean(
                         "thread_network", "TrelFeature__enabled", false));
 
-        NsdServiceInfo discoveredService = discoverService(mNsdManager, "_trel._udp");
+        NsdServiceInfo discoveredService =
+                discoverService(mNsdManager, "_trel._udp", mOtCtl.getExtendedAddr());
         assertThat(discoveredService).isNotNull();
         // Resolve service with the current TREL port, otherwise it may return stale service from
         // a previous infra link setup.
@@ -478,7 +619,9 @@
                 DeviceConfigUtils.getDeviceConfigPropertyBoolean(
                         "thread_network", "TrelFeature__enabled", false));
 
-        assertThrows(TimeoutException.class, () -> discoverService(mNsdManager, "_trel._udp"));
+        assertThrows(
+                TimeoutException.class,
+                () -> discoverService(mNsdManager, "_trel._udp", mOtCtl.getExtendedAddr()));
     }
 
     private void registerService(NsdServiceInfo serviceInfo, RegistrationListener listener)
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 7a5895f..b608c5d 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -17,43 +17,39 @@
 package android.net.thread;
 
 import static android.Manifest.permission.ACCESS_NETWORK_STATE;
-import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
 import static android.net.thread.utils.IntegrationTestUtils.CALLBACK_TIMEOUT;
-import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_CONFIG;
-import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
+import static android.net.thread.utils.IntegrationTestUtils.getIpv6Addresses;
 import static android.net.thread.utils.IntegrationTestUtils.getIpv6LinkAddresses;
-import static android.net.thread.utils.IntegrationTestUtils.getPrefixesFromNetData;
-import static android.net.thread.utils.IntegrationTestUtils.getThreadNetwork;
-import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 import static android.net.thread.utils.ThreadNetworkControllerWrapper.JOIN_TIMEOUT;
+import static android.os.SystemClock.elapsedRealtime;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommand;
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
-import static com.android.server.thread.openthread.IOtDaemon.TUN_IF_NAME;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
+import static org.junit.Assume.assumeTrue;
+
 import static java.util.concurrent.TimeUnit.SECONDS;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
-import android.net.LinkAddress;
-import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
 import android.net.thread.utils.ThreadStateListener;
@@ -63,6 +59,8 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.google.common.collect.FluentIterable;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
@@ -92,11 +90,11 @@
     // The maximum time for OT addresses to be propagated to the TUN interface "thread-wpan"
     private static final Duration TUN_ADDR_UPDATE_TIMEOUT = Duration.ofSeconds(1);
 
-    // The maximum time for changes to be propagated to netdata.
-    private static final Duration NET_DATA_UPDATE_TIMEOUT = Duration.ofSeconds(1);
-
     private static final Duration NETWORK_CALLBACK_TIMEOUT = Duration.ofSeconds(10);
 
+    // The duration between attached and addresses show up on thread-wpan
+    private static final Duration LINK_ADDR_TIMEOUT = Duration.ofSeconds(2);
+
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -107,11 +105,6 @@
                                     + "B9D351B40C0402A0FFF8");
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
-    private static final ThreadConfiguration DEFAULT_CONFIG =
-            new ThreadConfiguration.Builder().build();
-
-    private static final Inet6Address GROUP_ADDR_ALL_ROUTERS =
-            (Inet6Address) InetAddresses.parseNumericAddress("ff02::2");
 
     private static final String TEST_NO_SLAAC_PREFIX = "9101:dead:beef:cafe::/64";
     private static final InetAddress TEST_NO_SLAAC_PREFIX_ADDRESS =
@@ -129,14 +122,12 @@
     @Before
     public void setUp() throws Exception {
         mExecutor = Executors.newSingleThreadExecutor();
+        mFtd = new FullThreadDevice(10 /* nodeId */);
         mOtCtl = new OtDaemonController();
         mController.setEnabledAndWait(true);
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
         mController.leaveAndWait();
-
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        mOtCtl.factoryReset();
-
-        mFtd = new FullThreadDevice(10 /* nodeId */);
     }
 
     @After
@@ -145,7 +136,6 @@
 
         mController.setTestNetworkAsUpstreamAndWait(null);
         mController.leaveAndWait();
-        mController.setConfigurationAndWait(DEFAULT_CONFIG);
 
         mFtd.destroy();
         mExecutor.shutdownNow();
@@ -163,19 +153,6 @@
     }
 
     @Test
-    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
-            throws Exception {
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        runShellCommand("stop ot-daemon");
-
-        mController.waitForRole(DEVICE_ROLE_DETACHED, CALLBACK_TIMEOUT);
-        mController.waitForRole(DEVICE_ROLE_LEADER, RESTART_JOIN_TIMEOUT);
-        assertThat(mOtCtl.isInterfaceUp()).isTrue();
-        assertThat(runShellCommand("ifconfig thread-wpan")).contains("UP POINTOPOINT RUNNING");
-    }
-
-    @Test
     public void otDaemonFactoryReset_deviceRoleIsStopped() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
 
@@ -217,6 +194,8 @@
 
     @Test
     public void otDaemonRestart_latestCountryCodeIsSetToOtDaemon() throws Exception {
+        assumeTrue(mOtCtl.isCountryCodeSupported());
+
         runThreadCommand("force-country-code enabled CN");
 
         runShellCommand("stop ot-daemon");
@@ -228,6 +207,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void udp_appStartEchoServer_endDeviceUdpEchoSuccess() throws Exception {
         // Topology:
         //   Test App ------ thread-wpan ------ End Device
@@ -246,30 +226,25 @@
     }
 
     @Test
-    public void joinNetworkWithBrDisabled_meshLocalAddressesArePreferred() throws Exception {
-        // When BR feature is disabled, there is no OMR address, so the mesh-local addresses are
-        // expected to be preferred.
-        mOtCtl.executeCommand("br disable");
+    public void joinNetwork_onlyMlEidIsPreferred() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
+        waitFor(
+                () -> getIpv6Addresses("thread-wpan").contains(mOtCtl.getMlEid()),
+                LINK_ADDR_TIMEOUT);
 
         IpPrefix meshLocalPrefix = DEFAULT_DATASET.getMeshLocalPrefix();
-        List<LinkAddress> linkAddresses = getIpv6LinkAddresses("thread-wpan");
-        for (LinkAddress address : linkAddresses) {
-            if (meshLocalPrefix.contains(address.getAddress())) {
-                assertThat(address.getDeprecationTime())
-                        .isGreaterThan(SystemClock.elapsedRealtime());
-                assertThat(address.isPreferred()).isTrue();
-            }
-        }
-
-        mOtCtl.executeCommand("br enable");
-    }
-
-    @Test
-    public void joinNetwork_tunInterfaceJoinsAllRouterMulticastGroup() throws Exception {
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        assertTunInterfaceMemberOfGroup(GROUP_ADDR_ALL_ROUTERS);
+        var linkAddrs = FluentIterable.from(getIpv6LinkAddresses("thread-wpan"));
+        var meshLocalAddrs = linkAddrs.filter(addr -> meshLocalPrefix.contains(addr.getAddress()));
+        var mlEidAddrs = meshLocalAddrs.filter(addr -> addr.getAddress().equals(mOtCtl.getMlEid()));
+        var nonMlEidAddrs = meshLocalAddrs.filter(addr -> !mlEidAddrs.contains(addr));
+        assertThat(mlEidAddrs).hasSize(1);
+        assertThat(mlEidAddrs.allMatch(addr -> addr.isPreferred())).isTrue();
+        assertThat(mlEidAddrs.allMatch(addr -> addr.getDeprecationTime() > elapsedRealtime()))
+                .isTrue();
+        assertThat(nonMlEidAddrs).isNotEmpty();
+        assertThat(nonMlEidAddrs.allMatch(addr -> !addr.isPreferred())).isTrue();
+        assertThat(nonMlEidAddrs.allMatch(addr -> addr.getDeprecationTime() <= elapsedRealtime()))
+                .isTrue();
     }
 
     @Test
@@ -287,6 +262,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void edPingsMeshLocalAddresses_oneReplyPerRequest() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
@@ -302,81 +278,15 @@
     }
 
     @Test
-    public void addPrefixToNetData_routeIsAddedToTunInterface() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        // Ftd child doesn't have the ability to add a prefix, so let BR itself add a prefix.
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-        waitFor(
-                () -> {
-                    String netData = mOtCtl.executeCommand("netdata show");
-                    return getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
-                },
-                NET_DATA_UPDATE_TIMEOUT);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isTrue();
-    }
-
-    @Test
-    public void removePrefixFromNetData_routeIsRemovedFromTunInterface() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-
-        mOtCtl.executeCommand("prefix remove " + TEST_NO_SLAAC_PREFIX);
-        mOtCtl.executeCommand("netdata register");
-        waitFor(
-                () -> {
-                    String netData = mOtCtl.executeCommand("netdata show");
-                    return !getPrefixesFromNetData(netData).contains(TEST_NO_SLAAC_PREFIX);
-                },
-                NET_DATA_UPDATE_TIMEOUT);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isFalse();
-    }
-
-    @Test
-    public void toggleThreadNetwork_routeFromPreviousNetDataIsRemoved() throws Exception {
-        ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
-        mController.joinAndWait(DEFAULT_DATASET);
-        mOtCtl.executeCommand("prefix add " + TEST_NO_SLAAC_PREFIX + " pros med");
-        mOtCtl.executeCommand("netdata register");
-
-        mController.leaveAndWait();
-        mOtCtl.factoryReset();
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        LinkProperties lp = cm.getLinkProperties(getThreadNetwork(CALLBACK_TIMEOUT));
-        assertThat(lp).isNotNull();
-        assertThat(lp.getRoutes().stream().anyMatch(r -> r.matches(TEST_NO_SLAAC_PREFIX_ADDRESS)))
-                .isFalse();
-    }
-
-    @Test
-    public void setConfiguration_disableBorderRouter_noBrfunctionsEnabled() throws Exception {
-        NetworkRequest request =
-                new NetworkRequest.Builder()
-                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                        .build();
+    @RequiresSimulationThreadDevice
+    public void setConfiguration_disableBorderRouter_borderRoutingDisabled() throws Exception {
         startFtdLeader(mFtd, DEFAULT_DATASET);
 
         mController.setConfigurationAndWait(
                 new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
         mController.joinAndWait(DEFAULT_DATASET);
-        NetworkCapabilities caps = registerNetworkCallbackAndWait(request);
 
-        assertThat(caps.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)).isFalse();
         assertThat(mOtCtl.getBorderRoutingState()).ignoringCase().isEqualTo("disabled");
-        assertThat(mOtCtl.getSrpServerState()).ignoringCase().isNotEqualTo("disabled");
         // TODO: b/376217403 - enables / disables Border Agent at runtime
     }
 
@@ -442,8 +352,4 @@
             throw new IllegalStateException(e);
         }
     }
-
-    private void assertTunInterfaceMemberOfGroup(Inet6Address address) throws Exception {
-        waitFor(() -> isInMulticastGroup(TUN_IF_NAME, address), TUN_ADDR_UPDATE_TIMEOUT);
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 2f0ab34..804a332 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -28,11 +28,13 @@
 
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
 
 import android.content.Context;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
 
@@ -66,11 +68,7 @@
 
     @Before
     public void setUp() throws Exception {
-        // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
-        // results in timeout error.
-        // A future fix will provide proper support for factoryreset, allowing us to replace the
-        // legacy "ot-ctl".
-        mOtCtl.factoryReset();
+        mController.leaveAndWait();
 
         mFtd = new FullThreadDevice(10 /* nodeId */);
         ensureThreadEnabled();
@@ -143,6 +141,8 @@
 
     @Test
     public void forceCountryCode_setCN_getCountryCodeReturnsCN() {
+        assumeTrue(mOtCtl.isCountryCodeSupported());
+
         runThreadCommand("force-country-code enabled CN");
 
         final String result = runThreadCommand("get-country-code");
@@ -168,6 +168,7 @@
     }
 
     @Test
+    @RequiresSimulationThreadDevice
     public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         startFtdChild(mFtd, DEFAULT_DATASET);
diff --git a/thread/tests/multidevices/Android.bp b/thread/tests/multidevices/Android.bp
index 050caa8..1d2ae62 100644
--- a/thread/tests/multidevices/Android.bp
+++ b/thread/tests/multidevices/Android.bp
@@ -35,9 +35,4 @@
         "mts-tethering",
         "general-tests",
     ],
-    version: {
-        py3: {
-            embedded_launcher: true,
-        },
-    },
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index bc8da8b..95ebda5 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -35,6 +35,7 @@
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_THREAD_ENABLED;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -42,6 +43,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -93,7 +95,6 @@
 
 import androidx.test.annotation.UiThreadTest;
 import androidx.test.core.app.ApplicationProvider;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
 import com.android.connectivity.resources.R;
@@ -112,6 +113,7 @@
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.InOrder;
@@ -124,6 +126,8 @@
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
@@ -132,7 +136,7 @@
 
 /** Unit tests for {@link ThreadNetworkControllerService}. */
 @SmallTest
-@RunWith(AndroidJUnit4.class)
+@RunWith(Parameterized.class)
 // This test doesn't really need to run on the UI thread, but @Before and @Test annotated methods
 // need to run in the same thread because there are code in {@code ThreadNetworkControllerService}
 // checking that all its methods are running in the thread of the handler it's using. This is due
@@ -200,6 +204,17 @@
     @Rule(order = 1)
     public final TemporaryFolder tempFolder = new TemporaryFolder();
 
+    private final boolean mIsBorderRouterEnabled;
+
+    @Parameterized.Parameters
+    public static Collection configArguments() {
+        return Arrays.asList(new Object[][] {{false}, {true}});
+    }
+
+    public ThreadNetworkControllerServiceTest(boolean isBorderRouterEnabled) {
+        mIsBorderRouterEnabled = isBorderRouterEnabled;
+    }
+
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
@@ -231,6 +246,8 @@
 
         when(mConnectivityResources.get()).thenReturn(mResources);
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+        when(mResources.getBoolean(eq(R.bool.config_thread_border_router_default_enabled)))
+                .thenReturn(mIsBorderRouterEnabled);
         when(mResources.getBoolean(
                         eq(R.bool.config_thread_srp_server_wait_for_border_routing_enabled)))
                 .thenReturn(true);
@@ -564,7 +581,7 @@
         mTestLooper.dispatchAll();
 
         assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_DISABLED);
-        assertThat(mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED)).isTrue();
+        assertThat(mPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
@@ -920,7 +937,9 @@
     }
 
     @Test
-    public void initialize_upstreamNetworkRequestHasCertainTransportTypesAndCapabilities() {
+    public void initialize_borderRouterEnabled_upstreamNetworkRequestHasExpectedTransportAndCaps() {
+        assumeTrue(mIsBorderRouterEnabled);
+
         mService.initialize();
         mTestLooper.dispatchAll();
 
@@ -999,7 +1018,8 @@
     }
 
     @Test
-    public void activateEphemeralKeyMode_succeed() throws Exception {
+    public void activateEphemeralKeyMode_borderRouterEnabled_succeed() throws Exception {
+        assumeTrue(mIsBorderRouterEnabled);
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
 
@@ -1010,7 +1030,8 @@
     }
 
     @Test
-    public void deactivateEphemeralKeyMode_succeed() throws Exception {
+    public void deactivateEphemeralKeyMode_borderRouterEnabled_succeed() throws Exception {
+        assumeTrue(mIsBorderRouterEnabled);
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
 
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
index ca9741d..6eb9b50 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
@@ -17,9 +17,10 @@
 package com.android.server.thread;
 
 import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -109,6 +110,7 @@
 
     private ThreadNetworkCountryCode mThreadNetworkCountryCode;
     private boolean mErrorSetCountryCode;
+    private boolean mErrorUnsupportedFeatureSetCountryCode;
 
     @Captor private ArgumentCaptor<LocationListener> mLocationListenerCaptor;
     @Captor private ArgumentCaptor<Geocoder.GeocodeListener> mGeocodeListenerCaptor;
@@ -143,6 +145,10 @@
 
                     if (mErrorSetCountryCode) {
                         cb.onError(ERROR_INTERNAL_ERROR, new String("Invalid country code"));
+                    } else if (mErrorUnsupportedFeatureSetCountryCode) {
+                        cb.onError(
+                                ERROR_UNSUPPORTED_FEATURE,
+                                new String("Setting country code is not supported"));
                     } else {
                         cb.onSuccess();
                     }
@@ -453,8 +459,41 @@
     }
 
     @Test
+    public void setCountryCodeNotSupported_returnUnsupportedFeatureError_countryCodeNotSetAgain() {
+        mThreadNetworkCountryCode.initialize();
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+
+        mErrorUnsupportedFeatureSetCountryCode = true;
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(TEST_COUNTRY_CODE_CN), mOperationReceiverCaptor.capture());
+
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_US);
+        verifyNoMoreInteractions(mThreadNetworkControllerService);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
+    public void setCountryCodeNotSupported_returnUnsupportedFeatureError_unregisterAllCallbacks() {
+        mThreadNetworkCountryCode.initialize();
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+
+        mErrorUnsupportedFeatureSetCountryCode = true;
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(TEST_COUNTRY_CODE_CN), mOperationReceiverCaptor.capture());
+
+        verify(mLocationManager).removeUpdates(mLocationListenerCaptor.capture());
+        verify(mWifiManager)
+                .unregisterActiveCountryCodeChangedCallback(
+                        mWifiCountryCodeReceiverCaptor.capture());
+        verify(mContext).unregisterReceiver(mTelephonyCountryCodeReceiverCaptor.capture());
+    }
+
+    @Test
     public void settingsCountryCode_settingsCountryCodeIsActive_settingsCountryCodeIsUsed() {
-        when(mPersistentSettings.get(THREAD_COUNTRY_CODE)).thenReturn(TEST_COUNTRY_CODE_CN);
+        when(mPersistentSettings.get(KEY_COUNTRY_CODE)).thenReturn(TEST_COUNTRY_CODE_CN);
         mThreadNetworkCountryCode.initialize();
 
         assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
@@ -468,6 +507,7 @@
         mThreadNetworkCountryCode.dump(new FileDescriptor(), printWriter, null);
         String outputString = stringWriter.toString();
 
+        assertThat(outputString).contains("mIsCpSettingCountryCodeSupported");
         assertThat(outputString).contains("mOverrideCountryCodeInfo");
         assertThat(outputString).contains("mTelephonyCountryCodeSlotInfoMap");
         assertThat(outputString).contains("mTelephonyCountryCodeInfo");
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
index ba489d9..15f3d0b 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadPersistentSettingsTest.java
@@ -16,8 +16,8 @@
 
 package com.android.server.thread;
 
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_COUNTRY_CODE;
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_COUNTRY_CODE;
+import static com.android.server.thread.ThreadPersistentSettings.KEY_THREAD_ENABLED;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -35,6 +35,7 @@
 
 import com.android.connectivity.resources.R;
 import com.android.server.connectivity.ConnectivityResources;
+import com.android.server.thread.ThreadPersistentSettings.Key;
 
 import org.junit.After;
 import org.junit.Before;
@@ -83,68 +84,81 @@
 
     @Test
     public void initialize_readsFromFile() throws Exception {
-        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
+        byte[] data = createXmlForParsing(KEY_THREAD_ENABLED, false);
         setupAtomicFileForRead(data);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
     }
 
     @Test
     public void initialize_ThreadDisabledInResources_returnsThreadDisabled() throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
-        setupAtomicFileForRead(new byte[0]);
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
+    }
+
+    @Test
+    public void initialize_ThreadEnabledInResources_returnsThreadEnabled() throws Exception {
+        when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
+
+        mThreadPersistentSettings.initialize();
+
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void initialize_ThreadDisabledInResourcesButEnabledInXml_returnsThreadEnabled()
             throws Exception {
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(false);
-        byte[] data = createXmlForParsing(THREAD_ENABLED.key, true);
-        setupAtomicFileForRead(data);
+        setupAtomicFileForRead(createXmlForParsing(KEY_THREAD_ENABLED, true));
+        mThreadPersistentSettings =
+                new ThreadPersistentSettings(mAtomicFile, mConnectivityResources);
 
         mThreadPersistentSettings.initialize();
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
-        mThreadPersistentSettings.put(THREAD_ENABLED.key, true);
+        mThreadPersistentSettings.put(KEY_THREAD_ENABLED, true);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isTrue();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isTrue();
     }
 
     @Test
     public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
-        mThreadPersistentSettings.put(THREAD_ENABLED.key, false);
+        mThreadPersistentSettings.put(KEY_THREAD_ENABLED, false);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_ENABLED)).isFalse();
+        assertThat(mThreadPersistentSettings.get(KEY_THREAD_ENABLED)).isFalse();
     }
 
     @Test
     public void put_ThreadCountryCodeString_returnsString() throws Exception {
-        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, TEST_COUNTRY_CODE);
+        mThreadPersistentSettings.put(KEY_COUNTRY_CODE, TEST_COUNTRY_CODE);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isEqualTo(TEST_COUNTRY_CODE);
     }
 
     @Test
     public void put_ThreadCountryCodeNull_returnsNull() throws Exception {
-        mThreadPersistentSettings.put(THREAD_COUNTRY_CODE.key, null);
+        mThreadPersistentSettings.put(KEY_COUNTRY_CODE, null);
 
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isNull();
         mThreadPersistentSettings.initialize();
-        assertThat(mThreadPersistentSettings.get(THREAD_COUNTRY_CODE)).isNull();
+        assertThat(mThreadPersistentSettings.get(KEY_COUNTRY_CODE)).isNull();
     }
 
     @Test
@@ -202,10 +216,10 @@
         return new AtomicFile(mTemporaryFolder.newFile());
     }
 
-    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
+    private byte[] createXmlForParsing(Key<Boolean> key, Boolean value) throws Exception {
         PersistableBundle bundle = new PersistableBundle();
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
-        bundle.putBoolean(key, value);
+        bundle.putBoolean(key.key, value);
         bundle.writeToStream(outputStream);
         return outputStream.toByteArray();
     }
diff --git a/thread/tests/utils/Android.bp b/thread/tests/utils/Android.bp
index 726ec9d..7990752 100644
--- a/thread/tests/utils/Android.bp
+++ b/thread/tests/utils/Android.bp
@@ -31,6 +31,7 @@
     ],
     srcs: [
         "src/**/*.java",
+        "src/**/*.kt",
     ],
     defaults: [
         "framework-connectivity-test-defaults",
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
similarity index 98%
rename from thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
rename to thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
index 209eed6..ed63fd0 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
@@ -239,6 +239,12 @@
         executeCommand("udp send %s %d %s", serverAddr.getHostAddress(), serverPort, message);
     }
 
+    /** Sets `true` to enable SRP server on this device. */
+    public void setSrpServerEnabled(boolean enabled) {
+        String cmd = enabled ? "enable" : "disable";
+        executeCommand("srp server " + cmd);
+    }
+
     /** Enables the SRP client and run in autostart mode. */
     public void autoStartSrpClient() {
         executeCommand("srp client autostart enable");
@@ -521,7 +527,7 @@
     }
 
     /** Waits for an SRP server to be present in Network Data */
-    private void waitForSrpServer() throws TimeoutException {
+    public void waitForSrpServer() throws TimeoutException {
         // CLI output:
         // > srp client server
         // [fd64:db12:25f4:7e0b:1bfc:6344:25ac:2dd7]:53538
diff --git a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java b/thread/tests/utils/src/android/net/thread/utils/InfraNetworkDevice.java
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
rename to thread/tests/utils/src/android/net/thread/utils/InfraNetworkDevice.java
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
similarity index 92%
rename from thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
rename to thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
index 801e21e..77d0955 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/utils/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -455,9 +455,8 @@
     fun isInMulticastGroup(interfaceName: String, address: Inet6Address): Boolean {
         val cmd = "ip -6 maddr show dev $interfaceName"
         val output: String = runShellCommandOrThrow(cmd)
-        val addressStr = address.hostAddress
         for (line in output.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) {
-            if (line.contains(addressStr)) {
+            if (line.contains(address.hostAddress!!)) {
                 return true
             }
         }
@@ -479,15 +478,37 @@
         return addresses
     }
 
-    /** Return the first discovered service of `serviceType`.  */
+    /** Returns the list of [InetAddress] of the given network. */
+    @JvmStatic
+    fun getIpv6Addresses(interfaceName: String): List<InetAddress> {
+        return getIpv6LinkAddresses(interfaceName).map { it.address }
+    }
+
+    /** Return the first discovered service of `serviceType`. */
     @JvmStatic
     @Throws(Exception::class)
     fun discoverService(nsdManager: NsdManager, serviceType: String): NsdServiceInfo {
+        return discoverService(nsdManager, serviceType, null)
+    }
+
+    /**
+     * Returns the service that matches `serviceType` and `serviceName`.
+     *
+     * If `serviceName` is null, returns the first discovered service. `serviceName` is not case
+     * sensitive.
+     */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun discoverService(nsdManager: NsdManager, serviceType: String, serviceName: String?):
+            NsdServiceInfo {
         val serviceInfoFuture = CompletableFuture<NsdServiceInfo>()
         val listener: NsdManager.DiscoveryListener = object : DefaultDiscoveryListener() {
             override fun onServiceFound(serviceInfo: NsdServiceInfo) {
                 Log.d(TAG, "onServiceFound: $serviceInfo")
-                serviceInfoFuture.complete(serviceInfo)
+                if (serviceName == null ||
+                        serviceInfo.getServiceName().equals(serviceName, true /* ignore case */)) {
+                    serviceInfoFuture.complete(serviceInfo)
+                }
             }
         }
         nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, listener)
@@ -583,6 +604,17 @@
     }
 
     /**
+     * Let the FTD join the specified Thread network and wait for it becomes a Child or Router.
+     */
+    @JvmStatic
+    @Throws(Exception::class)
+    fun joinNetworkAndWait(ftd: FullThreadDevice, dataset: ActiveOperationalDataset) {
+        ftd.factoryReset()
+        ftd.joinNetwork(dataset)
+        ftd.waitForStateAnyOf(listOf("router", "child"), JOIN_TIMEOUT)
+    }
+
+    /**
      * Let the FTD join the specified Thread network and wait for border routing to be available.
      *
      * @return the OMR address
@@ -603,15 +635,31 @@
     /** Enables Thread and joins the specified Thread network. */
     @JvmStatic
     fun enableThreadAndJoinNetwork(dataset: ActiveOperationalDataset) {
-        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
-        OtDaemonController().factoryReset();
-
         val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
         val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        controller.leaveAndWait();
+
         controller.setEnabledAndWait(true);
         controller.joinAndWait(dataset);
     }
 
+    /** Enables Border Router and joins the specified Thread network. */
+    @JvmStatic
+    fun enableBorderRouterAndJoinNetwork(dataset: ActiveOperationalDataset) {
+        val context: Context = requireNotNull(ApplicationProvider.getApplicationContext());
+        val controller = requireNotNull(ThreadNetworkControllerWrapper.newInstance(context));
+
+        // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
+        controller.leaveAndWait();
+
+        controller.setEnabledAndWait(true);
+        val config = ThreadConfiguration.Builder().setBorderRouterEnabled(true).build();
+        controller.setConfigurationAndWait(config);
+        controller.joinAndWait(dataset);
+    }
+
     /** Leaves the Thread network and disables Thread. */
     @JvmStatic
     fun leaveNetworkAndDisableThread() {
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
similarity index 86%
rename from thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
rename to thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
index 9fbfa45..d35b94e 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
@@ -24,9 +24,11 @@
 import com.android.compatibility.common.util.SystemUtil;
 
 import java.net.Inet6Address;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /**
  * Wrapper of the "/system/bin/ot-ctl" which can be used to send CLI commands to ot-daemon to
@@ -72,6 +74,25 @@
                 .toList();
     }
 
+    /** Returns the OMR address of this device or {@code null} if it doesn't exist. */
+    @Nullable
+    public Inet6Address getOmrAddress() {
+        List<Inet6Address> allAddresses = new ArrayList<>(getAddresses());
+        allAddresses.removeAll(getMeshLocalAddresses());
+
+        List<Inet6Address> omrAddresses =
+                allAddresses.stream()
+                        .filter(addr -> !addr.isLinkLocalAddress())
+                        .collect(Collectors.toList());
+        if (omrAddresses.isEmpty()) {
+            return null;
+        } else if (omrAddresses.size() > 1) {
+            throw new IllegalStateException();
+        }
+
+        return omrAddresses.getFirst();
+    }
+
     /** Returns {@code true} if the Thread interface is up. */
     public boolean isInterfaceUp() {
         String output = executeCommand("ifconfig");
@@ -156,6 +177,12 @@
         return executeCommandAndParse("extpanid").get(0);
     }
 
+    public boolean isCountryCodeSupported() {
+        final String result = executeCommand("region");
+
+        return !result.equals("Error 12: NotImplemented\r\n");
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
index b586a19..9a1a05b 100644
--- a/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
+++ b/thread/tests/utils/src/android/net/thread/utils/TapTestNetworkTracker.java
@@ -100,27 +100,17 @@
     private void setUpTestNetwork() throws Exception {
         mInterface = mContext.getSystemService(TestNetworkManager.class).createTapInterface();
 
-        mConnectivityManager.requestNetwork(newNetworkRequest(), mNetworkCallback);
+        mConnectivityManager.requestNetwork(
+                TestableNetworkAgent.Companion.makeNetworkRequestForInterface(
+                        mInterface.getInterfaceName()),
+                mNetworkCallback);
 
         LinkProperties lp = new LinkProperties();
         lp.setInterfaceName(getInterfaceName());
         mAgent =
-                new TestableNetworkAgent(
-                        mContext,
-                        mLooper,
-                        newNetworkCapabilities(),
-                        lp,
-                        new NetworkAgentConfig.Builder().build());
-        mNetwork = mAgent.register();
-        mAgent.markConnected();
+                TestableNetworkAgent.Companion.createOnInterface(
+                        mContext, mLooper, mInterface.getInterfaceName(), TIMEOUT.toMillis());
 
-        PollingCheck.check(
-                "No usable address on interface",
-                TIMEOUT.toMillis(),
-                () -> hasUsableAddress(mNetwork, getInterfaceName()));
-
-        lp.setLinkAddresses(makeLinkAddresses());
-        mAgent.sendLinkProperties(lp);
         mNetworkCallback.eventuallyExpect(
                 LINK_PROPERTIES_CHANGED,
                 TIMEOUT.toMillis(),
@@ -133,59 +123,4 @@
         mInterface.getFileDescriptor().close();
         mAgent.waitForIdle(TIMEOUT.toMillis());
     }
-
-    private NetworkRequest newNetworkRequest() {
-        return new NetworkRequest.Builder()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()))
-                .build();
-    }
-
-    private NetworkCapabilities newNetworkCapabilities() {
-        return new NetworkCapabilities()
-                .removeCapability(NET_CAPABILITY_TRUSTED)
-                .addTransportType(TRANSPORT_TEST)
-                .setNetworkSpecifier(new TestNetworkSpecifier(getInterfaceName()));
-    }
-
-    private List<LinkAddress> makeLinkAddresses() {
-        List<LinkAddress> linkAddresses = new ArrayList<>();
-        List<InterfaceAddress> interfaceAddresses = Collections.emptyList();
-
-        try {
-            interfaceAddresses =
-                    NetworkInterface.getByName(getInterfaceName()).getInterfaceAddresses();
-        } catch (SocketException ignored) {
-            // Ignore failures when getting the addresses.
-        }
-
-        for (InterfaceAddress address : interfaceAddresses) {
-            linkAddresses.add(
-                    new LinkAddress(address.getAddress(), address.getNetworkPrefixLength()));
-        }
-
-        return linkAddresses;
-    }
-
-    private static boolean hasUsableAddress(Network network, String interfaceName) {
-        try {
-            if (NetworkInterface.getByName(interfaceName).getInterfaceAddresses().isEmpty()) {
-                return false;
-            }
-        } catch (SocketException e) {
-            return false;
-        }
-        // Check if the link-local address can be used. Address flags are not available without
-        // elevated permissions, so check that bindSocket works.
-        try {
-            FileDescriptor sock = Os.socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP);
-            network.bindSocket(sock);
-            Os.connect(sock, parseNumericAddress("ff02::fb%" + interfaceName), 12345);
-            Os.close(sock);
-        } catch (ErrnoException | IOException e) {
-            return false;
-        }
-        return true;
-    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestDnsServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestDnsServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestDnsServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt b/thread/tests/utils/src/android/net/thread/utils/TestTunNetworkUtils.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestTunNetworkUtils.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestTunNetworkUtils.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestUdpEchoServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestUdpEchoServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestUdpEchoServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt b/thread/tests/utils/src/android/net/thread/utils/TestUdpServer.kt
similarity index 100%
rename from thread/tests/integration/src/android/net/thread/utils/TestUdpServer.kt
rename to thread/tests/utils/src/android/net/thread/utils/TestUdpServer.kt
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
similarity index 97%
rename from thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
rename to thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
index b6114f3..c4150cb 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -233,7 +233,10 @@
     public void setNat64EnabledAndWait(boolean enabled) throws Exception {
         final ThreadConfiguration config = getConfiguration();
         final ThreadConfiguration newConfig =
-                new ThreadConfiguration.Builder(config).setNat64Enabled(enabled).build();
+                new ThreadConfiguration.Builder(config)
+                        .setBorderRouterEnabled(true)
+                        .setNat64Enabled(enabled)
+                        .build();
         setConfigurationAndWait(newConfig);
     }