Merge "Improve error message for doTestContinuousQueries" into main
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
index 83619d6..39009cb 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -1,6 +1,15 @@
+[Builtin Hooks]
+bpfmt = true
+clang_format = true
+ktfmt = true
+
+[Builtin Hooks Options]
+clang_format = --commit ${PREUPLOAD_COMMIT} --style file --extensions c,h,cc,cpp,hpp
+ktfmt = --kotlinlang-style
+
 [Hook Scripts]
 checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT}
 
-ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py -f ${PREUPLOAD_FILES}
+ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py --no-verify-format -f ${PREUPLOAD_FILES}
 
 hidden_api_txt_checksorted_hook = ${REPO_ROOT}/tools/platform-compat/hiddenapi/checksorted_sha.sh ${PREUPLOAD_COMMIT} ${REPO_ROOT}
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 8b3102a..0f5a014 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -198,6 +198,10 @@
 
     /**
      * VIRTUAL tethering type.
+     *
+     * This tethering type is for providing external network to virtual machines
+     * running on top of Android devices, which are created and managed by
+     * AVF(Android Virtualization Framework).
      * @hide
      */
     @FlaggedApi(Flags.TETHERING_REQUEST_VIRTUAL)
@@ -1379,6 +1383,9 @@
     @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
     public void registerTetheringEventCallback(@NonNull Executor executor,
             @NonNull TetheringEventCallback callback) {
+        Objects.requireNonNull(executor);
+        Objects.requireNonNull(callback);
+
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "registerTetheringEventCallback caller:" + callerPkg);
 
@@ -1533,6 +1540,8 @@
             Manifest.permission.ACCESS_NETWORK_STATE
     })
     public void unregisterTetheringEventCallback(@NonNull final TetheringEventCallback callback) {
+        Objects.requireNonNull(callback);
+
         final String callerPkg = mContext.getOpPackageName();
         Log.i(TAG, "unregisterTetheringEventCallback caller:" + callerPkg);
 
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 5cdd6ab..b807544 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -72,7 +72,6 @@
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
-import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.SyncStateMachine.StateInfo;
 import com.android.net.module.util.ip.InterfaceController;
@@ -239,11 +238,8 @@
     private final INetd mNetd;
     @NonNull
     private final BpfCoordinator mBpfCoordinator;
-    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
-    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
-    // must be able to find all classes at runtime.
     @NonNull
-    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
+    private final RoutingCoordinatorManager mRoutingCoordinator;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
     private final PrivateAddressCoordinator mPrivateAddressCoordinator;
@@ -301,7 +297,7 @@
     public IpServer(
             String ifaceName, Handler handler, int interfaceType, SharedLog log,
             INetd netd, @NonNull BpfCoordinator bpfCoordinator,
-            @Nullable LateSdk<RoutingCoordinatorManager> routingCoordinator, Callback callback,
+            RoutingCoordinatorManager routingCoordinatorManager, Callback callback,
             TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
         super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
@@ -309,7 +305,7 @@
         mLog = log.forSubComponent(ifaceName);
         mNetd = netd;
         mBpfCoordinator = bpfCoordinator;
-        mRoutingCoordinator = routingCoordinator;
+        mRoutingCoordinator = routingCoordinatorManager;
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -825,47 +821,25 @@
 
     private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
         try {
-            if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
-                // TODO : remove this call in favor of using the LocalNetworkConfiguration
-                // correctly, which will let ConnectivityService do it automatically.
-                mRoutingCoordinator.value.addInterfaceToNetwork(netId, ifaceName);
-            } else {
-                mNetd.networkAddInterface(netId, ifaceName);
-            }
-        } catch (ServiceSpecificException | RemoteException e) {
+            // TODO : remove this call in favor of using the LocalNetworkConfiguration
+            // correctly, which will let ConnectivityService do it automatically.
+            mRoutingCoordinator.addInterfaceToNetwork(netId, ifaceName);
+        } catch (ServiceSpecificException e) {
             mLog.e("Failed to add " + mIfaceName + " to local table: ", e);
         }
     }
 
-    private void addInterfaceForward(@NonNull final String fromIface,
-            @NonNull final String toIface) throws ServiceSpecificException, RemoteException {
-        if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
-            mRoutingCoordinator.value.addInterfaceForward(fromIface, toIface);
-        } else {
-            mNetd.tetherAddForward(fromIface, toIface);
-            mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
-        }
+    private void addInterfaceForward(@NonNull final String fromIface, @NonNull final String toIface)
+            throws ServiceSpecificException {
+        mRoutingCoordinator.addInterfaceForward(fromIface, toIface);
     }
 
     private void removeInterfaceForward(@NonNull final String fromIface,
             @NonNull final String toIface) {
-        if (SdkLevel.isAtLeastS() && null != mRoutingCoordinator.value) {
-            try {
-                mRoutingCoordinator.value.removeInterfaceForward(fromIface, toIface);
-            } catch (ServiceSpecificException e) {
-                mLog.e("Exception in removeInterfaceForward", e);
-            }
-        } else {
-            try {
-                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in ipfwdRemoveInterfaceForward", e);
-            }
-            try {
-                mNetd.tetherRemoveForward(fromIface, toIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in disableNat", e);
-            }
+        try {
+            mRoutingCoordinator.removeInterfaceForward(fromIface, toIface);
+        } catch (RuntimeException e) {
+            mLog.e("Exception in removeInterfaceForward", e);
         }
     }
 
@@ -1370,7 +1344,7 @@
                         mBpfCoordinator.maybeAttachProgram(mIfaceName, ifname);
                         try {
                             addInterfaceForward(mIfaceName, ifname);
-                        } catch (RemoteException | ServiceSpecificException e) {
+                        } catch (RuntimeException e) {
                             mLog.e("Exception enabling iface forward", e);
                             cleanupUpstream();
                             mLastError = TETHER_ERROR_ENABLE_FORWARDING_ERROR;
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 163fe24..d62f18f 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -138,7 +138,6 @@
 import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
-import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceCallbackShim;
@@ -246,10 +245,7 @@
     private final Handler mHandler;
     private final INetd mNetd;
     private final NetdCallback mNetdCallback;
-    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
-    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
-    // must be able to find all classes at runtime.
-    @NonNull private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
+    private final RoutingCoordinatorManager mRoutingCoordinator;
     private final UserRestrictionActionListener mTetheringRestriction;
     private final ActiveDataSubIdListener mActiveDataSubIdListener;
     private final ConnectedClientsTracker mConnectedClientsTracker;
@@ -294,11 +290,11 @@
         mLog.mark("Tethering.constructed");
         mDeps = deps;
         mContext = mDeps.getContext();
-        mNetd = mDeps.getINetd(mContext);
-        mRoutingCoordinator = mDeps.getRoutingCoordinator(mContext);
+        mNetd = mDeps.getINetd(mContext, mLog);
+        mRoutingCoordinator = mDeps.getRoutingCoordinator(mContext, mLog);
         mLooper = mDeps.makeTetheringLooper();
         mNotificationUpdater = mDeps.makeNotificationUpdater(mContext, mLooper);
-        mTetheringMetrics = mDeps.makeTetheringMetrics();
+        mTetheringMetrics = mDeps.makeTetheringMetrics(mContext);
 
         // This is intended to ensrure that if something calls startTethering(bluetooth) just after
         // bluetooth is enabled. Before onServiceConnected is called, store the calls into this
@@ -2397,9 +2393,6 @@
                 hasCallingPermission(NETWORK_SETTINGS)
                         || hasCallingPermission(PERMISSION_MAINLINE_NETWORK_STACK)
                         || hasCallingPermission(NETWORK_STACK);
-        if (callback == null) {
-            throw new NullPointerException();
-        }
         mHandler.post(() -> {
             mTetheringEventCallbacks.register(callback, new CallbackCookie(hasListPermission));
             final TetheringCallbackStartedParcel parcel = new TetheringCallbackStartedParcel();
@@ -2437,9 +2430,6 @@
 
     /** Unregister tethering event callback */
     void unregisterTetheringEventCallback(ITetheringEventCallback callback) {
-        if (callback == null) {
-            throw new NullPointerException();
-        }
         mHandler.post(() -> {
             mTetheringEventCallbacks.unregister(callback);
         });
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 54dbf6c..5d9d349 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -36,7 +36,7 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.RoutingCoordinatorManager;
-import com.android.net.module.util.SdkUtil.LateSdk;
+import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.BluetoothPanShimImpl;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -120,20 +120,26 @@
     /**
      * Get a reference to INetd to be used by tethering.
      */
-    public INetd getINetd(Context context) {
-        return INetd.Stub.asInterface(
-                (IBinder) context.getSystemService(Context.NETD_SERVICE));
+    public INetd getINetd(Context context, SharedLog log) {
+        final INetd netd =
+                INetd.Stub.asInterface((IBinder) context.getSystemService(Context.NETD_SERVICE));
+        if (netd == null) {
+            log.wtf("INetd is null");
+        }
+        return netd;
     }
 
     /**
-     * Get the routing coordinator, or null if below S.
+     * Get the routing coordinator.
      */
-    @Nullable
-    public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(Context context) {
-        if (!SdkLevel.isAtLeastS()) return new LateSdk<>(null);
-        return new LateSdk<>(
-                new RoutingCoordinatorManager(
-                        context, ConnectivityInternalApiUtil.getRoutingCoordinator(context)));
+    public RoutingCoordinatorManager getRoutingCoordinator(Context context, SharedLog log) {
+        IBinder binder;
+        if (!SdkLevel.isAtLeastS()) {
+            binder = new RoutingCoordinatorService(getINetd(context, log));
+        } else {
+            binder = ConnectivityInternalApiUtil.getRoutingCoordinator(context);
+        }
+        return new RoutingCoordinatorManager(context, binder);
     }
 
     /**
@@ -187,8 +193,8 @@
     /**
      * Make the TetheringMetrics to be used by tethering.
      */
-    public TetheringMetrics makeTetheringMetrics() {
-        return new TetheringMetrics();
+    public TetheringMetrics makeTetheringMetrics(Context ctx) {
+        return new TetheringMetrics(ctx);
     }
 
     /**
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringService.java b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
index a147a4a..454cbf1 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringService.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringService.java
@@ -164,6 +164,8 @@
         @Override
         public void registerTetheringEventCallback(ITetheringEventCallback callback,
                 String callerPkg) {
+            // Silently ignore call if the callback is null. This can only happen via reflection.
+            if (callback == null) return;
             try {
                 if (!hasTetherAccessPermission()) {
                     callback.onCallbackStopped(TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION);
@@ -176,6 +178,8 @@
         @Override
         public void unregisterTetheringEventCallback(ITetheringEventCallback callback,
                 String callerPkg) {
+            // Silently ignore call if the callback is null. This can only happen via reflection.
+            if (callback == null) return;
             try {
                 if (!hasTetherAccessPermission()) {
                     callback.onCallbackStopped(TETHER_ERROR_NO_ACCESS_TETHERING_PERMISSION);
diff --git a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index 814afcd..136dfb1 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -46,6 +46,7 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNTETHER_IFACE_ERROR;
 
 import android.annotation.Nullable;
+import android.content.Context;
 import android.net.NetworkCapabilities;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
@@ -81,16 +82,50 @@
     private final SparseArray<NetworkTetheringReported.Builder> mBuilderMap = new SparseArray<>();
     private final SparseArray<Long> mDownstreamStartTime = new SparseArray<Long>();
     private final ArrayList<RecordUpstreamEvent> mUpstreamEventList = new ArrayList<>();
+    private final Context mContext;
+    private final Dependencies mDependencies;
     private UpstreamType mCurrentUpstream = null;
     private Long mCurrentUpStreamStartTime = 0L;
 
+    /**
+     * Dependencies of TetheringMetrics, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /**
+         * @see TetheringStatsLog
+         */
+        public void write(NetworkTetheringReported reported) {
+            TetheringStatsLog.write(
+                    TetheringStatsLog.NETWORK_TETHERING_REPORTED,
+                    reported.getErrorCode().getNumber(),
+                    reported.getDownstreamType().getNumber(),
+                    reported.getUpstreamType().getNumber(),
+                    reported.getUserType().getNumber(),
+                    reported.getUpstreamEvents().toByteArray(),
+                    reported.getDurationMillis());
+        }
+
+        /**
+         * @see System#currentTimeMillis()
+         */
+        public long timeNow() {
+            return System.currentTimeMillis();
+        }
+    }
 
     /**
-     * Return the current system time in milliseconds.
-     * @return the current system time in milliseconds.
+     * Constructor for the TetheringMetrics class.
+     *
+     * @param context The Context object used to access system services.
      */
-    public long timeNow() {
-        return System.currentTimeMillis();
+    public TetheringMetrics(Context context) {
+        this(context, new Dependencies());
+    }
+
+    TetheringMetrics(Context context, Dependencies dependencies) {
+        mContext = context;
+        mDependencies = dependencies;
     }
 
     private static class RecordUpstreamEvent {
@@ -123,7 +158,7 @@
                 .setUpstreamEvents(UpstreamEvents.newBuilder())
                 .setDurationMillis(0);
         mBuilderMap.put(downstreamType, statsBuilder);
-        mDownstreamStartTime.put(downstreamType, timeNow());
+        mDownstreamStartTime.put(downstreamType, mDependencies.timeNow());
     }
 
     /**
@@ -149,7 +184,7 @@
         UpstreamType upstream = transportTypeToUpstreamTypeEnum(ns);
         if (upstream.equals(mCurrentUpstream)) return;
 
-        final long newTime = timeNow();
+        final long newTime = mDependencies.timeNow();
         if (mCurrentUpstream != null) {
             mUpstreamEventList.add(new RecordUpstreamEvent(mCurrentUpStreamStartTime, newTime,
                     mCurrentUpstream));
@@ -206,7 +241,7 @@
                     event.mUpstreamType, 0L /* txBytes */, 0L /* rxBytes */);
         }
         final long startTime = Math.max(downstreamStartTime, mCurrentUpStreamStartTime);
-        final long stopTime = timeNow();
+        final long stopTime = mDependencies.timeNow();
         // Handle the last upstream event.
         addUpstreamEvent(upstreamEventsBuilder, startTime, stopTime, mCurrentUpstream,
                 0L /* txBytes */, 0L /* rxBytes */);
@@ -248,15 +283,7 @@
     @VisibleForTesting
     public void write(@NonNull final NetworkTetheringReported reported) {
         final byte[] upstreamEvents = reported.getUpstreamEvents().toByteArray();
-
-        TetheringStatsLog.write(
-                TetheringStatsLog.NETWORK_TETHERING_REPORTED,
-                reported.getErrorCode().getNumber(),
-                reported.getDownstreamType().getNumber(),
-                reported.getUpstreamType().getNumber(),
-                reported.getUserType().getNumber(),
-                upstreamEvents,
-                reported.getDurationMillis());
+        mDependencies.write(reported);
         if (DBG) {
             Log.d(
                     TAG,
@@ -374,4 +401,22 @@
 
         return UpstreamType.UT_UNKNOWN;
     }
+
+    /**
+     * Check whether tethering metrics' data usage can be collected for a given upstream type.
+     *
+     * @param type the upstream type
+     */
+    public static boolean isUsageSupportedForUpstreamType(@NonNull UpstreamType type) {
+        switch(type) {
+            case UT_CELLULAR:
+            case UT_WIFI:
+            case UT_BLUETOOTH:
+            case UT_ETHERNET:
+                return true;
+            default:
+                break;
+        }
+        return false;
+    }
 }
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 748f23c..177296a 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -51,6 +51,7 @@
 import static org.mockito.Matchers.anyBoolean;
 import static org.mockito.Matchers.anyString;
 import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
@@ -174,8 +175,7 @@
     @Mock private RouterAdvertisementDaemon mRaDaemon;
     @Mock private IpServer.Dependencies mDependencies;
     @Mock private PrivateAddressCoordinator mAddressCoordinator;
-    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinatorManager =
-            new LateSdk<>(SdkLevel.isAtLeastS() ? mock(RoutingCoordinatorManager.class) : null);
+    @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
     @Mock private TetheringMetrics mTetheringMetrics;
@@ -280,24 +280,6 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(DEFAULT_USING_BPF_OFFLOAD);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(false /* default value */);
 
-        // Simulate the behavior of RoutingCoordinator
-        if (null != mRoutingCoordinatorManager.value) {
-            doAnswer(it -> {
-                final String fromIface = (String) it.getArguments()[0];
-                final String toIface = (String) it.getArguments()[1];
-                mNetd.tetherAddForward(fromIface, toIface);
-                mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
-                return null;
-            }).when(mRoutingCoordinatorManager.value).addInterfaceForward(any(), any());
-            doAnswer(it -> {
-                final String fromIface = (String) it.getArguments()[0];
-                final String toIface = (String) it.getArguments()[1];
-                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
-                mNetd.tetherRemoveForward(fromIface, toIface);
-                return null;
-            }).when(mRoutingCoordinatorManager.value).removeInterfaceForward(any(), any());
-        }
-
         setUpDhcpServer();
     }
 
@@ -455,105 +437,114 @@
         // Telling the state machine about its upstream interface triggers
         // a little more configuration.
         dispatchTetherConnectionChanged(UPSTREAM_IFACE);
-        InOrder inOrder = inOrder(mNetd, mBpfCoordinator);
+        InOrder inOrder = inOrder(mBpfCoordinator, mRoutingCoordinatorManager);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE>.
         inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX,
                 UPSTREAM_IFACE);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).ipfwdAddInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
+        inOrder.verify(mRoutingCoordinatorManager).addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
 
-        verifyNoMoreInteractions(mNetd, mCallback, mBpfCoordinator);
+        verifyNoMoreInteractions(mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
     }
 
     @Test
     public void handlesChangingUpstream() throws Exception {
         initTetheredStateMachine(TETHERING_BLUETOOTH, UPSTREAM_IFACE);
 
+        clearInvocations(mBpfCoordinator, mRoutingCoordinatorManager);
         dispatchTetherConnectionChanged(UPSTREAM_IFACE2);
-        InOrder inOrder = inOrder(mNetd, mBpfCoordinator);
+        InOrder inOrder = inOrder(mBpfCoordinator, mRoutingCoordinatorManager);
 
         // Remove the forwarding pair <IFACE_NAME, UPSTREAM_IFACE>.
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2>.
         inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).ipfwdAddInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
+        inOrder.verify(mRoutingCoordinatorManager).addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
 
-        verifyNoMoreInteractions(mNetd, mCallback, mBpfCoordinator);
+        verifyNoMoreInteractions(mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
     }
 
     @Test
     public void handlesChangingUpstreamNatFailure() throws Exception {
         initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE);
 
-        doThrow(RemoteException.class).when(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
+        doThrow(RuntimeException.class)
+                .when(mRoutingCoordinatorManager)
+                .addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
 
         dispatchTetherConnectionChanged(UPSTREAM_IFACE2);
-        InOrder inOrder = inOrder(mNetd, mBpfCoordinator);
+        InOrder inOrder = inOrder(mBpfCoordinator, mRoutingCoordinatorManager);
 
         // Remove the forwarding pair <IFACE_NAME, UPSTREAM_IFACE>.
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> and expect that failed on
-        // tetherAddForward.
+        // addInterfaceForward.
         inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
+        inOrder.verify(mRoutingCoordinatorManager).addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
 
         // Remove the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> to fallback.
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE2);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
     }
 
     @Test
     public void handlesChangingUpstreamInterfaceForwardingFailure() throws Exception {
         initTetheredStateMachine(TETHERING_WIFI, UPSTREAM_IFACE);
 
-        doThrow(RemoteException.class).when(mNetd).ipfwdAddInterfaceForward(
-                IFACE_NAME, UPSTREAM_IFACE2);
+        doThrow(RuntimeException.class)
+                .when(mRoutingCoordinatorManager)
+                .addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
 
         dispatchTetherConnectionChanged(UPSTREAM_IFACE2);
-        InOrder inOrder = inOrder(mNetd, mBpfCoordinator);
+        InOrder inOrder = inOrder(mBpfCoordinator, mRoutingCoordinatorManager);
 
         // Remove the forwarding pair <IFACE_NAME, UPSTREAM_IFACE>.
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
 
         // Add the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> and expect that failed on
         // ipfwdAddInterfaceForward.
         inOrder.verify(mBpfCoordinator).maybeAddUpstreamToLookupTable(UPSTREAM_IFINDEX2,
                 UPSTREAM_IFACE2);
         inOrder.verify(mBpfCoordinator).maybeAttachProgram(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).ipfwdAddInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
+        inOrder.verify(mRoutingCoordinatorManager).addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
 
         // Remove the forwarding pair <IFACE_NAME, UPSTREAM_IFACE2> to fallback.
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE2);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE2);
     }
 
     @Test
     public void canUnrequestTetheringWithUpstream() throws Exception {
         initTetheredStateMachine(TETHERING_BLUETOOTH, UPSTREAM_IFACE);
 
+        clearInvocations(
+                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
         dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
-        InOrder inOrder = inOrder(mNetd, mCallback, mAddressCoordinator, mBpfCoordinator);
+        InOrder inOrder =
+                inOrder(
+                        mNetd,
+                        mCallback,
+                        mAddressCoordinator,
+                        mBpfCoordinator,
+                        mRoutingCoordinatorManager);
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
-        inOrder.verify(mNetd).tetherRemoveForward(IFACE_NAME, UPSTREAM_IFACE);
+        inOrder.verify(mRoutingCoordinatorManager)
+                .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mBpfCoordinator).updateIpv6UpstreamInterface(
                 mIpServer, NO_UPSTREAM, NO_PREFIXES);
         // When tethering stops, upstream interface is set to zero and thus clearing all upstream
@@ -572,7 +563,8 @@
                 mIpServer, STATE_AVAILABLE, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), any(LinkProperties.class));
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator, mBpfCoordinator);
+        verifyNoMoreInteractions(
+                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
     }
 
     @Test
@@ -624,10 +616,14 @@
     public void shouldTearDownUsbOnUpstreamError() throws Exception {
         initTetheredStateMachine(TETHERING_USB, null);
 
-        doThrow(RemoteException.class).when(mNetd).tetherAddForward(anyString(), anyString());
+        doThrow(RuntimeException.class)
+                .when(mRoutingCoordinatorManager)
+                .addInterfaceForward(anyString(), anyString());
         dispatchTetherConnectionChanged(UPSTREAM_IFACE);
-        InOrder usbTeardownOrder = inOrder(mNetd, mCallback);
-        usbTeardownOrder.verify(mNetd).tetherAddForward(IFACE_NAME, UPSTREAM_IFACE);
+        InOrder usbTeardownOrder = inOrder(mNetd, mCallback, mRoutingCoordinatorManager);
+        usbTeardownOrder
+                .verify(mRoutingCoordinatorManager)
+                .addInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
 
         usbTeardownOrder.verify(mNetd, times(2)).interfaceSetCfg(
                 argThat(cfg -> IFACE_NAME.equals(cfg.ifName)));
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 df7141f..6ba5d48 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -191,7 +191,6 @@
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.RoutingCoordinatorManager;
-import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -292,6 +291,7 @@
     @Mock private BluetoothPanShim mBluetoothPanShim;
     @Mock private TetheredInterfaceRequestShim mTetheredInterfaceRequestShim;
     @Mock private TetheringMetrics mTetheringMetrics;
+    @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
 
     private final MockIpServerDependencies mIpServerDependencies =
             spy(new MockIpServerDependencies());
@@ -484,10 +484,10 @@
             return mEntitleMgr;
         }
 
-        @Nullable
         @Override
-        public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(final Context context) {
-            return new LateSdk<>(null);
+        public RoutingCoordinatorManager getRoutingCoordinator(final Context context,
+                SharedLog log) {
+            return mRoutingCoordinatorManager;
         }
 
         @Override
@@ -498,7 +498,7 @@
         }
 
         @Override
-        public INetd getINetd(Context context) {
+        public INetd getINetd(Context context, SharedLog log) {
             return mNetd;
         }
 
@@ -528,7 +528,7 @@
         }
 
         @Override
-        public TetheringMetrics makeTetheringMetrics() {
+        public TetheringMetrics makeTetheringMetrics(Context ctx) {
             return mTetheringMetrics;
         }
 
@@ -1082,8 +1082,8 @@
         UpstreamNetworkState upstreamState = buildMobileIPv4UpstreamState();
         runUsbTethering(upstreamState);
 
-        verify(mNetd, times(1)).tetherAddForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
 
         sendIPv6TetherUpdates(upstreamState);
         assertSetIfaceToDadProxy(0 /* numOfCalls */, "" /* ifaceName */);
@@ -1111,8 +1111,8 @@
         UpstreamNetworkState upstreamState = buildMobileIPv6UpstreamState();
         runUsbTethering(upstreamState);
 
-        verify(mNetd, times(1)).tetherAddForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
 
         sendIPv6TetherUpdates(upstreamState);
         // TODO: add interfaceParams to compare in verify.
@@ -1127,8 +1127,8 @@
         UpstreamNetworkState upstreamState = buildMobileDualStackUpstreamState();
         runUsbTethering(upstreamState);
 
-        verify(mNetd, times(1)).tetherAddForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
         verify(mRouterAdvertisementDaemon, times(1)).start();
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
                 any(), any());
@@ -1145,13 +1145,12 @@
         UpstreamNetworkState upstreamState = buildMobile464xlatUpstreamState();
         runUsbTethering(upstreamState);
 
-        verify(mNetd, times(1)).tetherAddForward(TEST_RNDIS_IFNAME, TEST_XLAT_MOBILE_IFNAME);
-        verify(mNetd, times(1)).tetherAddForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_RNDIS_IFNAME, TEST_XLAT_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
                 any(), any());
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_RNDIS_IFNAME,
-                TEST_XLAT_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_RNDIS_IFNAME, TEST_MOBILE_IFNAME);
 
         sendIPv6TetherUpdates(upstreamState);
         assertSetIfaceToDadProxy(1 /* numOfCalls */, TEST_MOBILE_IFNAME /* ifaceName */);
@@ -1172,10 +1171,10 @@
         UpstreamNetworkState upstreamState = buildMobileIPv6UpstreamState();
         runUsbTethering(upstreamState);
 
-        verify(mNetd, times(1)).tetherAddForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
                 any(), any());
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
 
         // Then 464xlat comes up
         upstreamState = buildMobile464xlatUpstreamState();
@@ -1187,12 +1186,11 @@
         mLooper.dispatchAll();
 
         // Forwarding is added for 464xlat
-        verify(mNetd, times(1)).tetherAddForward(TEST_NCM_IFNAME, TEST_XLAT_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_NCM_IFNAME,
-                TEST_XLAT_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_NCM_IFNAME, TEST_XLAT_MOBILE_IFNAME);
         // Forwarding was not re-added for v6 (still times(1))
-        verify(mNetd, times(1)).tetherAddForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
-        verify(mNetd, times(1)).ipfwdAddInterfaceForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager, times(1))
+                .addInterfaceForward(TEST_NCM_IFNAME, TEST_MOBILE_IFNAME);
         // DHCP not restarted on downstream (still times(1))
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS).times(1)).startWithCallbacks(
                 any(), any());
@@ -3435,8 +3433,7 @@
         runUsbTethering(upstreamState);
 
         verify(mNetd).interfaceGetList();
-        verify(mNetd).tetherAddForward(expectedIface, TEST_MOBILE_IFNAME);
-        verify(mNetd).ipfwdAddInterfaceForward(expectedIface, TEST_MOBILE_IFNAME);
+        verify(mRoutingCoordinatorManager).addInterfaceForward(expectedIface, TEST_MOBILE_IFNAME);
 
         verify(mRouterAdvertisementDaemon).start();
         verify(mDhcpServer, timeout(DHCPSERVER_START_TIMEOUT_MS)).startWithCallbacks(
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
index e2c924c..7cef9cb 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/metrics/TetheringMetricsTest.java
@@ -46,10 +46,11 @@
 import static android.net.TetheringManager.TETHER_ERROR_UNSUPPORTED;
 import static android.net.TetheringManager.TETHER_ERROR_UNTETHER_IFACE_ERROR;
 
-import static org.mockito.Mockito.reset;
-import static org.mockito.Mockito.spy;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.verify;
 
+import android.content.Context;
 import android.net.NetworkCapabilities;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
@@ -60,10 +61,12 @@
 import androidx.test.runner.AndroidJUnit4;
 
 import com.android.networkstack.tethering.UpstreamNetworkState;
+import com.android.networkstack.tethering.metrics.TetheringMetrics.Dependencies;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
 @RunWith(AndroidJUnit4.class)
@@ -76,28 +79,23 @@
     private static final long TEST_START_TIME = 1670395936033L;
     private static final long SECOND_IN_MILLIS = 1_000L;
 
+    @Mock private Context mContext;
+    @Mock private Dependencies mDeps;
+
     private TetheringMetrics mTetheringMetrics;
     private final NetworkTetheringReported.Builder mStatsBuilder =
             NetworkTetheringReported.newBuilder();
 
     private long mElapsedRealtime;
 
-    private class MockTetheringMetrics extends TetheringMetrics {
-        @Override
-        public void write(final NetworkTetheringReported reported) {}
-        @Override
-        public long timeNow() {
-            return currentTimeMillis();
-        }
-    }
-
     private long currentTimeMillis() {
         return TEST_START_TIME + mElapsedRealtime;
     }
 
     private void incrementCurrentTime(final long duration) {
         mElapsedRealtime += duration;
-        mTetheringMetrics.timeNow();
+        final long currentTimeMillis = currentTimeMillis();
+        doReturn(currentTimeMillis).when(mDeps).timeNow();
     }
 
     private long getElapsedRealtime() {
@@ -106,12 +104,14 @@
 
     private void clearElapsedRealtime() {
         mElapsedRealtime = 0;
+        doReturn(TEST_START_TIME).when(mDeps).timeNow();
     }
 
     @Before
     public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
-        mTetheringMetrics = spy(new MockTetheringMetrics());
+        doReturn(TEST_START_TIME).when(mDeps).timeNow();
+        mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mElapsedRealtime = 0L;
     }
 
@@ -126,7 +126,7 @@
                 .setUpstreamEvents(upstreamEvents)
                 .setDurationMillis(duration)
                 .build();
-        verify(mTetheringMetrics).write(expectedReport);
+        verify(mDeps).write(expectedReport);
     }
 
     private void updateErrorAndSendReport(final int downstream, final int error) {
@@ -162,6 +162,7 @@
 
     private void runDownstreamTypesTest(final int type, final DownstreamType expectedResult)
             throws Exception {
+        mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mTetheringMetrics.createBuilder(type, TEST_CALLER_PKG);
         final long duration = 2 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
@@ -172,9 +173,7 @@
 
         verifyReport(expectedResult, ErrorCode.EC_NO_ERROR, UserType.USER_UNKNOWN,
                 upstreamEvents, getElapsedRealtime());
-        reset(mTetheringMetrics);
         clearElapsedRealtime();
-        mTetheringMetrics.cleanup();
     }
 
     @Test
@@ -189,6 +188,7 @@
 
     private void runErrorCodesTest(final int errorCode, final ErrorCode expectedResult)
             throws Exception {
+        mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG);
         mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI));
         final long duration = 2 * SECOND_IN_MILLIS;
@@ -199,9 +199,7 @@
         addUpstreamEvent(upstreamEvents, UpstreamType.UT_WIFI, duration, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, expectedResult, UserType.USER_UNKNOWN,
                     upstreamEvents, getElapsedRealtime());
-        reset(mTetheringMetrics);
         clearElapsedRealtime();
-        mTetheringMetrics.cleanup();
     }
 
     @Test
@@ -231,6 +229,7 @@
 
     private void runUserTypesTest(final String callerPkg, final UserType expectedResult)
             throws Exception {
+        mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mTetheringMetrics.createBuilder(TETHERING_WIFI, callerPkg);
         final long duration = 1 * SECOND_IN_MILLIS;
         incrementCurrentTime(duration);
@@ -241,9 +240,7 @@
         addUpstreamEvent(upstreamEvents, UpstreamType.UT_NO_NETWORK, duration, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR, expectedResult,
                     upstreamEvents, getElapsedRealtime());
-        reset(mTetheringMetrics);
         clearElapsedRealtime();
-        mTetheringMetrics.cleanup();
     }
 
     @Test
@@ -256,6 +253,7 @@
 
     private void runUpstreamTypesTest(final UpstreamNetworkState ns,
             final UpstreamType expectedResult) throws Exception {
+        mTetheringMetrics = new TetheringMetrics(mContext, mDeps);
         mTetheringMetrics.createBuilder(TETHERING_WIFI, TEST_CALLER_PKG);
         mTetheringMetrics.maybeUpdateUpstreamType(ns);
         final long duration = 2 * SECOND_IN_MILLIS;
@@ -266,9 +264,7 @@
         addUpstreamEvent(upstreamEvents, expectedResult, duration, 0L, 0L);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
                 UserType.USER_UNKNOWN, upstreamEvents, getElapsedRealtime());
-        reset(mTetheringMetrics);
         clearElapsedRealtime();
-        mTetheringMetrics.cleanup();
     }
 
     @Test
@@ -379,4 +375,21 @@
                 UserType.USER_SETTINGS, upstreamEvents,
                 currentTimeMillis() - wifiTetheringStartTime);
     }
+
+    private void runUsageSupportedForUpstreamTypeTest(final UpstreamType upstreamType,
+            final boolean isSupported) {
+        final boolean result = TetheringMetrics.isUsageSupportedForUpstreamType(upstreamType);
+        assertEquals(isSupported, result);
+    }
+
+    @Test
+    public void testUsageSupportedForUpstreamTypeTest() {
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_CELLULAR, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_WIFI, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_BLUETOOTH, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_ETHERNET, true /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_WIFI_AWARE, false /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_LOWPAN, false /* isSupported */);
+        runUsageSupportedForUpstreamTypeTest(UpstreamType.UT_UNKNOWN, false /* isSupported */);
+    }
 }
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 5e41dd9..a6a967b 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -4489,6 +4489,7 @@
     public static final int CALLBACK_BLK_CHANGED                = 11;
     /** @hide */
     public static final int CALLBACK_LOCAL_NETWORK_INFO_CHANGED = 12;
+    // When adding new IDs, note CallbackQueue assumes callback IDs are at most 16 bits.
 
 
     /** @hide */
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
index fcfb15f..c575d40 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
@@ -29,7 +29,9 @@
 import android.util.ArrayMap;
 import android.util.Log;
 
+import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.SharedLog;
+import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.net.DatagramPacket;
@@ -225,6 +227,12 @@
             Log.wtf(TAG, "No mDns packets to send");
             return;
         }
+        // Check all packets with the same address
+        if (!MdnsUtils.checkAllPacketsWithSameAddress(packets)) {
+            Log.wtf(TAG, "Some mDNS packets have a different target address. addresses="
+                    + CollectionUtils.map(packets, DatagramPacket::getSocketAddress));
+            return;
+        }
 
         final boolean isIpv6 = ((InetSocketAddress) packets.get(0).getSocketAddress())
                 .getAddress() instanceof Inet6Address;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index b3bdbe0..643430a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -303,8 +303,8 @@
         serviceCache.unregisterServiceExpiredCallback(cacheKey);
     }
 
-    private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(
-            @NonNull MdnsResponse response, @NonNull String[] serviceTypeLabels) {
+    private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(@NonNull MdnsResponse response,
+            @NonNull String[] serviceTypeLabels, long elapsedRealtimeMillis) {
         String[] hostName = null;
         int port = 0;
         if (response.hasServiceRecord()) {
@@ -351,7 +351,7 @@
                 textEntries,
                 response.getInterfaceIndex(),
                 response.getNetwork(),
-                now.plusMillis(response.getMinRemainingTtl(now.toEpochMilli())));
+                now.plusMillis(response.getMinRemainingTtl(elapsedRealtimeMillis)));
     }
 
     private List<MdnsResponse> getExistingServices() {
@@ -380,8 +380,8 @@
         if (existingInfo == null) {
             for (MdnsResponse existingResponse : serviceCache.getCachedServices(cacheKey)) {
                 if (!responseMatchesOptions(existingResponse, searchOptions)) continue;
-                final MdnsServiceInfo info =
-                        buildMdnsServiceInfoFromResponse(existingResponse, serviceTypeLabels);
+                final MdnsServiceInfo info = buildMdnsServiceInfoFromResponse(
+                        existingResponse, serviceTypeLabels, clock.elapsedRealtime());
                 listener.onServiceNameDiscovered(info, true /* isServiceFromCache */);
                 listenerInfo.setServiceDiscovered(info.getServiceInstanceName());
                 if (existingResponse.isComplete()) {
@@ -561,7 +561,7 @@
             if (response.getServiceInstanceName() != null) {
                 listeners.valueAt(i).unsetServiceDiscovered(response.getServiceInstanceName());
                 final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
-                        response, serviceTypeLabels);
+                        response, serviceTypeLabels, clock.elapsedRealtime());
                 if (response.isComplete()) {
                     sharedLog.log(message + ". onServiceRemoved: " + serviceInfo);
                     listener.onServiceRemoved(serviceInfo);
@@ -605,8 +605,8 @@
                         + " %b, responseIsComplete: %b",
                 serviceInstanceName, newInCache, serviceBecomesComplete,
                 response.isComplete()));
-        MdnsServiceInfo serviceInfo =
-                buildMdnsServiceInfoFromResponse(response, serviceTypeLabels);
+        final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
+                response, serviceTypeLabels, clock.elapsedRealtime());
 
         for (int i = 0; i < listeners.size(); i++) {
             // If a service stops matching the options (currently can only happen if it loses a
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
index 9cfcba1..17e5b31 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketClient.java
@@ -28,7 +28,9 @@
 import android.util.Log;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.SharedLog;
+import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.net.DatagramPacket;
@@ -249,6 +251,12 @@
             Log.wtf(TAG, "No mDns packets to send");
             return;
         }
+        // Check all packets with the same address
+        if (!MdnsUtils.checkAllPacketsWithSameAddress(packets)) {
+            Log.wtf(TAG, "Some mDNS packets have a different target address. addresses="
+                    + CollectionUtils.map(packets, DatagramPacket::getSocketAddress));
+            return;
+        }
 
         final boolean isIpv4 = ((InetSocketAddress) packets.get(0).getSocketAddress())
                 .getAddress() instanceof Inet4Address;
diff --git a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
index 3c11a24..226867f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
+++ b/service-t/src/com/android/server/connectivity/mdns/util/MdnsUtils.java
@@ -34,6 +34,7 @@
 
 import java.io.IOException;
 import java.net.DatagramPacket;
+import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.nio.ByteBuffer;
 import java.nio.CharBuffer;
@@ -361,4 +362,23 @@
             return SystemClock.elapsedRealtime();
         }
     }
+
+    /**
+     * Check all DatagramPackets with the same destination address.
+     */
+    public static boolean checkAllPacketsWithSameAddress(List<DatagramPacket> packets) {
+        // No packet for address check
+        if (packets.isEmpty()) {
+            return true;
+        }
+
+        final InetAddress address =
+                ((InetSocketAddress) packets.get(0).getSocketAddress()).getAddress();
+        for (DatagramPacket packet : packets) {
+            if (!address.equals(((InetSocketAddress) packet.getSocketAddress()).getAddress())) {
+                return false;
+            }
+        }
+        return true;
+    }
 }
\ No newline at end of file
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
index 02a9ce6..4027038 100644
--- a/service/ServiceConnectivityResources/res/values/config_thread.xml
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -54,9 +54,21 @@
     -->
     <string translatable="false" name="config_thread_model_name">Thread Border Router</string>
 
-    <!-- Whether the Thread network will be managed by the Google Home ecosystem. When this value
-    is set, a TXT entry "vgh=0" or "vgh=1" will be added to the "_mehscop._udp" mDNS service
-    respectively (The TXT value is a string).
+    <!-- Specifies vendor-specific mDNS TXT entries which will be included in the "_meshcop._udp"
+    service. The TXT entries list MUST conform to the format requirement in RFC 6763 section 6. For
+    example, the key and value of each TXT entry MUST be separated with "=". If the value length is
+    0, the trailing "=" may be omitted. Additionally, the TXT keys MUST start with "v" and be at
+    least 2 characters.
+
+    Note, do not include credentials in any of the TXT entries - they will be advertised on Wi-Fi
+    or Ethernet link.
+
+    An example config can be:
+      <string-array name="config_thread_mdns_vendor_specific_txts">
+        <item>vab=123</item>
+        <item>vcd</item>
+      </string-array>
     -->
-    <bool name="config_thread_managed_by_google_home">false</bool>
+    <string-array name="config_thread_mdns_vendor_specific_txts">
+    </string-array>
 </resources>
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index 158b0c8..fbaae05 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -51,6 +51,7 @@
             <item type="string" name="config_thread_vendor_name" />
             <item type="string" name="config_thread_vendor_oui" />
             <item type="string" name="config_thread_model_name" />
+            <item type="array" name="config_thread_mdns_vendor_specific_txts" />
         </policy>
     </overlayable>
 </resources>
diff --git a/service/src/com/android/server/CallbackQueue.java b/service/src/com/android/server/CallbackQueue.java
new file mode 100644
index 0000000..060a984
--- /dev/null
+++ b/service/src/com/android/server/CallbackQueue.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server;
+
+import android.annotation.NonNull;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+
+import com.android.net.module.util.GrowingIntArray;
+
+/**
+ * A utility class to add/remove {@link NetworkCallback}s from a queue.
+ *
+ * <p>This is intended to be used as a temporary builder to create/modify callbacks stored in an int
+ * array for memory efficiency.
+ *
+ * <p>Intended usage:
+ * <pre>
+ *     final CallbackQueue queue = new CallbackQueue(storedCallbacks);
+ *     queue.forEach(netId, callbackId -> { [...] });
+ *     queue.addCallback(netId, callbackId);
+ *     [...]
+ *     queue.shrinkToLength();
+ *     storedCallbacks = queue.getBackingArray();
+ * </pre>
+ *
+ * <p>This class is not thread-safe.
+ */
+public class CallbackQueue extends GrowingIntArray {
+    public CallbackQueue(int[] initialCallbacks) {
+        super(initialCallbacks);
+    }
+
+    /**
+     * Get a callback int from netId and callbackId.
+     *
+     * <p>The first 16 bits of each int is the netId; the last 16 bits are the callback index.
+     */
+    private static int getCallbackInt(int netId, int callbackId) {
+        return (netId << 16) | (callbackId & 0xffff);
+    }
+
+    private static int getNetId(int callbackInt) {
+        return callbackInt >>> 16;
+    }
+
+    private static int getCallbackId(int callbackInt) {
+        return callbackInt & 0xffff;
+    }
+
+    /**
+     * A consumer interface for {@link #forEach(CallbackConsumer)}.
+     *
+     * <p>This is similar to a BiConsumer&lt;Integer, Integer&gt;, but avoids the boxing cost.
+     */
+    public interface CallbackConsumer {
+        /**
+         * Method called on each callback in the queue.
+         */
+        void accept(int netId, int callbackId);
+    }
+
+    /**
+     * Iterate over all callbacks in the queue.
+     */
+    public void forEach(@NonNull CallbackConsumer consumer) {
+        forEach(value -> {
+            final int netId = getNetId(value);
+            final int callbackId = getCallbackId(value);
+            consumer.accept(netId, callbackId);
+        });
+    }
+
+    /**
+     * Indicates whether the queue contains a callback for the given (netId, callbackId).
+     */
+    public boolean hasCallback(int netId, int callbackId) {
+        return contains(getCallbackInt(netId, callbackId));
+    }
+
+    /**
+     * Remove all callbacks for the given netId.
+     *
+     * @return true if at least one callback was removed.
+     */
+    public boolean removeCallbacksForNetId(int netId) {
+        return removeValues(cb -> getNetId(cb) == netId);
+    }
+
+    /**
+     * Remove all callbacks for the given netId and callbackId.
+     * @return true if at least one callback was removed.
+     */
+    public boolean removeCallbacks(int netId, int callbackId) {
+        final int cbInt = getCallbackInt(netId, callbackId);
+        return removeValues(cb -> cb == cbInt);
+    }
+
+    /**
+     * Add a callback at the end of the queue.
+     */
+    public void addCallback(int netId, int callbackId) {
+        add(getCallbackInt(netId, callbackId));
+    }
+
+    @Override
+    protected String valueToString(int item) {
+        final int callbackId = getCallbackId(item);
+        final int netId = getNetId(item);
+        return ConnectivityManager.getCallbackName(callbackId) + "(" + netId + ")";
+    }
+}
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index f015742..4d4dacf 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -145,6 +145,7 @@
 import static com.android.net.module.util.PermissionUtils.hasAnyPermissionOf;
 import static com.android.server.ConnectivityStatsLog.CONNECTIVITY_STATE_SAMPLE;
 import static com.android.server.connectivity.ConnectivityFlags.DELAY_DESTROY_SOCKETS;
+import static com.android.server.connectivity.ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS;
 import static com.android.server.connectivity.ConnectivityFlags.REQUEST_RESTRICTED_WIFI;
 import static com.android.server.connectivity.ConnectivityFlags.INGRESS_TO_VPN_ADDRESS_FILTERING;
 
@@ -329,9 +330,9 @@
 import com.android.net.module.util.LinkPropertiesUtils.CompareOrUpdateResult;
 import com.android.net.module.util.LinkPropertiesUtils.CompareResult;
 import com.android.net.module.util.LocationPermissionChecker;
-import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.PerUidCounter;
 import com.android.net.module.util.PermissionUtils;
+import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.TcUtils;
 import com.android.net.module.util.netlink.InetDiagMessage;
 import com.android.networkstack.apishim.BroadcastOptionsShimImpl;
@@ -510,6 +511,9 @@
 
     private final boolean mUseDeclaredMethodsForCallbacksEnabled;
 
+    // Flag to delay callbacks for frozen apps, suppressing duplicate and stale callbacks.
+    private final boolean mQueueCallbacksForFrozenApps;
+
     /**
      * Uids ConnectivityService tracks blocked status of to send blocked status callbacks.
      * Key is uid based on mAsUid of registered networkRequestInfo
@@ -1873,6 +1877,9 @@
                 context, ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN);
         mUseDeclaredMethodsForCallbacksEnabled = mDeps.isFeatureEnabled(context,
                 ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS);
+        // registerUidFrozenStateChangedCallback is only available on U+
+        mQueueCallbacksForFrozenApps = mDeps.isAtLeastU()
+                && mDeps.isFeatureEnabled(context, QUEUE_CALLBACKS_FOR_FROZEN_APPS);
         mCarrierPrivilegeAuthenticator = mDeps.makeCarrierPrivilegeAuthenticator(
                 mContext, mTelephonyManager, mRequestRestrictedWifiEnabled,
                 this::handleUidCarrierPrivilegesLost, mHandler);
@@ -2028,7 +2035,7 @@
         mDelayDestroySockets = mDeps.isFeatureNotChickenedOut(context, DELAY_DESTROY_SOCKETS);
         mAllowSysUiConnectivityReports = mDeps.isFeatureNotChickenedOut(
                 mContext, ALLOW_SYSUI_CONNECTIVITY_REPORTS);
-        if (mDestroyFrozenSockets) {
+        if (mDestroyFrozenSockets || mQueueCallbacksForFrozenApps) {
             final UidFrozenStateChangedCallback frozenStateChangedCallback =
                     new UidFrozenStateChangedCallback() {
                 @Override
@@ -3464,6 +3471,14 @@
 
     private void handleFrozenUids(int[] uids, int[] frozenStates) {
         ensureRunningOnConnectivityServiceThread();
+        handleDestroyFrozenSockets(uids, frozenStates);
+        // TODO: handle freezing NetworkCallbacks
+    }
+
+    private void handleDestroyFrozenSockets(int[] uids, int[] frozenStates) {
+        if (!mDestroyFrozenSockets) {
+            return;
+        }
         for (int i = 0; i < uids.length; i++) {
             final int uid = uids[i];
             final boolean addReason = frozenStates[i] == UID_FROZEN_STATE_FROZEN;
@@ -7764,6 +7779,11 @@
             }
         }
 
+        boolean isCallbackOverridden(int callbackId) {
+            return !mUseDeclaredMethodsForCallbacksEnabled
+                    || (mDeclaredMethodsFlags & (1 << callbackId)) != 0;
+        }
+
         boolean hasHigherOrderThan(@NonNull final NetworkRequestInfo target) {
             // Compare two preference orders.
             return mPreferenceOrder < target.mPreferenceOrder;
@@ -10233,6 +10253,18 @@
         return new LocalNetworkInfo.Builder().setUpstreamNetwork(upstream).build();
     }
 
+    private Bundle makeCommonBundleForCallback(@NonNull final NetworkRequestInfo nri,
+            @Nullable Network network) {
+        final Bundle bundle = new Bundle();
+        // TODO b/177608132: make sure callbacks are indexed by NRIs and not NetworkRequest objects.
+        // TODO: check if defensive copies of data is needed.
+        putParcelable(bundle, nri.getNetworkRequestForCallback());
+        if (network != null) {
+            putParcelable(bundle, network);
+        }
+        return bundle;
+    }
+
     // networkAgent is only allowed to be null if notificationType is
     // CALLBACK_UNAVAIL. This is because UNAVAIL is about no network being
     // available, while all other cases are about some particular network.
@@ -10245,22 +10277,17 @@
             // are Type.LISTEN, but should not have NetworkCallbacks invoked.
             return;
         }
-        if (mUseDeclaredMethodsForCallbacksEnabled
-                && (nri.mDeclaredMethodsFlags & (1 << notificationType)) == 0) {
+        if (!nri.isCallbackOverridden(notificationType)) {
             // No need to send the notification as the recipient method is not overridden
             return;
         }
-        final Bundle bundle = new Bundle();
-        // TODO b/177608132: make sure callbacks are indexed by NRIs and not NetworkRequest objects.
-        // TODO: check if defensive copies of data is needed.
-        final NetworkRequest nrForCallback = nri.getNetworkRequestForCallback();
-        putParcelable(bundle, nrForCallback);
-        Message msg = Message.obtain();
-        if (notificationType != CALLBACK_UNAVAIL) {
-            putParcelable(bundle, networkAgent.network);
-        }
+        final Network bundleNetwork = notificationType == CALLBACK_UNAVAIL
+                ? null
+                : networkAgent.network;
+        final Bundle bundle = makeCommonBundleForCallback(nri, bundleNetwork);
         final boolean includeLocationSensitiveInfo =
                 (nri.mCallbackFlags & NetworkCallback.FLAG_INCLUDE_LOCATION_INFO) != 0;
+        final NetworkRequest nrForCallback = nri.getNetworkRequestForCallback();
         switch (notificationType) {
             case CALLBACK_AVAILABLE: {
                 final NetworkCapabilities nc =
@@ -10277,12 +10304,6 @@
                 // method here.
                 bundle.putParcelable(LocalNetworkInfo.class.getSimpleName(),
                         localNetworkInfoForNai(networkAgent));
-                // For this notification, arg1 contains the blocked status.
-                msg.arg1 = arg1;
-                break;
-            }
-            case CALLBACK_LOSING: {
-                msg.arg1 = arg1;
                 break;
             }
             case CALLBACK_CAP_CHANGED: {
@@ -10305,7 +10326,6 @@
             }
             case CALLBACK_BLK_CHANGED: {
                 maybeLogBlockedStatusChanged(nri, networkAgent.network, arg1);
-                msg.arg1 = arg1;
                 break;
             }
             case CALLBACK_LOCAL_NETWORK_INFO_CHANGED: {
@@ -10317,17 +10337,26 @@
                 break;
             }
         }
+        callCallbackForRequest(nri, notificationType, bundle, arg1);
+    }
+
+    private void callCallbackForRequest(@NonNull final NetworkRequestInfo nri, int notificationType,
+            Bundle bundle, int arg1) {
+        Message msg = Message.obtain();
+        msg.arg1 = arg1;
         msg.what = notificationType;
         msg.setData(bundle);
         try {
             if (VDBG) {
                 String notification = ConnectivityManager.getCallbackName(notificationType);
-                log("sending notification " + notification + " for " + nrForCallback);
+                log("sending notification " + notification + " for "
+                        + nri.getNetworkRequestForCallback());
             }
             nri.mMessenger.send(msg);
         } catch (RemoteException e) {
             // may occur naturally in the race of binder death.
-            loge("RemoteException caught trying to send a callback msg for " + nrForCallback);
+            loge("RemoteException caught trying to send a callback msg for "
+                    + nri.getNetworkRequestForCallback());
         }
     }
 
@@ -11416,11 +11445,7 @@
             return;
         }
 
-        final int blockedReasons = mUidBlockedReasons.get(nri.mAsUid, BLOCKED_REASON_NONE);
-        final boolean metered = nai.networkCapabilities.isMetered();
-        final boolean vpnBlocked = isUidBlockedByVpn(nri.mAsUid, mVpnBlockedUidRanges);
-        callCallbackForRequest(nri, nai, CALLBACK_AVAILABLE,
-                getBlockedState(nri.mAsUid, blockedReasons, metered, vpnBlocked));
+        callCallbackForRequest(nri, nai, CALLBACK_AVAILABLE, getBlockedState(nai, nri.mAsUid));
     }
 
     // Notify the requests on this NAI that the network is now lingered.
@@ -11450,6 +11475,13 @@
                 : reasons & ~BLOCKED_REASON_LOCKDOWN_VPN;
     }
 
+    private int getBlockedState(@NonNull NetworkAgentInfo nai, int uid) {
+        final boolean metered = nai.networkCapabilities.isMetered();
+        final boolean vpnBlocked = isUidBlockedByVpn(uid, mVpnBlockedUidRanges);
+        final int blockedReasons = mUidBlockedReasons.get(uid, BLOCKED_REASON_NONE);
+        return getBlockedState(uid, blockedReasons, metered, vpnBlocked);
+    }
+
     private void setUidBlockedReasons(int uid, @BlockedReason int blockedReasons) {
         if (blockedReasons == BLOCKED_REASON_NONE) {
             mUidBlockedReasons.delete(uid);
diff --git a/service/src/com/android/server/connectivity/ConnectivityFlags.java b/service/src/com/android/server/connectivity/ConnectivityFlags.java
index 1ee1ed7..df87316 100644
--- a/service/src/com/android/server/connectivity/ConnectivityFlags.java
+++ b/service/src/com/android/server/connectivity/ConnectivityFlags.java
@@ -49,6 +49,9 @@
     public static final String USE_DECLARED_METHODS_FOR_CALLBACKS =
             "use_declared_methods_for_callbacks";
 
+    public static final String QUEUE_CALLBACKS_FOR_FROZEN_APPS =
+            "queue_callbacks_for_frozen_apps";
+
     private boolean mNoRematchAllRequestsOnRegister;
 
     /**
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index cf6127f..917ad4d 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -23,6 +23,7 @@
 import android.os.Binder;
 import android.os.Process;
 import android.os.ServiceSpecificException;
+import android.os.UserHandle;
 import android.system.ErrnoException;
 import android.util.Log;
 
@@ -67,8 +68,8 @@
     }
 
     private void enforceBlockPortPermission() {
-        final int uid = Binder.getCallingUid();
-        if (uid == Process.ROOT_UID || uid == Process.PHONE_UID) return;
+        final int appId = UserHandle.getAppId(Binder.getCallingUid());
+        if (appId == Process.ROOT_UID || appId == Process.PHONE_UID) return;
         PermissionUtils.enforceNetworkStackPermission(mContext);
     }
 
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 2c3a558..14bd5df 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -345,12 +345,10 @@
     ],
     sdk_version: "module_current",
     min_sdk_version: "30",
-    static_libs: [
-        "modules-utils-build_system",
-    ],
     libs: [
         "framework-annotations-lib",
         "framework-connectivity",
+        "modules-utils-build_system",
     ],
     // TODO: remove "apex_available:platform".
     apex_available: [
diff --git a/staticlibs/device/com/android/net/module/util/GrowingIntArray.java b/staticlibs/device/com/android/net/module/util/GrowingIntArray.java
new file mode 100644
index 0000000..4a81c10
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/GrowingIntArray.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import android.annotation.NonNull;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.util.Arrays;
+import java.util.StringJoiner;
+import java.util.function.IntConsumer;
+import java.util.function.IntPredicate;
+
+/**
+ * A growing array of primitive ints.
+ *
+ * <p>This is similar to ArrayList&lt;Integer&gt;, but avoids the cost of boxing (each Integer costs
+ * 16 bytes) and creation / garbage collection of individual Integer objects.
+ *
+ * <p>This class does not use any heuristic for growing capacity, so every call to
+ * {@link #add(int)} may reallocate the backing array. Callers should use
+ * {@link #ensureHasCapacity(int)} to minimize this behavior when they plan to add several values.
+ */
+public class GrowingIntArray {
+    private int[] mValues;
+    private int mLength;
+
+    /**
+     * Create an empty GrowingIntArray with the given capacity.
+     */
+    public GrowingIntArray(int initialCapacity) {
+        mValues = new int[initialCapacity];
+        mLength = 0;
+    }
+
+    /**
+     * Create a GrowingIntArray with an initial array of values.
+     *
+     * <p>The array will be used as-is and may be modified, so callers must stop using it after
+     * calling this constructor.
+     */
+    protected GrowingIntArray(int[] initialValues) {
+        mValues = initialValues;
+        mLength = initialValues.length;
+    }
+
+    /**
+     * Add a value to the array.
+     */
+    public void add(int value) {
+        ensureHasCapacity(1);
+        mValues[mLength] = value;
+        mLength++;
+    }
+
+    /**
+     * Get the current number of values in the array.
+     */
+    public int length() {
+        return mLength;
+    }
+
+    /**
+     * Get the value at a given index.
+     *
+     * @throws ArrayIndexOutOfBoundsException if the index is out of bounds.
+     */
+    public int get(int index) {
+        if (index < 0 || index >= mLength) {
+            throw new ArrayIndexOutOfBoundsException(index);
+        }
+        return mValues[index];
+    }
+
+    /**
+     * Iterate over all values in the array.
+     */
+    public void forEach(@NonNull IntConsumer consumer) {
+        for (int i = 0; i < mLength; i++) {
+            consumer.accept(mValues[i]);
+        }
+    }
+
+    /**
+     * Remove all values matching a predicate.
+     *
+     * @return true if at least one value was removed.
+     */
+    public boolean removeValues(@NonNull IntPredicate predicate) {
+        int newQueueLength = 0;
+        for (int i = 0; i < mLength; i++) {
+            final int cb = mValues[i];
+            if (!predicate.test(cb)) {
+                mValues[newQueueLength] = cb;
+                newQueueLength++;
+            }
+        }
+        if (mLength != newQueueLength) {
+            mLength = newQueueLength;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Indicates whether the array contains the given value.
+     */
+    public boolean contains(int value) {
+        for (int i = 0; i < mLength; i++) {
+            if (mValues[i] == value) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Remove all values from the array.
+     */
+    public void clear() {
+        mLength = 0;
+    }
+
+    /**
+     * Ensure at least the given number of values can be added to the array without reallocating.
+     *
+     * @param capacity The minimum number of additional values the array must be able to hold.
+     */
+    public void ensureHasCapacity(int capacity) {
+        if (mValues.length >= mLength + capacity) {
+            return;
+        }
+        mValues = Arrays.copyOf(mValues, mLength + capacity);
+    }
+
+    @VisibleForTesting
+    int getBackingArrayLength() {
+        return mValues.length;
+    }
+
+    /**
+     * Shrink the array backing this class to the minimum required length.
+     */
+    public void shrinkToLength() {
+        if (mValues.length != mLength) {
+            mValues = Arrays.copyOf(mValues, mLength);
+        }
+    }
+
+    /**
+     * Get values as array by shrinking the internal array to length and returning it.
+     *
+     * <p>This avoids reallocations if the array is already the correct length, but callers should
+     * stop using this instance of {@link GrowingIntArray} if they use the array returned by this
+     * method.
+     */
+    public int[] getShrinkedBackingArray() {
+        shrinkToLength();
+        return mValues;
+    }
+
+    /**
+     * Get the String representation of an item in the array, for use by {@link #toString()}.
+     */
+    protected String valueToString(int item) {
+        return String.valueOf(item);
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        final StringJoiner joiner = new StringJoiner(",", "[", "]");
+        forEach(item -> joiner.add(valueToString(item)));
+        return joiner.toString();
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
index e37061c..02e3643 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
@@ -18,12 +18,10 @@
 
 import android.content.Context;
 import android.net.RouteInfo;
-import android.os.Build;
 import android.os.IBinder;
 import android.os.RemoteException;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.RequiresApi;
 
 /**
  * A manager class for talking to the routing coordinator service.
@@ -32,7 +30,6 @@
  * by the build rules. Do not change build rules to gain access to this class from elsewhere.
  * @hide
  */
-@RequiresApi(Build.VERSION_CODES.S)
 public class RoutingCoordinatorManager {
     @NonNull final Context mContext;
     @NonNull final IRoutingCoordinator mService;
diff --git a/staticlibs/device/com/android/net/module/util/SingleWriterBpfMap.java b/staticlibs/device/com/android/net/module/util/SingleWriterBpfMap.java
index cd6bfec..a638cc4 100644
--- a/staticlibs/device/com/android/net/module/util/SingleWriterBpfMap.java
+++ b/staticlibs/device/com/android/net/module/util/SingleWriterBpfMap.java
@@ -19,6 +19,7 @@
 import android.system.ErrnoException;
 import android.util.Pair;
 
+import androidx.annotation.GuardedBy;
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
 
@@ -60,6 +61,7 @@
 public class SingleWriterBpfMap<K extends Struct, V extends Struct> extends BpfMap<K, V> {
     // HashMap instead of ArrayMap because it performs better on larger maps, and many maps used in
     // our code can contain hundreds of items.
+    @GuardedBy("this")
     private final HashMap<K, V> mCache = new HashMap<>();
 
     // This should only ever be called (hence private) once for a given 'path'.
@@ -72,10 +74,12 @@
         super(path, BPF_F_RDWR_EXCLUSIVE, key, value);
 
         // Populate cache with the current map contents.
-        K currentKey = super.getFirstKey();
-        while (currentKey != null) {
-            mCache.put(currentKey, super.getValue(currentKey));
-            currentKey = super.getNextKey(currentKey);
+        synchronized (this) {
+            K currentKey = super.getFirstKey();
+            while (currentKey != null) {
+                mCache.put(currentKey, super.getValue(currentKey));
+                currentKey = super.getNextKey(currentKey);
+            }
         }
     }
 
diff --git a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
index a8c50d8..f1ff2e4 100644
--- a/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
+++ b/staticlibs/framework/com/android/net/module/util/NetworkStackConstants.java
@@ -260,6 +260,18 @@
     public static final short DNS_OVER_TLS_PORT = 853;
 
     /**
+     * Dns query type constants.
+     *
+     * See also:
+     *    - https://datatracker.ietf.org/doc/html/rfc1035#section-3.2.2
+     */
+    public static final int TYPE_A = 1;
+    public static final int TYPE_PTR = 12;
+    public static final int TYPE_TXT = 16;
+    public static final int TYPE_AAAA = 28;
+    public static final int TYPE_SRV = 33;
+
+    /**
      * IEEE802.11 standard constants.
      *
      * See also:
diff --git a/staticlibs/netd/libnetdutils/include/netdutils/Log.h b/staticlibs/netd/libnetdutils/include/netdutils/Log.h
index d266cbc..2de5ed7 100644
--- a/staticlibs/netd/libnetdutils/include/netdutils/Log.h
+++ b/staticlibs/netd/libnetdutils/include/netdutils/Log.h
@@ -203,7 +203,7 @@
     void record(Level lvl, const std::string& entry);
 
     mutable std::shared_mutex mLock;
-    std::deque<const std::string> mEntries;  // GUARDED_BY(mLock), when supported
+    std::deque<std::string> mEntries;  // GUARDED_BY(mLock), when supported
 };
 
 }  // namespace netdutils
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 8b390e3..caaf959 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -13,13 +13,16 @@
 #  limitations under the License.
 
 from unittest.mock import MagicMock, patch
+from absl.testing import parameterized
 from mobly import asserts
 from mobly import base_test
 from mobly import config_parser
 from mobly.controllers.android_device_lib.adb import AdbError
 from net_tests_utils.host.python.apf_utils import (
+    ApfCapabilities,
     PatternNotFoundException,
     UnsupportedOperationException,
+    get_apf_capabilities,
     get_apf_counter,
     get_apf_counters_from_dumpsys,
     get_hardware_address,
@@ -29,7 +32,7 @@
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
 
 
-class TestApfUtils(base_test.BaseTestClass):
+class TestApfUtils(base_test.BaseTestClass, parameterized.TestCase):
 
   def __init__(self, configs: config_parser.TestRunConfig):
     super().__init__(configs)
@@ -150,3 +153,23 @@
     )
     with asserts.assert_raises(UnsupportedOperationException):
       send_raw_packet_downstream(self.mock_ad, "eth0", "AABBCCDDEEFF")
+
+  @parameterized.parameters(
+      ("2,2048,1", ApfCapabilities(2, 2048, 1)),  # Valid input
+      ("3,1024,0", ApfCapabilities(3, 1024, 0)),  # Valid input
+      ("invalid,output", ApfCapabilities(0, 0, 0)),  # Invalid input
+      ("", ApfCapabilities(0, 0, 0)),  # Empty input
+  )
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_apf_capabilities(
+      self, mock_output, expected_result, mock_adb_shell
+  ):
+    """Tests the get_apf_capabilities function with various inputs and expected results."""
+    # Configure the mock adb_shell to return the specified output
+    mock_adb_shell.return_value = mock_output
+
+    # Call the function under test
+    result = get_apf_capabilities(self.mock_ad, "wlan0")
+
+    # Assert that the result matches the expected result
+    asserts.assert_equal(result, expected_result)
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt
new file mode 100644
index 0000000..bdcb8c0
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/GrowingIntArrayTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.test.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class GrowingIntArrayTest {
+    @Test
+    fun testAddAndGet() {
+        val array = GrowingIntArray(1)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        assertEquals(-1, array.get(0))
+        assertEquals(0, array.get(1))
+        assertEquals(2, array.get(2))
+        assertEquals(3, array.length())
+    }
+
+    @Test
+    fun testForEach() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        val actual = mutableListOf<Int>()
+        array.forEach { actual.add(it) }
+
+        val expected = listOf(-1, 0, 2)
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun testForEach_EmptyArray() {
+        val array = GrowingIntArray(10)
+        array.forEach {
+            fail("This should not be called")
+        }
+    }
+
+    @Test
+    fun testRemoveValues() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(0)
+        array.add(2)
+
+        array.removeValues { it <= 0 }
+        assertEquals(1, array.length())
+        assertEquals(2, array.get(0))
+    }
+
+    @Test
+    fun testContains() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+
+        assertTrue(array.contains(-1))
+        assertTrue(array.contains(2))
+
+        assertFalse(array.contains(0))
+        assertFalse(array.contains(3))
+    }
+
+    @Test
+    fun testClear() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+        array.clear()
+
+        assertEquals(0, array.length())
+    }
+
+    @Test
+    fun testEnsureHasCapacity() {
+        val array = GrowingIntArray(0)
+        array.add(42)
+        array.ensureHasCapacity(2)
+
+        assertEquals(3, array.backingArrayLength)
+    }
+
+    @Test
+    fun testGetShrinkedBackingArray() {
+        val array = GrowingIntArray(10)
+        array.add(-1)
+        array.add(2)
+
+        assertContentEquals(intArrayOf(-1, 2), array.shrinkedBackingArray)
+    }
+
+    @Test
+    fun testToString() {
+        assertEquals("[]", GrowingIntArray(10).toString())
+        assertEquals("[1,2,3]", GrowingIntArray(3).apply {
+            add(1)
+            add(2)
+            add(3)
+        }.toString())
+    }
+}
diff --git a/staticlibs/tests/unit/src/com/android/testutils/DefaultNetworkRestoreMonitorTest.kt b/staticlibs/tests/unit/src/com/android/testutils/DefaultNetworkRestoreMonitorTest.kt
new file mode 100644
index 0000000..7e508fb
--- /dev/null
+++ b/staticlibs/tests/unit/src/com/android/testutils/DefaultNetworkRestoreMonitorTest.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import org.junit.Test
+import org.junit.runner.Description
+import org.junit.runner.notification.RunListener
+import org.junit.runner.notification.RunNotifier
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.ArgumentMatchers.argThat
+import org.mockito.Mockito.doAnswer
+import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+
+class DefaultNetworkRestoreMonitorTest {
+    private val restoreDefaultNetworkDesc =
+            Description.createSuiteDescription("RestoreDefaultNetwork")
+    private val testDesc = Description.createTestDescription("testClass", "testMethod")
+    private val wifiCap = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_WIFI)
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+            .build()
+    private val cellCap = NetworkCapabilities.Builder()
+            .addTransportType(TRANSPORT_CELLULAR)
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+            .build()
+    private val cm = mock(ConnectivityManager::class.java)
+    private val pm = mock(PackageManager::class.java).also {
+        doReturn(true).`when`(it).hasSystemFeature(anyString())
+    }
+    private val ctx = mock(Context::class.java).also {
+        doReturn(cm).`when`(it).getSystemService(ConnectivityManager::class.java)
+        doReturn(pm).`when`(it).getPackageManager()
+    }
+    private val notifier = mock(RunNotifier::class.java)
+    private val defaultNetworkMonitor = DefaultNetworkRestoreMonitor(
+        ctx,
+        notifier,
+        timeoutMs = 0
+    )
+
+    private fun getRunListener(): RunListener {
+        val captor = ArgumentCaptor.forClass(RunListener::class.java)
+        verify(notifier).addListener(captor.capture())
+        return captor.value
+    }
+
+    private fun mockDefaultNetworkCapabilities(cap: NetworkCapabilities?) {
+        if (cap == null) {
+            doNothing().`when`(cm).registerDefaultNetworkCallback(any())
+            return
+        }
+        doAnswer {
+            val callback = it.getArgument(0) as NetworkCallback
+            callback.onCapabilitiesChanged(Network(100), cap)
+        }.`when`(cm).registerDefaultNetworkCallback(any())
+    }
+
+    @Test
+    fun testDefaultNetworkRestoreMonitor_defaultNetworkRestored() {
+        mockDefaultNetworkCapabilities(wifiCap)
+        defaultNetworkMonitor.init(mock(ConnectUtil::class.java))
+
+        val listener = getRunListener()
+        listener.testFinished(testDesc)
+
+        defaultNetworkMonitor.reportResultAndCleanUp(restoreDefaultNetworkDesc)
+        val inOrder = inOrder(notifier)
+        inOrder.verify(notifier).fireTestStarted(restoreDefaultNetworkDesc)
+        inOrder.verify(notifier, never()).fireTestFailure(any())
+        inOrder.verify(notifier).fireTestFinished(restoreDefaultNetworkDesc)
+        inOrder.verify(notifier).removeListener(listener)
+    }
+
+    @Test
+    fun testDefaultNetworkRestoreMonitor_testStartWithoutDefaultNetwork() {
+        // There is no default network when the tests start
+        mockDefaultNetworkCapabilities(null)
+        defaultNetworkMonitor.init(mock(ConnectUtil::class.java))
+
+        mockDefaultNetworkCapabilities(wifiCap)
+        val listener = getRunListener()
+        listener.testFinished(testDesc)
+
+        defaultNetworkMonitor.reportResultAndCleanUp(restoreDefaultNetworkDesc)
+        val inOrder = inOrder(notifier)
+        inOrder.verify(notifier).fireTestStarted(restoreDefaultNetworkDesc)
+        // fireTestFailure is called
+        inOrder.verify(notifier).fireTestFailure(any())
+        inOrder.verify(notifier).fireTestFinished(restoreDefaultNetworkDesc)
+        inOrder.verify(notifier).removeListener(listener)
+    }
+
+    @Test
+    fun testDefaultNetworkRestoreMonitor_testEndWithoutDefaultNetwork() {
+        mockDefaultNetworkCapabilities(wifiCap)
+        defaultNetworkMonitor.init(mock(ConnectUtil::class.java))
+
+        // There is no default network after the test
+        mockDefaultNetworkCapabilities(null)
+        val listener = getRunListener()
+        listener.testFinished(testDesc)
+
+        defaultNetworkMonitor.reportResultAndCleanUp(restoreDefaultNetworkDesc)
+        val inOrder = inOrder(notifier)
+        inOrder.verify(notifier).fireTestStarted(restoreDefaultNetworkDesc)
+        // fireTestFailure is called with method name
+        inOrder.verify(
+                notifier
+        ).fireTestFailure(
+                argThat{failure -> failure.exception.message?.contains("testMethod") ?: false}
+        )
+        inOrder.verify(notifier).fireTestFinished(restoreDefaultNetworkDesc)
+        inOrder.verify(notifier).removeListener(listener)
+    }
+
+    @Test
+    fun testDefaultNetworkRestoreMonitor_testChangeDefaultNetwork() {
+        mockDefaultNetworkCapabilities(wifiCap)
+        defaultNetworkMonitor.init(mock(ConnectUtil::class.java))
+
+        // The default network transport types change after the test
+        mockDefaultNetworkCapabilities(cellCap)
+        val listener = getRunListener()
+        listener.testFinished(testDesc)
+
+        defaultNetworkMonitor.reportResultAndCleanUp(restoreDefaultNetworkDesc)
+        val inOrder = inOrder(notifier)
+        inOrder.verify(notifier).fireTestStarted(restoreDefaultNetworkDesc)
+        // fireTestFailure is called with method name
+        inOrder.verify(
+                notifier
+        ).fireTestFailure(
+                argThat{failure -> failure.exception.message?.contains("testMethod") ?: false}
+        )
+        inOrder.verify(notifier).fireTestFinished(restoreDefaultNetworkDesc)
+        inOrder.verify(notifier).removeListener(listener)
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DefaultNetworkRestoreMonitor.kt b/staticlibs/testutils/devicetests/com/android/testutils/DefaultNetworkRestoreMonitor.kt
new file mode 100644
index 0000000..1b709b2
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DefaultNetworkRestoreMonitor.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import com.android.internal.annotations.VisibleForTesting
+import com.android.net.module.util.BitUtils
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import org.junit.runner.Description
+import org.junit.runner.notification.Failure
+import org.junit.runner.notification.RunListener
+import org.junit.runner.notification.RunNotifier
+
+@VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE)
+class DefaultNetworkRestoreMonitor(
+        ctx: Context,
+        private val notifier: RunNotifier,
+        private val timeoutMs: Long = 3000
+) {
+    var firstFailure: Exception? = null
+    var initialTransports = 0L
+    val cm = ctx.getSystemService(ConnectivityManager::class.java)!!
+    val pm = ctx.packageManager
+    val listener = object : RunListener() {
+        override fun testFinished(desc: Description) {
+            // Only the first method that does not restore the default network should be blamed.
+            if (firstFailure != null) {
+                return
+            }
+            val cb = TestableNetworkCallback()
+            cm.registerDefaultNetworkCallback(cb)
+            try {
+                cb.eventuallyExpect<RecorderCallback.CallbackEntry.CapabilitiesChanged>(
+                    timeoutMs = timeoutMs
+                ) {
+                    BitUtils.packBits(it.caps.transportTypes) == initialTransports &&
+                            it.caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+                }
+            } catch (e: AssertionError) {
+                firstFailure = IllegalStateException(desc.methodName +
+                        " does not restore the default network")
+            } finally {
+                cm.unregisterNetworkCallback(cb)
+            }
+        }
+    }
+
+    fun init(connectUtil: ConnectUtil) {
+        // Ensure Wi-Fi and cellular connection before running test to avoid starting test
+        // with unexpected default network.
+        // ConnectivityTestTargetPreparer does the same thing, but it's possible that previous tests
+        // don't enable DefaultNetworkRestoreMonitor and the default network is not restored.
+        // This can be removed if all tests enable DefaultNetworkRestoreMonitor
+        if (pm.hasSystemFeature(PackageManager.FEATURE_WIFI)) {
+            connectUtil.ensureWifiValidated()
+        }
+        if (pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) {
+            connectUtil.ensureCellularValidated()
+        }
+
+        val capFuture = CompletableFuture<NetworkCapabilities>()
+        val cb = object : ConnectivityManager.NetworkCallback() {
+            override fun onCapabilitiesChanged(
+                    network: Network,
+                    cap: NetworkCapabilities
+            ) {
+                capFuture.complete(cap)
+            }
+        }
+        cm.registerDefaultNetworkCallback(cb)
+        try {
+            val cap = capFuture.get(100, TimeUnit.MILLISECONDS)
+            initialTransports = BitUtils.packBits(cap.transportTypes)
+        } catch (e: Exception) {
+            firstFailure = IllegalStateException(
+                    "Failed to get default network status before starting tests", e
+            )
+        } finally {
+            cm.unregisterNetworkCallback(cb)
+        }
+        notifier.addListener(listener)
+    }
+
+    fun reportResultAndCleanUp(desc: Description) {
+        notifier.fireTestStarted(desc)
+        if (firstFailure != null) {
+            notifier.fireTestFailure(
+                    Failure(desc, firstFailure)
+            )
+        }
+        notifier.fireTestFinished(desc)
+        notifier.removeListener(listener)
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
index 8687ac7..a014834 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRunner.kt
@@ -16,6 +16,8 @@
 
 package com.android.testutils
 
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import com.android.net.module.util.LinkPropertiesUtils.CompareOrUpdateResult
 import com.android.testutils.DevSdkIgnoreRule.IgnoreAfter
@@ -57,6 +59,10 @@
 class DevSdkIgnoreRunner(private val klass: Class<*>) : Runner(), Filterable, Sortable {
     private val leakMonitorDesc = Description.createTestDescription(klass, "ThreadLeakMonitor")
     private val shouldThreadLeakFailTest = klass.isAnnotationPresent(MonitorThreadLeak::class.java)
+    private val restoreDefaultNetworkDesc =
+            Description.createTestDescription(klass, "RestoreDefaultNetwork")
+    private val restoreDefaultNetwork = klass.isAnnotationPresent(RestoreDefaultNetwork::class.java)
+    val ctx = ApplicationProvider.getApplicationContext<Context>()
 
     // Inference correctly infers Runner & Filterable & Sortable for |baseRunner|, but the
     // Java bytecode doesn't have a way to express this. Give this type a name by wrapping it.
@@ -71,6 +77,10 @@
     // TODO(b/307693729): Remove this annotation and monitor thread leak by default.
     annotation class MonitorThreadLeak
 
+    // Annotation for test classes to indicate the test runner should verify the default network is
+    // restored after each test.
+    annotation class RestoreDefaultNetwork
+
     private val baseRunner: RunnerWrapper<*>? = klass.let {
         val ignoreAfter = it.getAnnotation(IgnoreAfter::class.java)
         val ignoreUpTo = it.getAnnotation(IgnoreUpTo::class.java)
@@ -125,6 +135,14 @@
             )
             return
         }
+
+        val networkRestoreMonitor = if (restoreDefaultNetwork) {
+            DefaultNetworkRestoreMonitor(ctx, notifier).apply{
+                init(ConnectUtil(ctx))
+            }
+        } else {
+            null
+        }
         val threadCountsBeforeTest = if (shouldThreadLeakFailTest) {
             // Dump threads as a baseline to monitor thread leaks.
             getAllThreadNameCounts()
@@ -137,6 +155,7 @@
         if (threadCountsBeforeTest != null) {
             checkThreadLeak(notifier, threadCountsBeforeTest)
         }
+        networkRestoreMonitor?.reportResultAndCleanUp(restoreDefaultNetworkDesc)
         // Clears up internal state of all inline mocks.
         // TODO: Call clearInlineMocks() at the end of each test.
         Mockito.framework().clearInlineMocks()
@@ -163,6 +182,9 @@
             if (shouldThreadLeakFailTest) {
                 it.addChild(leakMonitorDesc)
             }
+            if (restoreDefaultNetwork) {
+                it.addChild(restoreDefaultNetworkDesc)
+            }
         }
     }
 
@@ -173,7 +195,14 @@
         // When ignoring the tests, a skipped placeholder test is reported, so test count is 1.
         if (baseRunner == null) return 1
 
-        return baseRunner.testCount() + if (shouldThreadLeakFailTest) 1 else 0
+        var testCount = baseRunner.testCount()
+        if (shouldThreadLeakFailTest) {
+            testCount += 1
+        }
+        if (restoreDefaultNetwork) {
+            testCount += 1
+        }
+        return testCount
     }
 
     @Throws(NoTestsRemainException::class)
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index f71464c..415799c 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -12,7 +12,9 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
+from dataclasses import dataclass
 import re
+from mobly import asserts
 from mobly.controllers import android_device
 from mobly.controllers.android_device_lib.adb import AdbError
 from net_tests_utils.host.python import adb_utils, assert_utils
@@ -190,3 +192,65 @@
     raise assert_utils.UnexpectedBehaviorError(
         f"Got unexpected output: {output} for command: {cmd}."
     )
+
+
+@dataclass
+class ApfCapabilities:
+  """APF program support capabilities.
+
+  See android.net.apf.ApfCapabilities.
+
+  Attributes:
+      apf_version_supported (int): Version of APF instruction set supported for
+        packet filtering. 0 indicates no support for packet filtering using APF
+        programs.
+      apf_ram_size (int): Size of APF ram.
+      apf_packet_format (int): Format of packets passed to APF filter. Should be
+        one of ARPHRD_*
+  """
+
+  apf_version_supported: int
+  apf_ram_size: int
+  apf_packet_format: int
+
+  def __init__(
+      self,
+      apf_version_supported: int,
+      apf_ram_size: int,
+      apf_packet_format: int,
+  ):
+    self.apf_version_supported = apf_version_supported
+    self.apf_ram_size = apf_ram_size
+    self.apf_packet_format = apf_packet_format
+
+  def __str__(self):
+    """Returns a user-friendly string representation of the APF capabilities."""
+    return (
+        f"APF Version: {self.apf_version_supported}\n"
+        f"Ram Size: {self.apf_ram_size} bytes\n"
+        f"Packet Format: {self.apf_packet_format}"
+    )
+
+
+def get_apf_capabilities(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> ApfCapabilities:
+  output = adb_utils.adb_shell(
+      ad, f"cmd network_stack apf {iface_name} capabilities"
+  )
+  try:
+    values = [int(value_str) for value_str in output.split(",")]
+  except ValueError:
+    return ApfCapabilities(0, 0, 0)  # Conversion to integer failed
+  return ApfCapabilities(values[0], values[1], values[2])
+
+
+def assume_apf_version_support_at_least(
+    ad: android_device.AndroidDevice, iface_name: str, expected_version: int
+) -> None:
+  caps = get_apf_capabilities(ad, iface_name)
+  asserts.skip_if(
+      caps.apf_version_supported < expected_version,
+      f"Supported apf version {caps.apf_version_supported} < expected version"
+      f" {expected_version}",
+  )
diff --git a/staticlibs/testutils/host/python/mdns_utils.py b/staticlibs/testutils/host/python/mdns_utils.py
index ec1fea0..1234e54 100644
--- a/staticlibs/testutils/host/python/mdns_utils.py
+++ b/staticlibs/testutils/host/python/mdns_utils.py
@@ -12,9 +12,24 @@
 #  See the License for the specific language governing permissions and
 #  limitations under the License.
 
+from mobly import asserts
 from mobly.controllers import android_device
 
 
+def assume_mdns_test_preconditions(
+    advertising_device: android_device, discovery_device: android_device
+) -> None:
+  advertising = advertising_device.connectivity_multi_devices_snippet
+  discovery = discovery_device.connectivity_multi_devices_snippet
+
+  asserts.skip_if(
+      not advertising.isAtLeastT(), "Advertising device SDK is lower than T."
+  )
+  asserts.skip_if(
+      not discovery.isAtLeastT(), "Discovery device SDK is lower than T."
+  )
+
+
 def register_mdns_service_and_discover_resolve(
     advertising_device: android_device, discovery_device: android_device
 ) -> None:
diff --git a/staticlibs/testutils/host/python/wifip2p_utils.py b/staticlibs/testutils/host/python/wifip2p_utils.py
new file mode 100644
index 0000000..8b4ffa5
--- /dev/null
+++ b/staticlibs/testutils/host/python/wifip2p_utils.py
@@ -0,0 +1,50 @@
+#  Copyright (C) 2024 The Android Open Source Project
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+from mobly import asserts
+from mobly.controllers import android_device
+
+
+def assume_wifi_p2p_test_preconditions(
+    server_device: android_device, client_device: android_device
+) -> None:
+  server = server_device.connectivity_multi_devices_snippet
+  client = client_device.connectivity_multi_devices_snippet
+
+  # Assert pre-conditions
+  asserts.skip_if(not server.hasWifiFeature(), "Server requires Wifi feature")
+  asserts.skip_if(not client.hasWifiFeature(), "Client requires Wifi feature")
+  asserts.skip_if(
+      not server.isP2pSupported(), "Server requires Wi-fi P2P feature"
+  )
+  asserts.skip_if(
+      not client.isP2pSupported(), "Client requires Wi-fi P2P feature"
+  )
+
+
+def setup_wifi_p2p_server_and_client(
+    server_device: android_device, client_device: android_device
+) -> None:
+  """Set up the Wi-Fi P2P server and client."""
+  # Start Wi-Fi P2P on both server and client.
+  server_device.connectivity_multi_devices_snippet.startWifiP2p()
+  client_device.connectivity_multi_devices_snippet.startWifiP2p()
+
+
+def cleanup_wifi_p2p(
+    server_device: android_device, client_device: android_device
+) -> None:
+  # Stop Wi-Fi P2P
+  server_device.connectivity_multi_devices_snippet.stopWifiP2p()
+  client_device.connectivity_multi_devices_snippet.stopWifiP2p()
diff --git a/tests/cts/multidevices/connectivity_multi_devices_test.py b/tests/cts/multidevices/connectivity_multi_devices_test.py
index 0cfc361..7e7bbf5 100644
--- a/tests/cts/multidevices/connectivity_multi_devices_test.py
+++ b/tests/cts/multidevices/connectivity_multi_devices_test.py
@@ -68,6 +68,9 @@
     tether_utils.assume_hotspot_test_preconditions(
         self.serverDevice, self.clientDevice, UpstreamType.NONE
     )
+    mdns_utils.assume_mdns_test_preconditions(
+        self.clientDevice, self.serverDevice
+    )
     try:
       # Connectivity of the client verified by asserting the validated capability.
       tether_utils.setup_hotspot_and_client_for_upstream_type(
diff --git a/tests/cts/multidevices/snippet/Android.bp b/tests/cts/multidevices/snippet/Android.bp
index b0b32c2..c94087e 100644
--- a/tests/cts/multidevices/snippet/Android.bp
+++ b/tests/cts/multidevices/snippet/Android.bp
@@ -26,6 +26,7 @@
     srcs: [
         "ConnectivityMultiDevicesSnippet.kt",
         "MdnsMultiDevicesSnippet.kt",
+        "Wifip2pMultiDevicesSnippet.kt",
     ],
     manifest: "AndroidManifest.xml",
     static_libs: [
diff --git a/tests/cts/multidevices/snippet/AndroidManifest.xml b/tests/cts/multidevices/snippet/AndroidManifest.xml
index 967e581..4637497 100644
--- a/tests/cts/multidevices/snippet/AndroidManifest.xml
+++ b/tests/cts/multidevices/snippet/AndroidManifest.xml
@@ -21,6 +21,8 @@
   <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
   <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
   <uses-permission android:name="android.permission.INTERNET" />
+  <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
+                   android:usesPermissionFlags="neverForLocation" />
   <application>
     <!-- Add any classes that implement the Snippet interface as meta-data, whose
          value is a comma-separated string, each section being the package path
@@ -28,7 +30,8 @@
     <meta-data
         android:name="mobly-snippets"
         android:value="com.google.snippet.connectivity.ConnectivityMultiDevicesSnippet,
-                       com.google.snippet.connectivity.MdnsMultiDevicesSnippet" />
+                       com.google.snippet.connectivity.MdnsMultiDevicesSnippet,
+                       com.google.snippet.connectivity.Wifip2pMultiDevicesSnippet" />
   </application>
   <!-- Add an instrumentation tag so that the app can be launched through an
        instrument command. The runner `com.google.android.mobly.snippet.SnippetRunner`
diff --git a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
index 9bdf4a3..7368669 100644
--- a/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
+++ b/tests/cts/multidevices/snippet/ConnectivityMultiDevicesSnippet.kt
@@ -36,6 +36,7 @@
 import android.net.wifi.WifiNetworkSpecifier
 import android.net.wifi.WifiSsid
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.modules.utils.build.SdkLevel
 import com.android.testutils.AutoReleaseNetworkCallbackRule
 import com.android.testutils.ConnectUtil
 import com.android.testutils.NetworkCallbackHelper
@@ -71,6 +72,9 @@
     @Rpc(description = "Check whether the device supporters AP + STA concurrency.")
     fun isStaApConcurrencySupported() = wifiManager.isStaApConcurrencySupported()
 
+    @Rpc(description = "Check whether the device SDK is as least T")
+    fun isAtLeastT() = SdkLevel.isAtLeastT()
+
     @Rpc(description = "Request cellular connection and ensure it is the default network.")
     fun requestCellularAndEnsureDefault() {
         ctsNetUtils.disableWifi()
diff --git a/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
new file mode 100644
index 0000000..e0929bb
--- /dev/null
+++ b/tests/cts/multidevices/snippet/Wifip2pMultiDevicesSnippet.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.snippet.connectivity
+
+import android.net.wifi.WifiManager
+import android.net.wifi.p2p.WifiP2pManager
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.mobly.snippet.Snippet
+import com.google.android.mobly.snippet.rpc.Rpc
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import kotlin.test.fail
+
+private const val TIMEOUT_MS = 60000L
+
+class Wifip2pMultiDevicesSnippet : Snippet {
+    private val context by lazy { InstrumentationRegistry.getInstrumentation().getTargetContext() }
+    private val wifiManager by lazy {
+        context.getSystemService(WifiManager::class.java)
+                ?: fail("Could not get WifiManager service")
+    }
+    private val wifip2pManager by lazy {
+        context.getSystemService(WifiP2pManager::class.java)
+                ?: fail("Could not get WifiP2pManager service")
+    }
+    private lateinit var wifip2pChannel: WifiP2pManager.Channel
+
+    @Rpc(description = "Check whether the device supports Wi-Fi P2P.")
+    fun isP2pSupported() = wifiManager.isP2pSupported()
+
+    @Rpc(description = "Start Wi-Fi P2P")
+    fun startWifiP2p() {
+        // Initialize Wi-Fi P2P
+        wifip2pChannel = wifip2pManager.initialize(context, context.mainLooper, null)
+
+        // Ensure the Wi-Fi P2P channel is available
+        val p2pStateEnabledFuture = CompletableFuture<Boolean>()
+        wifip2pManager.requestP2pState(wifip2pChannel) { state ->
+            if (state == WifiP2pManager.WIFI_P2P_STATE_ENABLED) {
+                p2pStateEnabledFuture.complete(true)
+            }
+        }
+        p2pStateEnabledFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
+    }
+
+    @Rpc(description = "Stop Wi-Fi P2P")
+    fun stopWifiP2p() {
+        if (this::wifip2pChannel.isInitialized) {
+            wifip2pManager.cancelConnect(wifip2pChannel, null)
+            wifip2pManager.removeGroup(wifip2pChannel, null)
+        }
+    }
+}
diff --git a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
index 06a827b..2c7d5c6 100644
--- a/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
+++ b/tests/cts/net/src/android/net/cts/MultinetworkApiTest.java
@@ -40,10 +40,10 @@
 import android.system.OsConstants;
 import android.util.ArraySet;
 
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.platform.app.InstrumentationRegistry;
 
 import com.android.testutils.AutoReleaseNetworkCallbackRule;
+import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.DeviceConfigRule;
 
 import org.junit.Before;
@@ -53,7 +53,8 @@
 
 import java.util.Set;
 
-@RunWith(AndroidJUnit4.class)
+@DevSdkIgnoreRunner.RestoreDefaultNetwork
+@RunWith(DevSdkIgnoreRunner.class)
 public class MultinetworkApiTest {
     @Rule(order = 1)
     public final DeviceConfigRule mDeviceConfigRule = new DeviceConfigRule();
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index beb9274..60081d4 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -194,15 +194,16 @@
 // TODO : enable this in a Mainline update or in V.
 private const val SHOULD_CREATE_NETWORKS_IMMEDIATELY = false
 
-@RunWith(DevSdkIgnoreRunner::class)
-// NetworkAgent is not updatable in R-, so this test does not need to be compatible with older
-// versions. NetworkAgent was also based on AsyncChannel before S so cannot be tested the same way.
-@IgnoreUpTo(Build.VERSION_CODES.R)
+@AppModeFull(reason = "Instant apps can't use NetworkAgent because it needs NETWORK_FACTORY'.")
 // NetworkAgent is updated as part of the connectivity module, and running NetworkAgent tests in MTS
 // for modules other than Connectivity does not provide much value. Only run them in connectivity
 // module MTS, so the tests only need to cover the case of an updated NetworkAgent.
 @ConnectivityModuleTest
-@AppModeFull(reason = "Instant apps can't use NetworkAgent because it needs NETWORK_FACTORY'.")
+@DevSdkIgnoreRunner.RestoreDefaultNetwork
+// NetworkAgent is not updatable in R-, so this test does not need to be compatible with older
+// versions. NetworkAgent was also based on AsyncChannel before S so cannot be tested the same way.
+@IgnoreUpTo(Build.VERSION_CODES.R)
+@RunWith(DevSdkIgnoreRunner::class)
 class NetworkAgentTest {
     private val LOCAL_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.1")
     private val REMOTE_IPV4_ADDRESS = InetAddresses.parseNumericAddress("192.0.2.2")
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 2f88c41..ef3ebb0 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -92,6 +92,7 @@
         "frameworks-net-integration-testutils",
         "framework-protos",
         "mockito-target-minus-junit4",
+        "modules-utils-build",
         "net-tests-utils",
         "net-utils-services-common",
         "platform-compat-test-rules",
diff --git a/tests/unit/java/com/android/server/CallbackQueueTest.kt b/tests/unit/java/com/android/server/CallbackQueueTest.kt
new file mode 100644
index 0000000..a6dd5c3
--- /dev/null
+++ b/tests/unit/java/com/android/server/CallbackQueueTest.kt
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.CALLBACK_AVAILABLE
+import android.net.ConnectivityManager.CALLBACK_CAP_CHANGED
+import android.net.ConnectivityManager.CALLBACK_IP_CHANGED
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import java.lang.reflect.Modifier
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val TEST_NETID_1 = 123
+
+// Maximum 16 bits unsigned value
+private const val TEST_NETID_2 = 0xffff
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.R)
+class CallbackQueueTest {
+    @Test
+    fun testAddCallback() {
+        val cbs = listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_2 to CALLBACK_AVAILABLE,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED
+        )
+        val queue = CallbackQueue(intArrayOf()).apply {
+            cbs.forEach { addCallback(it.first, it.second) }
+        }
+
+        assertQueueEquals(cbs, queue)
+    }
+
+    @Test
+    fun testHasCallback() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+        }
+
+        assertTrue(queue.hasCallback(TEST_NETID_1, CALLBACK_AVAILABLE))
+        assertTrue(queue.hasCallback(TEST_NETID_2, CALLBACK_AVAILABLE))
+        assertTrue(queue.hasCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED))
+
+        assertFalse(queue.hasCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED))
+        assertFalse(queue.hasCallback(1234, CALLBACK_AVAILABLE))
+        assertFalse(queue.hasCallback(TEST_NETID_1, 5678))
+        assertFalse(queue.hasCallback(1234, 5678))
+    }
+
+    @Test
+    fun testRemoveCallbacks() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            assertFalse(removeCallbacks(TEST_NETID_1, CALLBACK_AVAILABLE))
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            assertTrue(removeCallbacks(TEST_NETID_1, CALLBACK_AVAILABLE))
+        }
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_2 to CALLBACK_AVAILABLE
+        ), queue)
+    }
+
+    @Test
+    fun testRemoveCallbacksForNetId() {
+        val queue = CallbackQueue(intArrayOf()).apply {
+            assertFalse(removeCallbacksForNetId(TEST_NETID_2))
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+            assertFalse(removeCallbacksForNetId(TEST_NETID_1))
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_1, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED)
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_IP_CHANGED)
+            assertTrue(removeCallbacksForNetId(TEST_NETID_2))
+        }
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_1 to CALLBACK_CAP_CHANGED,
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+        ), queue)
+    }
+
+    @Test
+    fun testConstructorFromExistingArray() {
+        val queue1 = CallbackQueue(intArrayOf()).apply {
+            addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            addCallback(TEST_NETID_2, CALLBACK_AVAILABLE)
+        }
+        val queue2 = CallbackQueue(queue1.shrinkedBackingArray)
+        assertQueueEquals(listOf(
+            TEST_NETID_1 to CALLBACK_AVAILABLE,
+            TEST_NETID_2 to CALLBACK_AVAILABLE
+        ), queue2)
+    }
+
+    @Test
+    fun testToString() {
+        assertEquals("[]", CallbackQueue(intArrayOf()).toString())
+        assertEquals(
+            "[CALLBACK_AVAILABLE($TEST_NETID_1)]",
+            CallbackQueue(intArrayOf()).apply {
+                addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+            }.toString()
+        )
+        assertEquals(
+            "[CALLBACK_AVAILABLE($TEST_NETID_1),CALLBACK_CAP_CHANGED($TEST_NETID_2)]",
+            CallbackQueue(intArrayOf()).apply {
+                addCallback(TEST_NETID_1, CALLBACK_AVAILABLE)
+                addCallback(TEST_NETID_2, CALLBACK_CAP_CHANGED)
+            }.toString()
+        )
+    }
+
+    @Test
+    fun testMaxNetId() {
+        // CallbackQueue assumes netIds are at most 16 bits
+        assertTrue(NetIdManager.MAX_NET_ID <= 0xffff)
+    }
+
+    @Test
+    fun testMaxCallbackId() {
+        // CallbackQueue assumes callback IDs are at most 16 bits.
+        val constants = ConnectivityManager::class.java.declaredFields.filter {
+            Modifier.isStatic(it.modifiers) && Modifier.isFinal(it.modifiers) &&
+                    it.name.startsWith("CALLBACK_")
+        }
+        constants.forEach {
+            it.isAccessible = true
+            assertTrue(it.get(null) as Int <= 0xffff)
+        }
+    }
+}
+
+private fun assertQueueEquals(expected: List<Pair<Int, Int>>, actual: CallbackQueue) {
+    assertEquals(
+        expected.size,
+        actual.length(),
+        "Size mismatch between expected: $expected and actual: $actual"
+    )
+
+    var nextIndex = 0
+    actual.forEach { netId, cbId ->
+        val (expNetId, expCbId) = expected[nextIndex]
+        val msg = "$actual does not match $expected at index $nextIndex"
+        assertEquals(expNetId, netId, msg)
+        assertEquals(expCbId, cbId, msg)
+        nextIndex++
+    }
+    // Ensure forEach iterations and size are consistent
+    assertEquals(expected.size, nextIndex)
+}
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index be7f2a3..999d17d 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -2180,6 +2180,7 @@
                 case ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK:
                 case ConnectivityFlags.REQUEST_RESTRICTED_WIFI:
                 case ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS:
+                case ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS:
                 case KEY_DESTROY_FROZEN_SOCKETS_VERSION:
                     return true;
                 default:
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
index fb3d183..4c71991 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
@@ -18,8 +18,10 @@
 
 import static com.android.server.connectivity.mdns.MdnsSocketProvider.SocketCallback;
 import static com.android.server.connectivity.mdns.MulticastPacketReader.PacketHandler;
+import static com.android.testutils.Cleanup.testAndCleanup;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
@@ -35,6 +37,7 @@
 import android.os.Build;
 import android.os.Handler;
 import android.os.HandlerThread;
+import android.util.Log;
 
 import com.android.net.module.util.HexDump;
 import com.android.net.module.util.SharedLog;
@@ -59,6 +62,7 @@
 import java.net.SocketException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 @RunWith(DevSdkIgnoreRunner.class)
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
@@ -437,4 +441,34 @@
             inOrder.verify(mSocket).send(packets.get(i));
         }
     }
+
+    @Test
+    public void testSendPacketWithMultiplePacketsWithDifferentAddresses() throws IOException {
+        final SocketCallback callback = expectSocketCallback();
+        final DatagramPacket ipv4Packet = new DatagramPacket(BUFFER, 0 /* offset */, BUFFER.length,
+                InetAddresses.parseNumericAddress("192.0.2.1"), 0 /* port */);
+        final DatagramPacket ipv6Packet = new DatagramPacket(BUFFER, 0 /* offset */, BUFFER.length,
+                InetAddresses.parseNumericAddress("2001:db8::"), 0 /* port */);
+        doReturn(true).when(mSocket).hasJoinedIpv4();
+        doReturn(true).when(mSocket).hasJoinedIpv6();
+        doReturn(createEmptyNetworkInterface()).when(mSocket).getInterface();
+
+        // Notify socket created
+        callback.onSocketCreated(mSocketKey, mSocket, List.of());
+        verify(mSocketCreationCallback).onSocketCreated(mSocketKey);
+
+        // Send packets with IPv4 and IPv6 then verify wtf logs and sending has never been called.
+        // Override the default TerribleFailureHandler, as that handler might terminate the process
+        // (if we're on an eng build).
+        final AtomicBoolean hasFailed = new AtomicBoolean(false);
+        final Log.TerribleFailureHandler originalHandler =
+                Log.setWtfHandler((tag, what, system) -> hasFailed.set(true));
+        testAndCleanup(() -> {
+            mSocketClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet, ipv6Packet),
+                    mSocketKey, false /* onlyUseIpv6OnIpv6OnlyNetworks */);
+            HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
+            assertTrue(hasFailed.get());
+            verify(mSocket, never()).send(any());
+        }, () -> Log.setWtfHandler(originalHandler));
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
index 1989ed3..ab70e38 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsSocketClientTests.java
@@ -16,6 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 
 import static org.junit.Assert.assertFalse;
@@ -38,9 +39,11 @@
 import android.annotation.RequiresPermission;
 import android.content.Context;
 import android.net.ConnectivityManager;
+import android.net.InetAddresses;
 import android.net.wifi.WifiManager;
 import android.net.wifi.WifiManager.MulticastLock;
 import android.text.format.DateUtils;
+import android.util.Log;
 
 import com.android.net.module.util.HexDump;
 import com.android.net.module.util.SharedLog;
@@ -594,6 +597,29 @@
         }
     }
 
+    @Test
+    public void testSendPacketWithMultiplePacketsWithDifferentAddresses() throws IOException {
+        mdnsClient.startDiscovery();
+        final byte[] buffer = new byte[10];
+        final DatagramPacket ipv4Packet = new DatagramPacket(buffer, 0 /* offset */, buffer.length,
+                InetAddresses.parseNumericAddress("192.0.2.1"), 0 /* port */);
+        final DatagramPacket ipv6Packet = new DatagramPacket(buffer, 0 /* offset */, buffer.length,
+                InetAddresses.parseNumericAddress("2001:db8::"), 0 /* port */);
+
+        // Send packets with IPv4 and IPv6 then verify wtf logs and sending has never been called.
+        // Override the default TerribleFailureHandler, as that handler might terminate the process
+        // (if we're on an eng build).
+        final AtomicBoolean hasFailed = new AtomicBoolean(false);
+        final Log.TerribleFailureHandler originalHandler =
+                Log.setWtfHandler((tag, what, system) -> hasFailed.set(true));
+        testAndCleanup(() -> {
+            mdnsClient.sendPacketRequestingMulticastResponse(List.of(ipv4Packet, ipv6Packet),
+                    false /* onlyUseIpv6OnIpv6OnlyNetworks */);
+            assertTrue(hasFailed.get());
+            verify(mockMulticastSocket, never()).send(any());
+        }, () -> Log.setWtfHandler(originalHandler));
+    }
+
     private DatagramPacket getTestDatagramPacket() {
         return new DatagramPacket(buf, 0, 5,
                 new InetSocketAddress(MdnsConstants.getMdnsIPv4Address(), 5353 /* port */));
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
index 009205e..cf88d05 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/util/MdnsUtilsTest.kt
@@ -16,9 +16,12 @@
 
 package com.android.server.connectivity.mdns.util
 
+import android.net.InetAddresses
 import android.os.Build
 import com.android.server.connectivity.mdns.MdnsConstants
 import com.android.server.connectivity.mdns.MdnsConstants.FLAG_TRUNCATED
+import com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR
+import com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR
 import com.android.server.connectivity.mdns.MdnsPacket
 import com.android.server.connectivity.mdns.MdnsPacketReader
 import com.android.server.connectivity.mdns.MdnsPointerRecord
@@ -193,4 +196,31 @@
         }
         return MdnsPacket(flags, questions, answers, emptyList(), emptyList())
     }
+
+    @Test
+    fun testCheckAllPacketsWithSameAddress() {
+        val buffer = ByteArray(10)
+        val v4Packet = DatagramPacket(buffer, buffer.size, IPV4_SOCKET_ADDR)
+        val otherV4Packet = DatagramPacket(
+            buffer,
+            buffer.size,
+            InetAddresses.parseNumericAddress("192.0.2.1"),
+            1234
+        )
+        val v6Packet = DatagramPacket(ByteArray(10), 10, IPV6_SOCKET_ADDR)
+        val otherV6Packet = DatagramPacket(
+            buffer,
+            buffer.size,
+            InetAddresses.parseNumericAddress("2001:db8::"),
+            1234
+        )
+        assertTrue(MdnsUtils.checkAllPacketsWithSameAddress(listOf()))
+        assertTrue(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet)))
+        assertTrue(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet, v4Packet)))
+        assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet, otherV4Packet)))
+        assertTrue(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v6Packet)))
+        assertTrue(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v6Packet, v6Packet)))
+        assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v6Packet, otherV6Packet)))
+        assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet, v6Packet)))
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index ed72fd2..de56ae5 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -165,6 +165,7 @@
         it[ConnectivityFlags.BACKGROUND_FIREWALL_CHAIN] = true
         it[ConnectivityFlags.DELAY_DESTROY_SOCKETS] = true
         it[ConnectivityFlags.USE_DECLARED_METHODS_FOR_CALLBACKS] = true
+        it[ConnectivityFlags.QUEUE_CALLBACKS_FOR_FROZEN_APPS] = true
     }
     fun setFeatureEnabled(flag: String, enabled: Boolean) = enabledFeatures.set(flag, enabled)
 
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 2f60d9a..e6f272b 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -111,7 +111,6 @@
 import android.os.RemoteException;
 import android.os.SystemClock;
 import android.os.UserManager;
-import android.provider.Settings;
 import android.util.Log;
 import android.util.SparseArray;
 
@@ -139,6 +138,7 @@
 import java.time.Clock;
 import java.time.DateTimeException;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -210,7 +210,6 @@
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
     private boolean mUserRestricted;
-    private boolean mAirplaneModeOn;
     private boolean mForceStopOtDaemonEnabled;
 
     private BorderRouterConfigurationParcel mBorderRouterConfig;
@@ -344,8 +343,8 @@
         final String modelName = resources.getString(R.string.config_thread_model_name);
         final String vendorName = resources.getString(R.string.config_thread_vendor_name);
         final String vendorOui = resources.getString(R.string.config_thread_vendor_oui);
-        final boolean managedByGoogle =
-                resources.getBoolean(R.bool.config_thread_managed_by_google_home);
+        final String[] vendorSpecificTxts =
+                resources.getStringArray(R.array.config_thread_mdns_vendor_specific_txts);
 
         if (!modelName.isEmpty()) {
             if (modelName.getBytes(UTF_8).length > MAX_MODEL_NAME_UTF8_BYTES) {
@@ -375,19 +374,44 @@
         meshcopTxts.modelName = modelName;
         meshcopTxts.vendorName = vendorName;
         meshcopTxts.vendorOui = HexEncoding.decode(vendorOui.replace("-", "").replace(":", ""));
-        meshcopTxts.nonStandardTxtEntries = List.of(makeManagedByGoogleTxtAttr(managedByGoogle));
+        meshcopTxts.nonStandardTxtEntries = makeVendorSpecificTxtAttrs(vendorSpecificTxts);
 
         return meshcopTxts;
     }
 
     /**
-     * Creates a DNS-SD TXT entry for indicating whether Thread on this device is managed by Google.
+     * Parses vendor-specific TXT entries from "=" separated strings into list of {@link
+     * DnsTxtAttribute}.
      *
-     * @return TXT entry "vgh=1" if {@code managedByGoogle} is {@code true}; otherwise, "vgh=0"
+     * @throws IllegalArgumentsException if invalid TXT entries are found in {@code vendorTxts}
      */
-    private static DnsTxtAttribute makeManagedByGoogleTxtAttr(boolean managedByGoogle) {
-        final byte[] value = (managedByGoogle ? "1" : "0").getBytes(UTF_8);
-        return new DnsTxtAttribute("vgh", value);
+    @VisibleForTesting
+    static List<DnsTxtAttribute> makeVendorSpecificTxtAttrs(String[] vendorTxts) {
+        List<DnsTxtAttribute> txts = new ArrayList<>();
+        for (String txt : vendorTxts) {
+            String[] kv = txt.split("=", 2 /* limit */); // Split with only the first '='
+            if (kv.length < 1) {
+                throw new IllegalArgumentException(
+                        "Invalid vendor-specific TXT is found in resources: " + txt);
+            }
+
+            if (kv[0].length() < 2) {
+                throw new IllegalArgumentException(
+                        "Invalid vendor-specific TXT key \""
+                                + kv[0]
+                                + "\": it must contain at least 2 characters");
+            }
+
+            if (!kv[0].startsWith("v")) {
+                throw new IllegalArgumentException(
+                        "Invalid vendor-specific TXT key \""
+                                + kv[0]
+                                + "\": it doesn't start with \"v\"");
+            }
+
+            txts.add(new DnsTxtAttribute(kv[0], (kv.length >= 2 ? kv[1] : "").getBytes(UTF_8)));
+        }
+        return txts;
     }
 
     private void onOtDaemonDied() {
@@ -420,8 +444,6 @@
                     requestThreadNetwork();
                     mUserRestricted = isThreadUserRestricted();
                     registerUserRestrictionsReceiver();
-                    mAirplaneModeOn = isAirplaneModeOn();
-                    registerAirplaneModeReceiver();
                     maybeInitializeOtDaemon();
                 });
     }
@@ -503,15 +525,6 @@
             // the otDaemon set enabled state operation succeeded or not, so that it can recover
             // to the desired value after reboot.
             mPersistentSettings.put(ThreadPersistentSettings.THREAD_ENABLED.key, isEnabled);
-
-            // Remember whether the user wanted to keep Thread enabled in airplane mode. If once
-            // the user disabled Thread again in airplane mode, the persistent settings state is
-            // reset (so that Thread will be auto-disabled again when airplane mode is turned on).
-            // This behavior is consistent with Wi-Fi and bluetooth.
-            if (mAirplaneModeOn) {
-                mPersistentSettings.put(
-                        ThreadPersistentSettings.THREAD_ENABLED_IN_AIRPLANE_MODE.key, isEnabled);
-            }
         }
 
         try {
@@ -598,10 +611,12 @@
                 new BroadcastReceiver() {
                     @Override
                     public void onReceive(Context context, Intent intent) {
-                        mHandler.post(() -> onUserRestrictionsChanged(isThreadUserRestricted()));
+                        onUserRestrictionsChanged(isThreadUserRestricted());
                     }
                 },
-                new IntentFilter(UserManager.ACTION_USER_RESTRICTIONS_CHANGED));
+                new IntentFilter(UserManager.ACTION_USER_RESTRICTIONS_CHANGED),
+                null /* broadcastPermission */,
+                mHandler);
     }
 
     private void onUserRestrictionsChanged(boolean newUserRestrictedState) {
@@ -648,72 +663,13 @@
         return mUserManager.hasUserRestriction(DISALLOW_THREAD_NETWORK);
     }
 
-    private void registerAirplaneModeReceiver() {
-        mContext.registerReceiver(
-                new BroadcastReceiver() {
-                    @Override
-                    public void onReceive(Context context, Intent intent) {
-                        mHandler.post(() -> onAirplaneModeChanged(isAirplaneModeOn()));
-                    }
-                },
-                new IntentFilter(Intent.ACTION_AIRPLANE_MODE_CHANGED));
-    }
-
-    private void onAirplaneModeChanged(boolean newAirplaneModeOn) {
-        checkOnHandlerThread();
-        if (mAirplaneModeOn == newAirplaneModeOn) {
-            return;
-        }
-        Log.i(TAG, "Airplane mode changed: " + mAirplaneModeOn + " -> " + newAirplaneModeOn);
-        mAirplaneModeOn = newAirplaneModeOn;
-
-        final boolean shouldEnableThread = shouldEnableThread();
-        final IOperationReceiver receiver =
-                new IOperationReceiver.Stub() {
-                    @Override
-                    public void onSuccess() {
-                        Log.d(
-                                TAG,
-                                (shouldEnableThread ? "Enabled" : "Disabled")
-                                        + " Thread due to airplane mode change");
-                    }
-
-                    @Override
-                    public void onError(int errorCode, String errorMessage) {
-                        Log.e(
-                                TAG,
-                                "Failed to "
-                                        + (shouldEnableThread ? "enable" : "disable")
-                                        + " Thread for airplane mode change");
-                    }
-                };
-        // Do not save the user restriction state to persistent settings so that the user
-        // configuration won't be overwritten
-        setEnabledInternal(
-                shouldEnableThread, false /* persist */, new OperationReceiverWrapper(receiver));
-    }
-
-    /** Returns {@code true} if Airplane mode has been turned on. */
-    private boolean isAirplaneModeOn() {
-        return Settings.Global.getInt(
-                        mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0)
-                == 1;
-    }
-
     /**
      * Returns {@code true} if Thread should be enabled based on current settings, runtime user
-     * restriction and airplane mode state.
+     * restriction state.
      */
     private boolean shouldEnableThread() {
-        final boolean enabledInAirplaneMode =
-                mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED_IN_AIRPLANE_MODE);
-
         return !mForceStopOtDaemonEnabled
                 && !mUserRestricted
-                // FIXME(b/340744397): Note that here we need to call `isAirplaneModeOn()` to get
-                // the latest state of airplane mode but can't use `mIsAirplaneMode`. This is for
-                // avoiding the race conditions described in b/340744397
-                && (!isAirplaneModeOn() || enabledInAirplaneMode)
                 && mPersistentSettings.get(ThreadPersistentSettings.THREAD_ENABLED);
     }
 
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 41f34ff..22e7a98 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -45,7 +45,6 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
-
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import android.content.Context;
@@ -859,7 +858,6 @@
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
         assertThat(txtMap.get("sb")).isNotNull();
-        assertThat(new String(txtMap.get("vgh"))).isIn(List.of("0", "1"));
     }
 
     @Test
@@ -886,7 +884,6 @@
         assertThat(txtMap.get("tv")).isNotNull();
         assertThat(txtMap.get("sb")).isNotNull();
         assertThat(txtMap.get("id").length).isEqualTo(16);
-        assertThat(new String(txtMap.get("vgh"))).isIn(List.of("0", "1"));
     }
 
     @Test
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index 6e2369f..eaf11b1 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -26,7 +26,6 @@
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
-import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED_IN_AIRPLANE_MODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -49,6 +48,8 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
@@ -68,7 +69,6 @@
 import android.os.SystemClock;
 import android.os.UserManager;
 import android.os.test.TestLooper;
-import android.provider.Settings;
 import android.util.AtomicFile;
 
 import androidx.test.annotation.UiThreadTest;
@@ -96,11 +96,11 @@
 import org.mockito.MockitoAnnotations;
 import org.mockito.MockitoSession;
 
-import java.nio.charset.StandardCharsets;
 import java.time.Clock;
 import java.time.DateTimeException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicReference;
@@ -150,7 +150,6 @@
     private static final byte[] TEST_VENDOR_OUI_BYTES = new byte[] {(byte) 0xAC, (byte) 0xDE, 0x48};
     private static final String TEST_VENDOR_NAME = "test vendor";
     private static final String TEST_MODEL_NAME = "test model";
-    private static final boolean TEST_VGH_VALUE = false;
 
     @Mock private ConnectivityManager mMockConnectivityManager;
     @Mock private NetworkAgent mMockNetworkAgent;
@@ -193,8 +192,6 @@
 
         when(mMockUserManager.hasUserRestriction(eq(DISALLOW_THREAD_NETWORK))).thenReturn(false);
 
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-
         when(mConnectivityResources.get()).thenReturn(mResources);
         when(mResources.getBoolean(eq(R.bool.config_thread_default_enabled))).thenReturn(true);
         when(mResources.getString(eq(R.string.config_thread_vendor_name)))
@@ -203,8 +200,8 @@
                 .thenReturn(TEST_VENDOR_OUI);
         when(mResources.getString(eq(R.string.config_thread_model_name)))
                 .thenReturn(TEST_MODEL_NAME);
-        when(mResources.getBoolean(eq(R.bool.config_thread_managed_by_google_home)))
-                .thenReturn(TEST_VGH_VALUE);
+        when(mResources.getStringArray(eq(R.array.config_thread_mdns_vendor_specific_txts)))
+                .thenReturn(new String[] {});
 
         final AtomicFile storageFile = new AtomicFile(tempFolder.newFile("thread_settings.xml"));
         mPersistentSettings = new ThreadPersistentSettings(storageFile, mConnectivityResources);
@@ -247,8 +244,8 @@
                 .thenReturn(TEST_VENDOR_OUI);
         when(mResources.getString(eq(R.string.config_thread_model_name)))
                 .thenReturn(TEST_MODEL_NAME);
-        when(mResources.getBoolean(eq(R.bool.config_thread_managed_by_google_home)))
-                .thenReturn(true);
+        when(mResources.getStringArray(eq(R.array.config_thread_mdns_vendor_specific_txts)))
+                .thenReturn(new String[] {"vt=test"});
 
         mService.initialize();
         mTestLooper.dispatchAll();
@@ -258,19 +255,7 @@
         assertThat(meshcopTxts.vendorOui).isEqualTo(TEST_VENDOR_OUI_BYTES);
         assertThat(meshcopTxts.modelName).isEqualTo(TEST_MODEL_NAME);
         assertThat(meshcopTxts.nonStandardTxtEntries)
-                .containsExactly(new DnsTxtAttribute("vgh", "1".getBytes(StandardCharsets.UTF_8)));
-    }
-
-    @Test
-    public void getMeshcopTxtAttributes_managedByGoogleIsFalse_vghIsZero() {
-        when(mResources.getBoolean(eq(R.bool.config_thread_managed_by_google_home)))
-                .thenReturn(false);
-
-        MeshcopTxtAttributes meshcopTxts =
-                ThreadNetworkControllerService.getMeshcopTxtAttributes(mResources);
-
-        assertThat(meshcopTxts.nonStandardTxtEntries)
-                .containsExactly(new DnsTxtAttribute("vgh", "0".getBytes(StandardCharsets.UTF_8)));
+                .containsExactly(new DnsTxtAttribute("vt", "test".getBytes(UTF_8)));
     }
 
     @Test
@@ -343,6 +328,61 @@
     }
 
     @Test
+    public void makeVendorSpecificTxtAttrs_validTxts_returnsParsedTxtAttrs() {
+        String[] txts = new String[] {"va=123", "vb=", "vc"};
+
+        List<DnsTxtAttribute> attrs = mService.makeVendorSpecificTxtAttrs(txts);
+
+        assertThat(attrs)
+                .containsExactly(
+                        new DnsTxtAttribute("va", "123".getBytes(UTF_8)),
+                        new DnsTxtAttribute("vb", new byte[] {}),
+                        new DnsTxtAttribute("vc", new byte[] {}));
+    }
+
+    @Test
+    public void makeVendorSpecificTxtAttrs_txtKeyNotStartWithV_throwsIllegalArgument() {
+        String[] txts = new String[] {"abc=123"};
+
+        assertThrows(
+                IllegalArgumentException.class, () -> mService.makeVendorSpecificTxtAttrs(txts));
+    }
+
+    @Test
+    public void makeVendorSpecificTxtAttrs_txtIsTooShort_throwsIllegalArgument() {
+        String[] txtEmptyKey = new String[] {"=123"};
+        String[] txtSingleCharKey = new String[] {"v=456"};
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mService.makeVendorSpecificTxtAttrs(txtEmptyKey));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mService.makeVendorSpecificTxtAttrs(txtSingleCharKey));
+    }
+
+    @Test
+    public void makeVendorSpecificTxtAttrs_txtValueIsEmpty_parseSuccess() {
+        String[] txts = new String[] {"va=", "vb"};
+
+        List<DnsTxtAttribute> attrs = mService.makeVendorSpecificTxtAttrs(txts);
+
+        assertThat(attrs)
+                .containsExactly(
+                        new DnsTxtAttribute("va", new byte[] {}),
+                        new DnsTxtAttribute("vb", new byte[] {}));
+    }
+
+    @Test
+    public void makeVendorSpecificTxtAttrs_multipleEquals_splittedByTheFirstEqual() {
+        String[] txts = new String[] {"va=abc=def=123"};
+
+        List<DnsTxtAttribute> attrs = mService.makeVendorSpecificTxtAttrs(txts);
+
+        assertThat(attrs).containsExactly(new DnsTxtAttribute("va", "abc=def=123".getBytes(UTF_8)));
+    }
+
+    @Test
     public void join_otDaemonRemoteFailure_returnsInternalError() throws Exception {
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
@@ -437,100 +477,6 @@
         assertThat(failure.getErrorCode()).isEqualTo(ERROR_FAILED_PRECONDITION);
     }
 
-    @Test
-    public void airplaneMode_initWithAirplaneModeOn_otDaemonNotStarted() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-
-        mService.initialize();
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.isInitialized()).isFalse();
-    }
-
-    @Test
-    public void airplaneMode_initWithAirplaneModeOff_threadIsEnabled() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-
-        mService.initialize();
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_ENABLED);
-    }
-
-    @Test
-    public void airplaneMode_changesFromOffToOn_stateIsDisabled() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-        AtomicReference<BroadcastReceiver> receiverRef =
-                captureBroadcastReceiver(Intent.ACTION_AIRPLANE_MODE_CHANGED);
-        mService.initialize();
-        mTestLooper.dispatchAll();
-
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_DISABLED);
-    }
-
-    @Test
-    public void airplaneMode_changesFromOnToOff_stateIsEnabled() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        AtomicReference<BroadcastReceiver> receiverRef =
-                captureBroadcastReceiver(Intent.ACTION_AIRPLANE_MODE_CHANGED);
-        mService.initialize();
-        mTestLooper.dispatchAll();
-
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_ENABLED);
-    }
-
-    @Test
-    public void airplaneMode_setEnabledWhenAirplaneModeIsOn_WillNotAutoDisableSecondTime() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        AtomicReference<BroadcastReceiver> receiverRef =
-                captureBroadcastReceiver(Intent.ACTION_AIRPLANE_MODE_CHANGED);
-        CompletableFuture<Void> setEnabledFuture = new CompletableFuture<>();
-        mService.initialize();
-
-        mService.setEnabled(true, newOperationReceiver(setEnabledFuture));
-        mTestLooper.dispatchAll();
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_ENABLED);
-        assertThat(mPersistentSettings.get(THREAD_ENABLED_IN_AIRPLANE_MODE)).isTrue();
-    }
-
-    @Test
-    public void airplaneMode_setDisabledWhenAirplaneModeIsOn_WillAutoDisableSecondTime() {
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        AtomicReference<BroadcastReceiver> receiverRef =
-                captureBroadcastReceiver(Intent.ACTION_AIRPLANE_MODE_CHANGED);
-        CompletableFuture<Void> setEnabledFuture = new CompletableFuture<>();
-        mService.initialize();
-        mService.setEnabled(true, newOperationReceiver(setEnabledFuture));
-        mTestLooper.dispatchAll();
-
-        mService.setEnabled(false, newOperationReceiver(setEnabledFuture));
-        mTestLooper.dispatchAll();
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-        Settings.Global.putInt(mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 1);
-        receiverRef.get().onReceive(mContext, new Intent());
-        mTestLooper.dispatchAll();
-
-        assertThat(mFakeOtDaemon.getEnabledState()).isEqualTo(STATE_DISABLED);
-        assertThat(mPersistentSettings.get(THREAD_ENABLED_IN_AIRPLANE_MODE)).isFalse();
-    }
-
     private AtomicReference<BroadcastReceiver> captureBroadcastReceiver(String action) {
         AtomicReference<BroadcastReceiver> receiverRef = new AtomicReference<>();
 
@@ -542,7 +488,9 @@
                 .when(mContext)
                 .registerReceiver(
                         any(BroadcastReceiver.class),
-                        argThat(actualIntentFilter -> actualIntentFilter.hasAction(action)));
+                        argThat(actualIntentFilter -> actualIntentFilter.hasAction(action)),
+                        any(),
+                        any());
 
         return receiverRef;
     }