Snap for 13226929 from 98f6178eaceea5574cc1f0114703174b40112d87 to 25Q2-release

Change-Id: I9f33c3761ef16c11c844d22cda3f1050934221f2
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 0a66f01..2f9c3bc 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -37,6 +37,7 @@
 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;
@@ -1258,6 +1259,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
          */
@@ -1279,6 +1318,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/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index d0ba431..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.
diff --git a/Tethering/src/com/android/networkstack/tethering/RequestTracker.java b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
index 3ebe4f7..9c61716 100644
--- a/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
+++ b/Tethering/src/com/android/networkstack/tethering/RequestTracker.java
@@ -19,6 +19,8 @@
 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;
@@ -28,6 +30,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 
 /**
  * Helper class to keep track of tethering requests.
@@ -36,29 +39,17 @@
  *    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 all pending requests with {@link #removeAllPendingRequests(int)}.
+ * 3) Remove pending request with {@link #removePendingRequest(TetheringRequest)}.
  * Note: This class is not thread-safe.
- * TODO: Add the pending IIntResultListeners at the same time as the pending requests, and
- *       call them when we get the tether result.
- * TODO: Add support for multiple Bluetooth requests before the PAN service connects instead of
- *       using a separate mPendingPanRequestListeners.
- * TODO: Add support for fuzzy-matched requests.
  */
 public class RequestTracker {
     private static final String TAG = RequestTracker.class.getSimpleName();
 
-    private class PendingRequest {
-        @NonNull
-        private final TetheringRequest mTetheringRequest;
+    @NonNull
+    private final boolean mUseFuzzyMatching;
 
-        private PendingRequest(@NonNull TetheringRequest tetheringRequest) {
-            mTetheringRequest = tetheringRequest;
-        }
-
-        @NonNull
-        TetheringRequest getTetheringRequest() {
-            return mTetheringRequest;
-        }
+    public RequestTracker(boolean useFuzzyMatching) {
+        mUseFuzzyMatching = useFuzzyMatching;
     }
 
     public enum AddResult {
@@ -69,24 +60,51 @@
         /**
          * 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.
+         * pending request before trying to add the result again. Only returned on V-.
          */
-        FAILURE_CONFLICTING_PENDING_REQUEST
+        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.
+     * 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<PendingRequest> mPendingRequests = new ArrayList<>();
+    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() {
-        List<TetheringRequest> requests = new ArrayList<>();
-        for (PendingRequest pendingRequest : mPendingRequests) {
-            requests.add(pendingRequest.getTetheringRequest());
+        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;
+            }
         }
-        return requests;
+
+        mPendingRequests.add(newRequest);
+        return AddResult.SUCCESS;
     }
 
     /**
@@ -95,9 +113,12 @@
      * 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 (PendingRequest request : mPendingRequests) {
-            TetheringRequest existingRequest = request.getTetheringRequest();
+        for (TetheringRequest existingRequest : mPendingRequests) {
             if (existingRequest.getTetheringType() != newRequest.getTetheringType()) {
                 continue;
             }
@@ -105,7 +126,7 @@
             // Can't add request if there's a request of the same type with different
             // parameters.
             if (!existingRequest.equalsIgnoreUidPackage(newRequest)) {
-                return AddResult.FAILURE_CONFLICTING_PENDING_REQUEST;
+                return AddResult.FAILURE_DUPLICATE_REQUEST_RESTART;
             }
         }
 
@@ -113,7 +134,7 @@
         // conflicting parameters above, so these would have been equivalent anyway (except for
         // UID).
         removeAllPendingRequests(newRequest.getTetheringType());
-        mPendingRequests.add(new PendingRequest(newRequest));
+        mPendingRequests.add(newRequest);
         return AddResult.SUCCESS;
     }
 
@@ -145,10 +166,8 @@
      */
     @Nullable
     public TetheringRequest getNextPendingRequest(int type) {
-        for (PendingRequest pendingRequest : mPendingRequests) {
-            TetheringRequest tetheringRequest =
-                    pendingRequest.getTetheringRequest();
-            if (tetheringRequest.getTetheringType() == type) return tetheringRequest;
+        for (TetheringRequest request : mPendingRequests) {
+            if (request.getTetheringType() == type) return request;
         }
         return null;
     }
@@ -158,7 +177,83 @@
      *
      * @param type Tethering type
      */
-    public void removeAllPendingRequests(int type) {
-        mPendingRequests.removeIf(r -> r.getTetheringRequest().getTetheringType() == 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);
+    }
+
+    /**
+     * 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 1a26658..c7ae353 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -43,6 +43,7 @@
 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;
@@ -67,6 +68,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;
@@ -145,7 +148,6 @@
 import com.android.internal.util.State;
 import com.android.internal.util.StateMachine;
 import com.android.modules.utils.build.SdkLevel;
-import com.android.net.flags.Flags;
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.HandlerUtils;
@@ -309,7 +311,7 @@
         mLooper = mDeps.makeTetheringLooper();
         mNotificationUpdater = mDeps.makeNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.makeTetheringMetrics(mContext);
-        mRequestTracker = new RequestTracker();
+        mRequestTracker = new RequestTracker(isTetheringWithSoftApConfigEnabled());
 
         mTetherStates = new ArrayMap<>();
         mConnectedClientsTracker = new ConnectedClientsTracker();
@@ -461,7 +463,7 @@
     }
 
     boolean isTetheringWithSoftApConfigEnabled() {
-        return SdkLevel.isAtLeastB() && Flags.tetheringWithSoftApConfig();
+        return mDeps.isTetheringWithSoftApConfigEnabled();
     }
 
     /**
@@ -704,9 +706,16 @@
             RequestTracker.AddResult result = mRequestTracker.addPendingRequest(request);
             // If tethering is already pending with a conflicting request, stop tethering before
             // starting.
-            if (result == RequestTracker.AddResult.FAILURE_CONFLICTING_PENDING_REQUEST) {
+            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;
             }
 
             if (request.isExemptFromEntitlementCheck()) {
@@ -726,39 +735,28 @@
         });
     }
 
-    private boolean isTetheringTypePendingOrServing(final int type) {
-        if (mRequestTracker.getNextPendingRequest(type) != null) 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) {
         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.
@@ -802,21 +800,25 @@
         // 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);
+        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) {
-            mRequestTracker.removeAllPendingRequests(type);
+            mRequestTracker.removePendingRequest(request);
+            final int type = request.getTetheringType();
             mTetheringMetrics.updateErrorCode(type, result);
             mTetheringMetrics.sendReport(type);
         }
@@ -862,8 +864,7 @@
             // the service just to stop tethering since tethering is not started. Just remove
             // any pending request to enable tethering, and notify them that they have failed.
             if (mPendingPanRequestListener != null) {
-                sendTetherResult(mPendingPanRequestListener, TETHER_ERROR_SERVICE_UNAVAIL,
-                        TETHERING_BLUETOOTH);
+                sendTetherResult(mPendingPanRequestListener, TETHER_ERROR_SERVICE_UNAVAIL);
             }
             mPendingPanRequestListener = null;
             return TETHER_ERROR_NO_ERROR;
@@ -904,7 +905,10 @@
                 if (mPendingPanRequestListener != null) {
                     final int result = setBluetoothTetheringSettings(mBluetoothPan,
                             true /* enable */);
-                    sendTetherResult(mPendingPanRequestListener, result, TETHERING_BLUETOOTH);
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            result);
                 }
                 mPendingPanRequestListener = null;
             });
@@ -918,8 +922,10 @@
                 mIsConnected = false;
 
                 if (mPendingPanRequestListener != null) {
-                    sendTetherResult(mPendingPanRequestListener, TETHER_ERROR_SERVICE_UNAVAIL,
-                            TETHERING_BLUETOOTH);
+                    sendTetherResultAndRemoveOnError(
+                            mRequestTracker.getOrCreatePendingRequest(TETHERING_BLUETOOTH),
+                            mPendingPanRequestListener,
+                            TETHER_ERROR_SERVICE_UNAVAIL);
                 }
                 mPendingPanRequestListener = null;
                 mBluetoothIfaceRequest = null;
@@ -1093,9 +1099,7 @@
         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);
         }
 
         TetheringRequest request = mRequestTracker.getNextPendingRequest(type);
@@ -1132,9 +1136,7 @@
                 // Do nothing
                 break;
         }
-        try {
-            listener.onResult(result);
-        } catch (RemoteException e) { }
+        sendTetherResult(listener, result);
     }
 
     /**
@@ -1167,11 +1169,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.
-        mRequestTracker.removeAllPendingRequests(request.getTetheringType());
         tetherState.ipServer.enable(request);
         if (request.getRequestType() == REQUEST_TYPE_PLACEHOLDER) {
             TerribleErrorLog.logTerribleError(TetheringStatsLog::write,
@@ -1191,10 +1191,7 @@
             return;
         }
         mHandler.post(() -> {
-            try {
-                listener.onResult(legacyUntetherInternal(iface));
-            } catch (RemoteException e) {
-            }
+            sendTetherResult(listener, legacyUntetherInternal(iface));
         });
     }
 
@@ -1209,7 +1206,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;
     }
 
@@ -1289,7 +1286,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,
@@ -1611,7 +1613,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;
         }
 
@@ -1628,7 +1630,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;
             }
         }
@@ -1775,9 +1777,7 @@
 
     void setUsbTethering(boolean enable, IIntResultListener listener) {
         mHandler.post(() -> {
-            try {
-                listener.onResult(setUsbTethering(enable));
-            } catch (RemoteException e) { }
+            sendTetherResult(listener, setUsbTethering(enable));
         });
     }
 
@@ -2904,6 +2904,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);
             }
 
@@ -2918,7 +2921,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:
@@ -3014,11 +3029,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);
     }
@@ -3026,9 +3048,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/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index bd35cf2..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,9 +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 SdkLevel.isAtLeastB() && Flags.tetheringWithSoftApConfig();
+        return SdkLevel.isAtLeastB();
     }
 }
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
index e00e9f0..086f2d2 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/RequestTrackerTest.java
@@ -18,19 +18,22 @@
 
 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.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -39,13 +42,10 @@
 public class RequestTrackerTest {
     private RequestTracker mRequestTracker;
 
-    @Before
-    public void setUp() {
-        mRequestTracker = new RequestTracker();
-    }
-
     @Test
     public void testNoRequestsAdded_noPendingRequests() {
+        mRequestTracker = new RequestTracker(false);
+
         assertThat(mRequestTracker.getNextPendingRequest(TETHERING_WIFI)).isNull();
         assertThat(mRequestTracker.getOrCreatePendingRequest(TETHERING_WIFI))
                 .isEqualTo(createPlaceholderRequest(TETHERING_WIFI));
@@ -53,6 +53,7 @@
 
     @Test
     public void testAddRequest_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
         final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
 
         final AddResult result = mRequestTracker.addPendingRequest(request);
@@ -64,6 +65,7 @@
 
     @Test
     public void testAddRequest_equalRequestExists_successResultAndBecomesNextPending() {
+        mRequestTracker = new RequestTracker(false);
         final TetheringRequest request = new TetheringRequest.Builder(TETHERING_WIFI).build();
         mRequestTracker.addPendingRequest(request);
 
@@ -77,6 +79,7 @@
 
     @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");
@@ -94,7 +97,8 @@
     }
 
     @Test
-    public void testAddConflictingRequest_returnsFailureConflictingPendingRequest() {
+    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();
@@ -102,13 +106,139 @@
 
         final AddResult result = mRequestTracker.addPendingRequest(conflictingRequest);
 
-        assertThat(result).isEqualTo(AddResult.FAILURE_CONFLICTING_PENDING_REQUEST);
+        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");
@@ -127,6 +257,7 @@
 
     @Test
     public void testRemoveAllPendingRequests_differentTypeExists_doesNotRemoveDifferentType() {
+        mRequestTracker = new RequestTracker(false);
         final TetheringRequest differentType = new TetheringRequest.Builder(TETHERING_USB).build();
         mRequestTracker.addPendingRequest(differentType);
 
@@ -136,4 +267,61 @@
         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/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index 74bab03..a69ead8 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;
@@ -308,8 +311,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();
 
@@ -340,6 +342,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) {
@@ -396,6 +399,9 @@
     }
 
     public class MockIpServerDependencies extends IpServer.Dependencies {
+
+        private int mOnDhcpServerCreatedResult = STATUS_SUCCESS;
+
         @Override
         public DadProxy getDadProxy(
                 Handler handler, InterfaceParams ifParams) {
@@ -437,7 +443,7 @@
                 DhcpServerCallbacks cb) {
             new Thread(() -> {
                 try {
-                    cb.onDhcpServerCreated(STATUS_SUCCESS, mDhcpServer);
+                    cb.onDhcpServerCreated(mOnDhcpServerCreatedResult, mDhcpServer);
                 } catch (RemoteException e) {
                     fail(e.getMessage());
                 }
@@ -448,6 +454,10 @@
                 IpNeighborMonitor.NeighborEventConsumer c) {
             return mIpNeighborMonitor;
         }
+
+        public void setOnDhcpServerCreatedResult(final int result) {
+            mOnDhcpServerCreatedResult = result;
+        }
     }
 
     public class MockTetheringDependencies extends TetheringDependencies {
@@ -573,6 +583,11 @@
         public int getBinderCallingUid() {
             return mBinderCallingUid;
         }
+
+        @Override
+        public boolean isTetheringWithSoftApConfigEnabled() {
+            return mTetheringWithSoftApConfigEnabled;
+        }
     }
 
     private static LinkProperties buildUpstreamLinkProperties(String interfaceName,
@@ -708,6 +723,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.
@@ -1944,7 +1962,6 @@
     @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,
@@ -1981,7 +1998,6 @@
     @Test
     public void workingWifiTetheringEnrichedApBroadcast() 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,
@@ -2030,7 +2046,6 @@
     @Test
     public void failureEnablingIpForwarding() throws Exception {
         initTetheringOnTestThread();
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
         doThrow(new RemoteException()).when(mNetd).ipfwdEnableForwarding(TETHERING_NAME);
 
         // Emulate pressing the WiFi tethering button.
@@ -2374,7 +2389,6 @@
         // 2. Enable wifi tethering.
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         initTetheringUpstream(upstreamState);
-        when(mWifiManager.startTetheredHotspot(null)).thenReturn(true);
 
         mTethering.startTethering(createTetheringRequest(TETHERING_WIFI), TEST_CALLER_PKG,
                 null);
@@ -2425,7 +2439,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))
@@ -2477,19 +2491,16 @@
         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();
-        }
+        // Netd "up" event should not trigger a state change callback in B+.
+        callback.assertNoStateChangeCallback();
         sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
-        mLooper.dispatchAll();
         // Verify we see  Available -> Tethered states
         assertArrayEquals(new TetheringInterface[] {wifiIfaceWithoutConfig},
                 callback.pollTetherStatesChanged().availableList);
@@ -2507,20 +2518,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,
+        mLooper.dispatchAll();
+        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME,
                     IFACE_IP_MODE_TETHERED);
-        }
-        assertArrayEquals(new TetheringInterface[] {wifiIfaceWithConfig},
+        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);
@@ -2529,6 +2535,320 @@
     }
 
     @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.assertHasResult();
+
+        // 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();
+        verify(mWifiManager, times(1)).startTetheredHotspot(any());
+        verify(mWifiManager, never()).stopSoftAp();
+        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();
+        verify(mWifiManager, times(1)).startTetheredHotspot(any());
+        verify(mWifiManager, never()).stopSoftAp();
+
+        // Mock the link layer event to start IP serving and verify we still get
+        // TETHER_ERROR_DUPLICATE_REQUEST even though the request is no longer pending and is
+        // already serving.
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+        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());
+        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();
+        successListener.assertHasResult();
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        // 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();
+        successListener.assertHasResult();
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        // 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.
+        sendWifiApStateChanged(WIFI_AP_STATE_DISABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        // 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();
+        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();
+        successListener.assertHasResult();
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        // 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();
+        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();
+        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();
+        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();
+        successListener.assertHasResult();
+        sendWifiApStateChanged(WIFI_AP_STATE_ENABLED, TEST_WLAN_IFNAME, IFACE_IP_MODE_TETHERED);
+
+        // 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();
+        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();
@@ -2794,7 +3114,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");
@@ -2891,7 +3212,6 @@
         final int clientAddrParceled = 0xc0a8002a;
         final ArgumentCaptor<DhcpServingParamsParcel> dhcpParamsCaptor =
                 ArgumentCaptor.forClass(DhcpServingParamsParcel.class);
-        when(mWifiManager.startTetheredHotspot(any())).thenReturn(true);
         mTethering.startTethering(createTetheringRequest(TETHERING_WIFI,
                         serverLinkAddr, clientLinkAddr, false, CONNECTIVITY_SCOPE_GLOBAL, null),
                 TEST_CALLER_PKG, null);
@@ -3055,26 +3375,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() {
@@ -3575,6 +3907,7 @@
     @Test
     public void testStartBluetoothTetheringFailsWhenTheresAnExistingRequestWaitingForPanService()
             throws Exception {
+        mTetheringWithSoftApConfigEnabled = false;
         initTetheringOnTestThread();
 
         mockBluetoothSettings(true /* bluetoothOn */, true /* tetheringOn */);
@@ -3873,6 +4206,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.
@@ -3885,7 +4233,6 @@
         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);
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/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index 1cf5e4d..11a374d 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -316,7 +316,7 @@
             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.
diff --git a/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java b/tests/cts/tethering/src/android/tethering/cts/TetheringManagerTest.java
index e645f67..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;
@@ -85,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;
 
@@ -234,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()) {
@@ -380,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 (!SdkLevel.isAtLeastB()) {
+                final String wifiTetheringIface = tetheredIface.getInterface();
                 try {
                     final int ret = runAsShell(TETHER_PRIVILEGED,
                             () -> mTM.tether(wifiTetheringIface));
@@ -455,31 +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 SdkLevel.isAtLeastB() && 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
@@ -492,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);
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/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 40f0089..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,7 +14,7 @@
  * 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;
@@ -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;
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 99%
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 46d4708..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
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 5b07e0a..b608c5d 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -21,26 +21,20 @@
 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.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.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -49,23 +43,21 @@
 import android.net.ConnectivityManager;
 import android.net.InetAddresses;
 import android.net.IpPrefix;
-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.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 android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
 
 import com.google.common.collect.FluentIterable;
 
@@ -74,7 +66,6 @@
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.junit.runners.Parameterized;
 
 import java.io.IOException;
 import java.net.DatagramPacket;
@@ -83,7 +74,6 @@
 import java.net.InetAddress;
 import java.time.Duration;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
@@ -92,7 +82,7 @@
 /** Tests for E2E Android Thread integration with ot-daemon, ConnectivityService, etc.. */
 @LargeTest
 @RequiresThreadFeature
-@RunWith(Parameterized.class)
+@RunWith(AndroidJUnit4.class)
 public class ThreadIntegrationTest {
     // The byte[] buffer size for UDP tests
     private static final int UDP_BUFFER_SIZE = 1024;
@@ -100,17 +90,8 @@
     // 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);
-
-    // The maximum time for changes in netdata to be propagated to link properties.
-    private static final Duration LINK_PROPERTIES_UPDATE_TIMEOUT = Duration.ofSeconds(1);
-
     private static final Duration NETWORK_CALLBACK_TIMEOUT = Duration.ofSeconds(10);
 
-    // The duration between attached and OMR address shows up on thread-wpan
-    private static final Duration OMR_LINK_ADDR_TIMEOUT = Duration.ofSeconds(30);
-
     // The duration between attached and addresses show up on thread-wpan
     private static final Duration LINK_ADDR_TIMEOUT = Duration.ofSeconds(2);
 
@@ -125,9 +106,6 @@
     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::");
@@ -140,54 +118,22 @@
             ThreadNetworkControllerWrapper.newInstance(mContext);
     private OtDaemonController mOtCtl;
     private FullThreadDevice mFtd;
-    private HandlerThread mHandlerThread;
-    private TapTestNetworkTracker mTestNetworkTracker;
-
-    public final boolean mIsBorderRouterEnabled;
-    private final ThreadConfiguration mConfig;
-
-    @Parameterized.Parameters
-    public static Collection configArguments() {
-        return Arrays.asList(new Object[][] {{false}, {true}});
-    }
-
-    public ThreadIntegrationTest(boolean isBorderRouterEnabled) {
-        mIsBorderRouterEnabled = isBorderRouterEnabled;
-        mConfig =
-                new ThreadConfiguration.Builder()
-                        .setBorderRouterEnabled(isBorderRouterEnabled)
-                        .build();
-    }
 
     @Before
     public void setUp() throws Exception {
         mExecutor = Executors.newSingleThreadExecutor();
+        mFtd = new FullThreadDevice(10 /* nodeId */);
         mOtCtl = new OtDaemonController();
         mController.setEnabledAndWait(true);
-        mController.setConfigurationAndWait(mConfig);
+        mController.setConfigurationAndWait(
+                new ThreadConfiguration.Builder().setBorderRouterEnabled(false).build());
         mController.leaveAndWait();
-
-        mHandlerThread = new HandlerThread("ThreadIntegrationTest");
-        mHandlerThread.start();
-
-        mTestNetworkTracker = new TapTestNetworkTracker(mContext, mHandlerThread.getLooper());
-        assertThat(mTestNetworkTracker).isNotNull();
-        mController.setTestNetworkAsUpstreamAndWait(mTestNetworkTracker.getInterfaceName());
-
-        mFtd = new FullThreadDevice(10 /* nodeId */);
     }
 
     @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();
 
@@ -207,20 +153,6 @@
     }
 
     @Test
-    public void otDaemonRestart_JoinedNetworkAndStopped_autoRejoinedAndTunIfStateConsistent()
-            throws Exception {
-        assumeTrue(mController.getConfiguration().isBorderRouterEnabled());
-        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);
 
@@ -294,33 +226,7 @@
     }
 
     @Test
-    public void joinNetwork_borderRouterEnabled_allMlAddrAreNotPreferredAndOmrIsPreferred()
-            throws Exception {
-        assumeTrue(mConfig.isBorderRouterEnabled());
-
-        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
-    public void joinNetwork_borderRouterDisabled_onlyMlEidIsPreferred() throws Exception {
-        assumeFalse(mConfig.isBorderRouterEnabled());
-
+    public void joinNetwork_onlyMlEidIsPreferred() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         waitFor(
                 () -> getIpv6Addresses("thread-wpan").contains(mOtCtl.getMlEid()),
@@ -342,13 +248,6 @@
     }
 
     @Test
-    public void joinNetwork_tunInterfaceJoinsAllRouterMulticastGroup() throws Exception {
-        mController.joinAndWait(DEFAULT_DATASET);
-
-        assertTunInterfaceMemberOfGroup(GROUP_ADDR_ALL_ROUTERS);
-    }
-
-    @Test
     public void joinNetwork_joinTheSameNetworkTwice_neverDetached() throws Exception {
         mController.joinAndWait(DEFAULT_DATASET);
         mController.waitForRole(DEVICE_ROLE_LEADER, JOIN_TIMEOUT);
@@ -379,55 +278,6 @@
     }
 
     @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);
-    }
-
-    @Test
     @RequiresSimulationThreadDevice
     public void setConfiguration_disableBorderRouter_borderRoutingDisabled() throws Exception {
         startFtdLeader(mFtd, DEFAULT_DATASET);
@@ -502,27 +352,4 @@
             throw new IllegalStateException(e);
         }
     }
-
-    private void assertTunInterfaceMemberOfGroup(Inet6Address address) throws Exception {
-        waitFor(() -> isInMulticastGroup(TUN_IF_NAME, address), TUN_ADDR_UPDATE_TIMEOUT);
-    }
-
-    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/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 100%
rename from thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
rename to thread/tests/utils/src/android/net/thread/utils/FullThreadDevice.java
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 99%
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 f7b4d19..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
             }
         }
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 100%
rename from thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
rename to thread/tests/utils/src/android/net/thread/utils/OtDaemonController.java
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 100%
rename from thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
rename to thread/tests/utils/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java