Merge changes I24b0bf5f,I950d6787 into main

* changes:
  Introduce dependencies object for EntitlementManager
  Improved Entitlement UI Launch for multiple admin users
diff --git a/TEST_MAPPING b/TEST_MAPPING
index 94adc5b..b773ed8 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -316,6 +316,14 @@
         }
       ]
     },
+    {
+      "name": "CtsHostsideNetworkTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "options": [
+        {
+          "exclude-annotation": "androidx.test.filters.RequiresDevice"
+        }
+      ]
+    },
     // Test with APK modules only, in cases where APEX is not supported, or the other modules
     // were simply not updated
     {
@@ -414,15 +422,6 @@
           "exclude-annotation": "androidx.test.filters.RequiresDevice"
         }
       ]
-    },
-    // TODO: upgrade to presubmit. Postsubmit on virtual devices to monitor flakiness only.
-    {
-      "name": "CtsHostsideNetworkTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
-      "options": [
-        {
-          "exclude-annotation": "androidx.test.filters.RequiresDevice"
-        }
-      ]
     }
   ],
   "imports": [
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 15ad226..5cf5528 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -204,6 +204,7 @@
     use_embedded_native_libs: true,
     privapp_allowlist: ":privapp_allowlist_com.android.tethering",
     apex_available: ["com.android.tethering"],
+    updatable: true,
 }
 
 android_app {
@@ -221,6 +222,7 @@
     lint: {
         error_checks: ["NewApi"],
     },
+    updatable: true,
 }
 
 sdk {
diff --git a/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java b/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
index 0df9047..af061e4 100644
--- a/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/30/com/android/networkstack/tethering/apishim/api30/BpfCoordinatorShimImpl.java
@@ -198,4 +198,13 @@
     public String toString() {
         return "Netd used";
     }
+
+    @Override
+    public int getLastMaxConnectionAndResetToCurrent() {
+        return 0;
+    }
+
+    @Override
+    public void clearConnectionCounters() {
+    }
 }
diff --git a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
index e6e99f4..b460f0d 100644
--- a/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
+++ b/Tethering/apishim/31/com/android/networkstack/tethering/apishim/api31/BpfCoordinatorShimImpl.java
@@ -19,6 +19,7 @@
 import static android.net.netstats.provider.NetworkStatsProvider.QUOTA_UNLIMITED;
 
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.TETHER_ACTIVE_SESSIONS_METRICS;
 
 import android.system.ErrnoException;
 import android.system.Os;
@@ -108,6 +109,22 @@
     // TODO: Add IPv6 rule count.
     private final SparseArray<Integer> mRule4CountOnUpstream = new SparseArray<>();
 
+    private final boolean mSupportActiveSessionsMetrics;
+    /**
+     * Tracks the current number of tethering connections and the maximum
+     * observed since the last metrics collection. Used to provide insights
+     * into the distribution of active tethering sessions for metrics reporting.
+
+     * These variables are accessed on the handler thread, which includes:
+     *  1. ConntrackEvents signaling the addition or removal of an IPv4 rule.
+     *  2. ConntrackEvents indicating the removal of a tethering client,
+     *     triggering the removal of associated rules.
+     *  3. Removal of the last IpServer, which resets counters to handle
+     *     potential synchronization issues.
+     */
+    private int mLastMaxConnectionCount = 0;
+    private int mCurrentConnectionCount = 0;
+
     public BpfCoordinatorShimImpl(@NonNull final Dependencies deps) {
         mLog = deps.getSharedLog().forSubComponent(TAG);
 
@@ -156,6 +173,9 @@
         } catch (ErrnoException e) {
             mLog.e("Could not clear mBpfDevMap: " + e);
         }
+
+        mSupportActiveSessionsMetrics = deps.isFeatureEnabled(deps.getContext(),
+                TETHER_ACTIVE_SESSIONS_METRICS);
     }
 
     @Override
@@ -350,6 +370,12 @@
                 final int upstreamIfindex = (int) key.iif;
                 int count = mRule4CountOnUpstream.get(upstreamIfindex, 0 /* default */);
                 mRule4CountOnUpstream.put(upstreamIfindex, ++count);
+
+                if (mSupportActiveSessionsMetrics) {
+                    mCurrentConnectionCount++;
+                    mLastMaxConnectionCount = Math.max(mCurrentConnectionCount,
+                            mLastMaxConnectionCount);
+                }
             } else {
                 mBpfUpstream4Map.insertEntry(key, value);
             }
@@ -385,6 +411,10 @@
                 } else {
                     mRule4CountOnUpstream.put(upstreamIfindex, count);
                 }
+
+                if (mSupportActiveSessionsMetrics) {
+                    mCurrentConnectionCount--;
+                }
             } else {
                 if (!mBpfUpstream4Map.deleteEntry(key)) return false;  // Rule did not exist
             }
@@ -465,14 +495,16 @@
 
     @Override
     public String toString() {
-        return String.join(", ", new String[] {
-                mapStatus(mBpfDownstream6Map, "mBpfDownstream6Map"),
-                mapStatus(mBpfUpstream6Map, "mBpfUpstream6Map"),
-                mapStatus(mBpfDownstream4Map, "mBpfDownstream4Map"),
-                mapStatus(mBpfUpstream4Map, "mBpfUpstream4Map"),
-                mapStatus(mBpfStatsMap, "mBpfStatsMap"),
-                mapStatus(mBpfLimitMap, "mBpfLimitMap"),
-                mapStatus(mBpfDevMap, "mBpfDevMap")
+        return String.join(", ", new String[]{
+            mapStatus(mBpfDownstream6Map, "mBpfDownstream6Map"),
+            mapStatus(mBpfUpstream6Map, "mBpfUpstream6Map"),
+            mapStatus(mBpfDownstream4Map, "mBpfDownstream4Map"),
+            mapStatus(mBpfUpstream4Map, "mBpfUpstream4Map"),
+            mapStatus(mBpfStatsMap, "mBpfStatsMap"),
+            mapStatus(mBpfLimitMap, "mBpfLimitMap"),
+            mapStatus(mBpfDevMap, "mBpfDevMap"),
+            "mCurrentConnectionCount=" + mCurrentConnectionCount,
+            "mLastMaxConnectionCount=" + mLastMaxConnectionCount
         });
     }
 
@@ -507,4 +539,17 @@
 
         return 0;
     }
+
+    /** Get last max connection count and reset to current count. */
+    public int getLastMaxConnectionAndResetToCurrent() {
+        final int ret = mLastMaxConnectionCount;
+        mLastMaxConnectionCount = mCurrentConnectionCount;
+        return ret;
+    }
+
+    /** Clear current connection count. */
+    public void clearConnectionCounters() {
+        mCurrentConnectionCount = 0;
+        mLastMaxConnectionCount = 0;
+    }
 }
diff --git a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
index 026b1c3..cb8bcc9 100644
--- a/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
+++ b/Tethering/apishim/common/com/android/networkstack/tethering/apishim/common/BpfCoordinatorShim.java
@@ -202,5 +202,11 @@
      * Remove interface index mapping.
      */
     public abstract boolean removeDevMap(int ifIndex);
+
+    /** Get last max connection count and reset to current count. */
+    public abstract int getLastMaxConnectionAndResetToCurrent();
+
+    /** Clear current connection count. */
+    public abstract void clearConnectionCounters();
 }
 
diff --git a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
index 5aca642..411971d 100644
--- a/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
+++ b/Tethering/common/TetheringLib/src/android/net/TetheringManager.java
@@ -430,7 +430,7 @@
     // Used to dispatch legacy ConnectivityManager methods that expect tethering to be able to
     // return results and perform operations synchronously.
     // TODO: remove once there are no callers of these legacy methods.
-    private class RequestDispatcher {
+    private static class RequestDispatcher {
         private final ConditionVariable mWaiting;
         public volatile int mRemoteResult;
 
@@ -446,8 +446,8 @@
             mWaiting = new ConditionVariable();
         }
 
-        int waitForResult(final RequestHelper request) {
-            getConnector(c -> request.runRequest(c, mListener));
+        int waitForResult(final RequestHelper request, final TetheringManager mgr) {
+            mgr.getConnector(c -> request.runRequest(c, mListener));
             if (!mWaiting.block(DEFAULT_TIMEOUT_MS)) {
                 throw new IllegalStateException("Callback timeout");
             }
@@ -603,7 +603,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
@@ -635,7 +635,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
@@ -663,7 +663,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 
     /**
@@ -1751,7 +1751,7 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
 
         return ret == TETHER_ERROR_NO_ERROR;
     }
@@ -1800,6 +1800,6 @@
             } catch (RemoteException e) {
                 throw new IllegalStateException(e);
             }
-        });
+        }, this);
     }
 }
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index b807544..a0604f2 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -70,13 +70,13 @@
 import com.android.internal.util.State;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.IIpv4PrefixRequest;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.SyncStateMachine.StateInfo;
 import com.android.net.module.util.ip.InterfaceController;
 import com.android.networkstack.tethering.BpfCoordinator;
-import com.android.networkstack.tethering.PrivateAddressCoordinator;
 import com.android.networkstack.tethering.TetheringConfiguration;
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
@@ -124,6 +124,8 @@
     // TODO: have PanService use some visible version of this constant
     private static final String BLUETOOTH_IFACE_ADDR = "192.168.44.1/24";
 
+    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
+
     // TODO: have this configurable
     private static final int DHCP_LEASE_TIME_SECS = 3600;
 
@@ -240,15 +242,17 @@
     private final BpfCoordinator mBpfCoordinator;
     @NonNull
     private final RoutingCoordinatorManager mRoutingCoordinator;
+    @NonNull
+    private final IIpv4PrefixRequest mIpv4PrefixRequest;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
-    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
 
     private final String mIfaceName;
     private final int mInterfaceType;
     private final LinkProperties mLinkProperties;
     private final boolean mUsingLegacyDhcp;
     private final int mP2pLeasesSubnetPrefixLength;
+    private final boolean mIsWifiP2pDedicatedIpEnabled;
 
     private final Dependencies mDeps;
 
@@ -298,7 +302,7 @@
             String ifaceName, Handler handler, int interfaceType, SharedLog log,
             INetd netd, @NonNull BpfCoordinator bpfCoordinator,
             RoutingCoordinatorManager routingCoordinatorManager, Callback callback,
-            TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
+            TetheringConfiguration config,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
         super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
         mHandler = handler;
@@ -306,6 +310,12 @@
         mNetd = netd;
         mBpfCoordinator = bpfCoordinator;
         mRoutingCoordinator = routingCoordinatorManager;
+        mIpv4PrefixRequest = new IIpv4PrefixRequest.Stub() {
+            @Override
+            public void onIpv4PrefixConflict(IpPrefix ipPrefix) throws RemoteException {
+                sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+            }
+        };
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -313,7 +323,7 @@
         mLinkProperties = new LinkProperties();
         mUsingLegacyDhcp = config.useLegacyDhcpServer();
         mP2pLeasesSubnetPrefixLength = config.getP2pLeasesSubnetPrefixLength();
-        mPrivateAddressCoordinator = addressCoordinator;
+        mIsWifiP2pDedicatedIpEnabled = config.shouldEnableWifiP2pDedicatedIp();
         mDeps = deps;
         mTetheringMetrics = tetheringMetrics;
         resetLinkProperties();
@@ -391,6 +401,11 @@
         return mInterfaceParams;
     }
 
+    @VisibleForTesting
+    public IIpv4PrefixRequest getIpv4PrefixRequest() {
+        return mIpv4PrefixRequest;
+    }
+
     /**
      * Get the latest list of DHCP leases that was reported. Must be called on the IpServer looper
      * thread.
@@ -639,7 +654,7 @@
         // NOTE: All of configureIPv4() will be refactored out of existence
         // into calls to InterfaceController, shared with startIPv4().
         mInterfaceCtrl.clearIPv4Address();
-        mPrivateAddressCoordinator.releaseDownstream(this);
+        mRoutingCoordinator.releaseDownstream(mIpv4PrefixRequest);
         mBpfCoordinator.tetherOffloadClientClear(this);
         mIpv4Address = null;
         mStaticIpv4ServerAddr = null;
@@ -698,12 +713,24 @@
         return (mInterfaceType == TetheringManager.TETHERING_BLUETOOTH) && !SdkLevel.isAtLeastT();
     }
 
+    private boolean shouldUseWifiP2pDedicatedIp() {
+        return mIsWifiP2pDedicatedIpEnabled
+                && mInterfaceType == TetheringManager.TETHERING_WIFI_P2P;
+    }
+
     private LinkAddress requestIpv4Address(final int scope, final boolean useLastAddress) {
         if (mStaticIpv4ServerAddr != null) return mStaticIpv4ServerAddr;
 
         if (shouldNotConfigureBluetoothInterface()) return new LinkAddress(BLUETOOTH_IFACE_ADDR);
 
-        return mPrivateAddressCoordinator.requestDownstreamAddress(this, scope, useLastAddress);
+        if (shouldUseWifiP2pDedicatedIp()) return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
+
+        if (useLastAddress) {
+            return mRoutingCoordinator.requestStickyDownstreamAddress(mInterfaceType, scope,
+                    mIpv4PrefixRequest);
+        }
+
+        return mRoutingCoordinator.requestDownstreamAddress(mIpv4PrefixRequest);
     }
 
     private boolean startIPv6() {
@@ -1148,6 +1175,7 @@
                 case CMD_SERVICE_FAILED_TO_START:
                     mLog.e("start serving fail, error: " + message.arg1);
                     transitionTo(mInitialState);
+                    break;
                 default:
                     return false;
             }
@@ -1393,8 +1421,28 @@
         @Override
         public void enter() {
             mLastError = TETHER_ERROR_NO_ERROR;
+            // TODO: clean this up after the synchronous state machine is fully rolled out. Clean up
+            // can be directly triggered after calling IpServer.stop() inside Tethering.java.
             sendInterfaceState(STATE_UNAVAILABLE);
         }
+
+        @Override
+        public boolean processMessage(Message message) {
+            switch (message.what) {
+                case CMD_IPV6_TETHER_UPDATE:
+                    // sendInterfaceState(STATE_UNAVAILABLE) triggers
+                    // handleInterfaceServingStateInactive which in turn cleans up IPv6 tethering
+                    // (and calls into IpServer one more time). At this point, this is the only
+                    // message we potentially see in this state because this IpServer has already
+                    // been removed from mTetherStates before transitioning to this State; however,
+                    // handleInterfaceServiceStateInactive passes a reference.
+                    // TODO: This can be removed once SyncStateMachine is rolled out and the
+                    // teardown path is cleaned up.
+                    return true;
+                default:
+                    return false;
+            }
+        }
     }
 
     class WaitingForRestartState extends State {
diff --git a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
index 89e06da..75ab9ec 100644
--- a/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
+++ b/Tethering/src/com/android/networkstack/tethering/BpfCoordinator.java
@@ -27,6 +27,7 @@
 import static android.system.OsConstants.ETH_P_IP;
 import static android.system.OsConstants.ETH_P_IPV6;
 
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 import static com.android.net.module.util.NetworkStackConstants.IPV4_MIN_MTU;
 import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN;
 import static com.android.net.module.util.ip.ConntrackMonitor.ConntrackEvent;
@@ -334,7 +335,6 @@
     };
 
     // TODO: add BpfMap<TetherDownstream64Key, TetherDownstream64Value> retrieving function.
-    @VisibleForTesting
     public abstract static class Dependencies {
         /** Get handler. */
         @NonNull public abstract Handler getHandler();
@@ -585,14 +585,10 @@
             if (mHandler.hasCallbacks(mScheduledConntrackMetricsSampling)) {
                 mHandler.removeCallbacks(mScheduledConntrackMetricsSampling);
             }
-            final int currentCount = mBpfConntrackEventConsumer.getCurrentConnectionCount();
-            if (currentCount != 0) {
-                Log.wtf(TAG, "Unexpected CurrentConnectionCount: " + currentCount);
-            }
             // Avoid sending metrics when tethering is about to close.
             // This leads to a missing final sample before disconnect
             // but avoids possibly duplicating the last metric in the upload.
-            mBpfConntrackEventConsumer.clearConnectionCounters();
+            mBpfCoordinatorShim.clearConnectionCounters();
         }
         // Stop scheduled polling stats and poll the latest stats from BPF maps.
         if (mHandler.hasCallbacks(mScheduledPollingStats)) {
@@ -1091,10 +1087,6 @@
         for (final Tether4Key k : deleteDownstreamRuleKeys) {
             mBpfCoordinatorShim.tetherOffloadRuleRemove(DOWNSTREAM, k);
         }
-        if (mSupportActiveSessionsMetrics) {
-            mBpfConntrackEventConsumer.decreaseCurrentConnectionCount(
-                    deleteUpstreamRuleKeys.size());
-        }
 
         // Cleanup each upstream interface by a set which avoids duplicated work on the same
         // upstream interface. Cleaning up the same interface twice (or more) here may raise
@@ -1367,10 +1359,6 @@
 
         pw.println();
         pw.println("mSupportActiveSessionsMetrics: " + mSupportActiveSessionsMetrics);
-        pw.println("getLastMaxConnectionCount: "
-                + mBpfConntrackEventConsumer.getLastMaxConnectionCount());
-        pw.println("getCurrentConnectionCount: "
-                + mBpfConntrackEventConsumer.getCurrentConnectionCount());
     }
 
     private void dumpStats(@NonNull IndentingPrintWriter pw) {
@@ -2062,21 +2050,6 @@
     // while TCP status is established.
     @VisibleForTesting
     class BpfConntrackEventConsumer implements ConntrackEventConsumer {
-        /**
-         * Tracks the current number of tethering connections and the maximum
-         * observed since the last metrics collection. Used to provide insights
-         * into the distribution of active tethering sessions for metrics reporting.
-
-         * These variables are accessed on the handler thread, which includes:
-         *  1. ConntrackEvents signaling the addition or removal of an IPv4 rule.
-         *  2. ConntrackEvents indicating the removal of a tethering client,
-         *     triggering the removal of associated rules.
-         *  3. Removal of the last IpServer, which resets counters to handle
-         *     potential synchronization issues.
-         */
-        private int mLastMaxConnectionCount = 0;
-        private int mCurrentConnectionCount = 0;
-
         // The upstream4 and downstream4 rules are built as the following tables. Only raw ip
         // upstream interface is supported. Note that the field "lastUsed" is only updated by
         // BPF program which records the last used time for a given rule.
@@ -2210,10 +2183,6 @@
                     return;
                 }
 
-                if (mSupportActiveSessionsMetrics) {
-                    decreaseCurrentConnectionCount(1);
-                }
-
                 maybeClearLimit(upstreamIndex);
                 return;
             }
@@ -2237,40 +2206,12 @@
                         + ", downstream: " + addedDownstream + ")");
                 return;
             }
-            if (mSupportActiveSessionsMetrics && addedUpstream && addedDownstream) {
-                mCurrentConnectionCount++;
-                mLastMaxConnectionCount = Math.max(mCurrentConnectionCount,
-                        mLastMaxConnectionCount);
-            }
         }
+    }
 
-        public int getLastMaxConnectionAndResetToCurrent() {
-            final int ret = mLastMaxConnectionCount;
-            mLastMaxConnectionCount = mCurrentConnectionCount;
-            return ret;
-        }
-
-        /** For dumping current state only. */
-        public int getLastMaxConnectionCount() {
-            return mLastMaxConnectionCount;
-        }
-
-        public int getCurrentConnectionCount() {
-            return mCurrentConnectionCount;
-        }
-
-        public void decreaseCurrentConnectionCount(int count) {
-            mCurrentConnectionCount -= count;
-            if (mCurrentConnectionCount < 0) {
-                Log.wtf(TAG, "Unexpected mCurrentConnectionCount: "
-                        + mCurrentConnectionCount);
-            }
-        }
-
-        public void clearConnectionCounters() {
-            mCurrentConnectionCount = 0;
-            mLastMaxConnectionCount = 0;
-        }
+    @VisibleForTesting(visibility = PRIVATE)
+    public int getLastMaxConnectionAndResetToCurrent() {
+        return mBpfCoordinatorShim.getLastMaxConnectionAndResetToCurrent();
     }
 
     @VisibleForTesting
@@ -2611,7 +2552,7 @@
 
     private void uploadConntrackMetricsSample() {
         mDeps.sendTetheringActiveSessionsReported(
-                mBpfConntrackEventConsumer.getLastMaxConnectionAndResetToCurrent());
+                mBpfCoordinatorShim.getLastMaxConnectionAndResetToCurrent());
     }
 
     private void schedulePollingStats() {
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 49bc86e..df255f3 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -253,7 +253,6 @@
     private final TetheringNotificationUpdater mNotificationUpdater;
     private final UserManager mUserManager;
     private final BpfCoordinator mBpfCoordinator;
-    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
     private final TetheringMetrics mTetheringMetrics;
     private final WearableConnectionManager mWearableConnectionManager;
     private int mActiveDataSubId = INVALID_SUBSCRIPTION_ID;
@@ -359,10 +358,6 @@
         // Load tethering configuration.
         updateConfiguration();
         mConfig.readEnableSyncSM(mContext);
-        // It is OK for the configuration to be passed to the PrivateAddressCoordinator at
-        // construction time because the only part of the configuration it uses is
-        // shouldEnableWifiP2pDedicatedIp(), and currently do not support changing that.
-        mPrivateAddressCoordinator = mDeps.makePrivateAddressCoordinator(mContext, mConfig);
 
         // Must be initialized after tethering configuration is loaded because BpfCoordinator
         // constructor needs to use the configuration.
@@ -2004,11 +1999,11 @@
             final UpstreamNetworkState ns = (UpstreamNetworkState) o;
             switch (arg1) {
                 case UpstreamNetworkMonitor.EVENT_ON_LINKPROPERTIES:
-                    mPrivateAddressCoordinator.updateUpstreamPrefix(
+                    mRoutingCoordinator.updateUpstreamPrefix(
                             ns.linkProperties, ns.networkCapabilities, ns.network);
                     break;
                 case UpstreamNetworkMonitor.EVENT_ON_LOST:
-                    mPrivateAddressCoordinator.removeUpstreamPrefix(ns.network);
+                    mRoutingCoordinator.removeUpstreamPrefix(ns.network);
                     break;
             }
 
@@ -2078,7 +2073,7 @@
                     return;
                 }
 
-                mPrivateAddressCoordinator.maybeRemoveDeprecatedUpstreams();
+                mRoutingCoordinator.maybeRemoveDeprecatedUpstreams();
                 mUpstreamNetworkMonitor.startObserveAllNetworks();
 
                 // TODO: De-duplicate with updateUpstreamWanted() below.
@@ -2666,11 +2661,6 @@
 
         dumpBpf(pw);
 
-        pw.println("Private address coordinator:");
-        pw.increaseIndent();
-        mPrivateAddressCoordinator.dump(pw);
-        pw.decreaseIndent();
-
         if (mWearableConnectionManager != null) {
             pw.println("WearableConnectionManager:");
             pw.increaseIndent();
@@ -2824,8 +2814,7 @@
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
                 new IpServer(iface, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
-                        mRoutingCoordinator, new ControlCallback(), mConfig,
-                        mPrivateAddressCoordinator, mTetheringMetrics,
+                        mRoutingCoordinator, new ControlCallback(), mConfig, mTetheringMetrics,
                         mDeps.makeIpServerDependencies()), isNcm);
         mTetherStates.put(iface, tetherState);
         tetherState.ipServer.start();
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
index c9817c9..b3e9c1b 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -182,7 +182,6 @@
     private final int mP2pLeasesSubnetPrefixLength;
 
     private final boolean mEnableWearTethering;
-    private final boolean mRandomPrefixBase;
 
     private final int mUsbTetheringFunction;
     protected final ContentResolver mContentResolver;
@@ -300,8 +299,6 @@
 
         mEnableWearTethering = shouldEnableWearTethering(ctx);
 
-        mRandomPrefixBase = mDeps.isFeatureEnabled(ctx, TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION);
-
         configLog.log(toString());
     }
 
@@ -390,10 +387,6 @@
         return mEnableWearTethering;
     }
 
-    public boolean isRandomPrefixBaseEnabled() {
-        return mRandomPrefixBase;
-    }
-
     /**
      * Check whether sync SM is enabled then set it to USE_SYNC_SM. This should be called once
      * when tethering is created. Otherwise if the flag is pushed while tethering is enabled,
@@ -455,9 +448,6 @@
         pw.print("mUsbTetheringFunction: ");
         pw.println(isUsingNcm() ? "NCM" : "RNDIS");
 
-        pw.print("mRandomPrefixBase: ");
-        pw.println(mRandomPrefixBase);
-
         pw.print("USE_SYNC_SM: ");
         pw.println(USE_SYNC_SM);
     }
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 81f057c..a4823ca 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -36,6 +36,7 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.PrivateAddressCoordinator;
 import com.android.net.module.util.RoutingCoordinatorManager;
 import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
@@ -136,7 +137,10 @@
     public RoutingCoordinatorManager getRoutingCoordinator(Context context, SharedLog log) {
         IBinder binder;
         if (!SdkLevel.isAtLeastS()) {
-            binder = new RoutingCoordinatorService(getINetd(context, log));
+            final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+            binder =
+                    new RoutingCoordinatorService(
+                            getINetd(context, log), cm::getAllNetworks, context);
         } else {
             binder = ConnectivityInternalApiUtil.getRoutingCoordinator(context);
         }
@@ -175,18 +179,6 @@
     }
 
     /**
-     * Make PrivateAddressCoordinator to be used by Tethering.
-     */
-    public PrivateAddressCoordinator makePrivateAddressCoordinator(
-            Context ctx, TetheringConfiguration cfg) {
-        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
-        return new PrivateAddressCoordinator(
-                cm::getAllNetworks,
-                cfg.isRandomPrefixBaseEnabled(),
-                cfg.shouldEnableWifiP2pDedicatedIp());
-    }
-
-    /**
      * Make BluetoothPanShim object to enable/disable bluetooth tethering.
      *
      * TODO: use BluetoothPan directly when mainline module is built with API 32.
diff --git a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
index fc50faf..087ce44 100644
--- a/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
+++ b/Tethering/src/com/android/networkstack/tethering/metrics/TetheringMetrics.java
@@ -62,7 +62,6 @@
 import android.net.NetworkTemplate;
 import android.os.Handler;
 import android.os.HandlerThread;
-import android.os.Looper;
 import android.stats.connectivity.DownstreamType;
 import android.stats.connectivity.ErrorCode;
 import android.stats.connectivity.UpstreamType;
@@ -76,6 +75,7 @@
 
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.DeviceConfigUtils;
+import com.android.net.module.util.HandlerUtils;
 import com.android.networkstack.tethering.UpstreamNetworkState;
 
 import java.util.ArrayList;
@@ -111,7 +111,11 @@
     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 ArrayMap<UpstreamType, DataUsage> mUpstreamUsageBaseline = new ArrayMap<>();
+    // Store the last reported data usage for each upstream type to be used for calculating the
+    // usage delta. The keys are the upstream types, and the values are the tethering UID data
+    // usage for the corresponding types. Retrieve the baseline data usage when tethering is
+    // enabled, update it when the upstream changes, and clear it when tethering is disabled.
+    private final ArrayMap<UpstreamType, DataUsage> mLastReportedUpstreamUsage = new ArrayMap<>();
     private final Context mContext;
     private final Dependencies mDependencies;
     private final NetworkStatsManager mNetworkStatsManager;
@@ -157,10 +161,15 @@
 
         /**
          * @see Handler
+         *
+         * Note: This should only be called once, within the constructor, as it creates a new
+         * thread. Calling it multiple times could lead to a thread leak.
          */
         @NonNull
-        public Handler createHandler(Looper looper) {
-            return new Handler(looper);
+        public Handler createHandler() {
+            final HandlerThread thread = new HandlerThread(TAG);
+            thread.start();
+            return new Handler(thread.getLooper());
         }
     }
 
@@ -177,9 +186,7 @@
         mContext = context;
         mDependencies = dependencies;
         mNetworkStatsManager = mContext.getSystemService(NetworkStatsManager.class);
-        final HandlerThread thread = new HandlerThread(TAG);
-        thread.start();
-        mHandler = dependencies.createHandler(thread.getLooper());
+        mHandler = dependencies.createHandler();
     }
 
     @VisibleForTesting
@@ -282,22 +289,33 @@
      * Calculates the data usage difference between the current and previous usage for the
      * specified upstream type.
      *
+     * Note: This must be called before updating mCurrentUpstream when changing the upstream.
+     *
      * @return A DataUsage object containing the calculated difference in transmitted (tx) and
      *         received (rx) bytes.
      */
     private DataUsage calculateDataUsageDelta(@Nullable UpstreamType upstream) {
-        if (upstream != null && mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
-                && isUsageSupportedForUpstreamType(upstream)) {
-            final DataUsage oldUsage = mUpstreamUsageBaseline.getOrDefault(upstream, EMPTY);
-            if (oldUsage.equals(EMPTY)) {
-                Log.d(TAG, "No usage baseline for the upstream=" + upstream);
-                return EMPTY;
-            }
-            // TODO(b/352537247): Fix data usage which might be incorrect if the device uses
-            //  tethering with the same upstream for over 15 days.
-            return DataUsage.subtract(getCurrentDataUsageForUpstreamType(upstream), oldUsage);
+        if (!mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)) {
+            return EMPTY;
         }
-        return EMPTY;
+
+        if (upstream == null || !isUsageSupportedForUpstreamType(upstream)) {
+            return EMPTY;
+        }
+
+        final DataUsage oldUsage = mLastReportedUpstreamUsage.getOrDefault(upstream, EMPTY);
+        if (oldUsage.equals(EMPTY)) {
+            Log.d(TAG, "No usage baseline for the upstream=" + upstream);
+            return EMPTY;
+        }
+        // TODO(b/370724247): Fix data usage which might be incorrect if the device uses
+        //  tethering with the same upstream for over 15 days.
+        // Need to refresh the baseline usage data. If the network switches back to Wi-Fi after
+        // using cellular data (Wi-Fi -> Cellular -> Wi-Fi), the old baseline might be
+        // inaccurate, leading to incorrect delta calculations.
+        final DataUsage newUsage = getCurrentDataUsageForUpstreamType(upstream);
+        mLastReportedUpstreamUsage.put(upstream, newUsage);
+        return DataUsage.subtract(newUsage, oldUsage);
     }
 
     /**
@@ -416,7 +434,6 @@
      * @param reported a NetworkTetheringReported object containing statistics to write
      */
     private void write(@NonNull final NetworkTetheringReported reported) {
-        final byte[] upstreamEvents = reported.getUpstreamEvents().toByteArray();
         mDependencies.write(reported);
         if (DBG) {
             Log.d(
@@ -430,7 +447,7 @@
                     + ", userType: "
                     + reported.getUserType().getNumber()
                     + ", upstreamTypes: "
-                    + Arrays.toString(upstreamEvents)
+                    + Arrays.toString(reported.getUpstreamEvents().toByteArray())
                     + ", durationMillis: "
                     + reported.getDurationMillis());
         }
@@ -444,25 +461,26 @@
     }
 
     private void handleInitUpstreamUsageBaseline() {
-        if (!(mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)
-                && mUpstreamUsageBaseline.isEmpty())) {
+        if (!mDependencies.isUpstreamDataUsageMetricsEnabled(mContext)) {
+            return;
+        }
+
+        if (!mLastReportedUpstreamUsage.isEmpty()) {
+            Log.wtf(TAG, "The upstream usage baseline has been initialed.");
             return;
         }
 
         for (UpstreamType type : UpstreamType.values()) {
             if (!isUsageSupportedForUpstreamType(type)) continue;
-            mUpstreamUsageBaseline.put(type, getCurrentDataUsageForUpstreamType(type));
+            mLastReportedUpstreamUsage.put(type, getCurrentDataUsageForUpstreamType(type));
         }
     }
 
     @VisibleForTesting
     @NonNull
-    DataUsage getDataUsageFromUpstreamType(@NonNull UpstreamType type) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-        return mUpstreamUsageBaseline.getOrDefault(type, EMPTY);
+    DataUsage getLastReportedUsageFromUpstreamType(@NonNull UpstreamType type) {
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
+        return mLastReportedUpstreamUsage.getOrDefault(type, EMPTY);
     }
 
 
@@ -497,7 +515,7 @@
         mUpstreamEventList.clear();
         mCurrentUpstream = null;
         mCurrentUpStreamStartTime = 0L;
-        mUpstreamUsageBaseline.clear();
+        mLastReportedUpstreamUsage.clear();
     }
 
     private DownstreamType downstreamTypeToEnum(final int ifaceType) {
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 423b9b8..01f3af9 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -70,7 +70,7 @@
 import com.android.net.module.util.structs.FragmentHeader;
 import com.android.net.module.util.structs.Ipv6Header;
 import com.android.testutils.HandlerUtils;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
@@ -158,10 +158,10 @@
     protected TetheredInterfaceRequester mTetheredInterfaceRequester;
 
     // Late initialization in initTetheringTester().
-    private TapPacketReader mUpstreamReader;
+    private PollPacketReader mUpstreamReader;
     private TestNetworkTracker mUpstreamTracker;
     private TestNetworkInterface mDownstreamIface;
-    private TapPacketReader mDownstreamReader;
+    private PollPacketReader mDownstreamReader;
     private MyTetheringEventCallback mTetheringEventCallback;
 
     public Context getContext() {
@@ -187,10 +187,10 @@
         return runAsShell(NETWORK_SETTINGS, TETHER_PRIVILEGED, () -> sTm.isTetheringSupported());
     }
 
-    protected void maybeStopTapPacketReader(final TapPacketReader tapPacketReader)
+    protected void maybeStopTapPacketReader(final PollPacketReader tapPacketReader)
             throws Exception {
         if (tapPacketReader != null) {
-            TapPacketReader reader = tapPacketReader;
+            PollPacketReader reader = tapPacketReader;
             mHandler.post(() -> reader.stop());
         }
     }
@@ -228,7 +228,7 @@
             });
         }
         if (mUpstreamReader != null) {
-            TapPacketReader reader = mUpstreamReader;
+            PollPacketReader reader = mUpstreamReader;
             mHandler.post(() -> reader.stop());
             mUpstreamReader = null;
         }
@@ -291,7 +291,7 @@
         });
     }
 
-    protected static void waitForRouterAdvertisement(TapPacketReader reader, String iface,
+    protected static void waitForRouterAdvertisement(PollPacketReader reader, String iface,
             long timeoutMs) {
         final long deadline = SystemClock.uptimeMillis() + timeoutMs;
         do {
@@ -574,13 +574,13 @@
         return nif.getIndex();
     }
 
-    protected TapPacketReader makePacketReader(final TestNetworkInterface iface) throws Exception {
+    protected PollPacketReader makePacketReader(final TestNetworkInterface iface) throws Exception {
         FileDescriptor fd = iface.getFileDescriptor().getFileDescriptor();
         return makePacketReader(fd, getMTU(iface));
     }
 
-    protected TapPacketReader makePacketReader(FileDescriptor fd, int mtu) {
-        final TapPacketReader reader = new TapPacketReader(mHandler, fd, mtu);
+    protected PollPacketReader makePacketReader(FileDescriptor fd, int mtu) {
+        final PollPacketReader reader = new PollPacketReader(mHandler, fd, mtu);
         mHandler.post(() -> reader.start());
         HandlerUtils.waitForIdle(mHandler, TIMEOUT_MS);
         return reader;
diff --git a/Tethering/tests/integration/base/android/net/TetheringTester.java b/Tethering/tests/integration/base/android/net/TetheringTester.java
index b152b4c..fb94eed 100644
--- a/Tethering/tests/integration/base/android/net/TetheringTester.java
+++ b/Tethering/tests/integration/base/android/net/TetheringTester.java
@@ -84,7 +84,7 @@
 import com.android.net.module.util.structs.RaHeader;
 import com.android.net.module.util.structs.TcpHeader;
 import com.android.net.module.util.structs.UdpHeader;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -157,14 +157,14 @@
     public static final String DHCP_HOSTNAME = "testhostname";
 
     private final ArrayMap<MacAddress, TetheredDevice> mTetheredDevices;
-    private final TapPacketReader mDownstreamReader;
-    private final TapPacketReader mUpstreamReader;
+    private final PollPacketReader mDownstreamReader;
+    private final PollPacketReader mUpstreamReader;
 
-    public TetheringTester(TapPacketReader downstream) {
+    public TetheringTester(PollPacketReader downstream) {
         this(downstream, null);
     }
 
-    public TetheringTester(TapPacketReader downstream, TapPacketReader upstream) {
+    public TetheringTester(PollPacketReader downstream, PollPacketReader upstream) {
         if (downstream == null) fail("Downstream reader could not be NULL");
 
         mDownstreamReader = downstream;
diff --git a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
index 32b2f3e..1bbea94 100644
--- a/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
+++ b/Tethering/tests/integration/src/android/net/EthernetTetheringTest.java
@@ -80,7 +80,7 @@
 import com.android.testutils.DeviceInfoUtils;
 import com.android.testutils.DumpTestUtils;
 import com.android.testutils.NetworkStackModuleTest;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import org.junit.After;
 import org.junit.Rule;
@@ -213,7 +213,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -253,7 +253,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -283,7 +283,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -357,7 +357,7 @@
 
         TestNetworkInterface downstreamIface = null;
         MyTetheringEventCallback tetheringEventCallback = null;
-        TapPacketReader downstreamReader = null;
+        PollPacketReader downstreamReader = null;
 
         try {
             downstreamIface = createTestInterface();
@@ -423,7 +423,7 @@
         // client, which is not possible in this test.
     }
 
-    private void checkTetheredClientCallbacks(final TapPacketReader packetReader,
+    private void checkTetheredClientCallbacks(final PollPacketReader packetReader,
             final MyTetheringEventCallback tetheringEventCallback) throws Exception {
         // Create a fake client.
         byte[] clientMacAddr = new byte[6];
diff --git a/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java b/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
index ebf09ed..0f3f5bb 100644
--- a/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/DadProxyTest.java
@@ -43,7 +43,7 @@
 import com.android.networkstack.tethering.util.TetheringUtils;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
 import com.android.testutils.DevSdkIgnoreRunner;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TapPacketReaderRule;
 
 import org.junit.After;
@@ -75,7 +75,7 @@
     private InterfaceParams mUpstreamParams, mTetheredParams;
     private HandlerThread mHandlerThread;
     private Handler mHandler;
-    private TapPacketReader mUpstreamPacketReader, mTetheredPacketReader;
+    private PollPacketReader mUpstreamPacketReader, mTetheredPacketReader;
 
     private static INetd sNetd;
 
@@ -219,7 +219,7 @@
     }
 
     // TODO: change to assert.
-    private boolean waitForPacket(ByteBuffer packet, TapPacketReader reader) {
+    private boolean waitForPacket(ByteBuffer packet, PollPacketReader reader) {
         byte[] p;
 
         while ((p = reader.popPacket(PACKET_TIMEOUT_MS)) != null) {
@@ -247,7 +247,7 @@
     }
 
     private void receivePacketAndMaybeExpectForwarded(boolean expectForwarded,
-            ByteBuffer in, TapPacketReader inReader, ByteBuffer out, TapPacketReader outReader)
+            ByteBuffer in, PollPacketReader inReader, ByteBuffer out, PollPacketReader outReader)
             throws IOException {
 
         inReader.sendResponse(in);
@@ -271,13 +271,13 @@
         assertEquals(msg, expectForwarded, waitForPacket(out, outReader));
     }
 
-    private void receivePacketAndExpectForwarded(ByteBuffer in, TapPacketReader inReader,
-            ByteBuffer out, TapPacketReader outReader) throws IOException {
+    private void receivePacketAndExpectForwarded(ByteBuffer in, PollPacketReader inReader,
+            ByteBuffer out, PollPacketReader outReader) throws IOException {
         receivePacketAndMaybeExpectForwarded(true, in, inReader, out, outReader);
     }
 
-    private void receivePacketAndExpectNotForwarded(ByteBuffer in, TapPacketReader inReader,
-            ByteBuffer out, TapPacketReader outReader) throws IOException {
+    private void receivePacketAndExpectNotForwarded(ByteBuffer in, PollPacketReader inReader,
+            ByteBuffer out, PollPacketReader outReader) throws IOException {
         receivePacketAndMaybeExpectForwarded(false, in, inReader, out, outReader);
     }
 
diff --git a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
index 90ceaa1..7cc8c74 100644
--- a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
@@ -64,7 +64,7 @@
 import com.android.net.module.util.structs.PrefixInformationOption;
 import com.android.net.module.util.structs.RaHeader;
 import com.android.net.module.util.structs.RdnssOption;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TapPacketReaderRule;
 
 import org.junit.After;
@@ -93,7 +93,7 @@
     private InterfaceParams mTetheredParams;
     private HandlerThread mHandlerThread;
     private Handler mHandler;
-    private TapPacketReader mTetheredPacketReader;
+    private PollPacketReader mTetheredPacketReader;
     private RouterAdvertisementDaemon mRaDaemon;
 
     private static INetd sNetd;
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 177296a..680e81d 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -89,13 +89,10 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.android.modules.utils.build.SdkLevel;
 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.networkstack.tethering.BpfCoordinator;
-import com.android.networkstack.tethering.PrivateAddressCoordinator;
 import com.android.networkstack.tethering.TetheringConfiguration;
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
@@ -139,6 +136,7 @@
     private static final boolean DEFAULT_USING_BPF_OFFLOAD = true;
     private static final int DEFAULT_SUBNET_PREFIX_LENGTH = 0;
     private static final int P2P_SUBNET_PREFIX_LENGTH = 25;
+    private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
 
     private static final InterfaceParams TEST_IFACE_PARAMS = new InterfaceParams(
             IFACE_NAME, 42 /* index */, MacAddress.ALL_ZEROS_ADDRESS, 1500 /* defaultMtu */);
@@ -174,7 +172,6 @@
     @Mock private DadProxy mDadProxy;
     @Mock private RouterAdvertisementDaemon mRaDaemon;
     @Mock private IpServer.Dependencies mDependencies;
-    @Mock private PrivateAddressCoordinator mAddressCoordinator;
     @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
@@ -196,6 +193,12 @@
 
     private void initStateMachine(int interfaceType, boolean usingLegacyDhcp,
             boolean usingBpfOffload) throws Exception {
+        initStateMachine(interfaceType, usingLegacyDhcp, usingBpfOffload,
+                false /* shouldEnableWifiP2pDedicatedIp */);
+    }
+
+    private void initStateMachine(int interfaceType, boolean usingLegacyDhcp,
+            boolean usingBpfOffload, boolean shouldEnableWifiP2pDedicatedIp) throws Exception {
         when(mDependencies.getDadProxy(any(), any())).thenReturn(mDadProxy);
         when(mDependencies.getRouterAdvertisementDaemon(any())).thenReturn(mRaDaemon);
         when(mDependencies.getInterfaceParams(IFACE_NAME)).thenReturn(TEST_IFACE_PARAMS);
@@ -213,6 +216,8 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(usingBpfOffload);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(usingLegacyDhcp);
         when(mTetherConfig.getP2pLeasesSubnetPrefixLength()).thenReturn(P2P_SUBNET_PREFIX_LENGTH);
+        when(mTetherConfig.shouldEnableWifiP2pDedicatedIp())
+                .thenReturn(shouldEnableWifiP2pDedicatedIp);
         when(mBpfCoordinator.isUsingBpfOffload()).thenReturn(usingBpfOffload);
         mIpServer = createIpServer(interfaceType);
         mIpServer.start();
@@ -252,9 +257,9 @@
             verify(mBpfCoordinator).updateIpv6UpstreamInterface(
                     mIpServer, interfaceParams.index, upstreamPrefixes);
         }
-        reset(mNetd, mBpfCoordinator, mCallback, mAddressCoordinator);
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(mTestAddress);
+        reset(mNetd, mBpfCoordinator, mCallback, mRoutingCoordinatorManager);
+        when(mRoutingCoordinatorManager.requestStickyDownstreamAddress(anyInt(), anyInt(),
+                any())).thenReturn(mTestAddress);
     }
 
     @SuppressWarnings("DoNotCall") // Ignore warning for synchronous to call to Thread.run()
@@ -275,8 +280,9 @@
     @Before public void setUp() throws Exception {
         MockitoAnnotations.initMocks(this);
         when(mSharedLog.forSubComponent(anyString())).thenReturn(mSharedLog);
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(mTestAddress);
+        when(mRoutingCoordinatorManager.requestStickyDownstreamAddress(anyInt(), anyInt(),
+                any())).thenReturn(mTestAddress);
+        when(mRoutingCoordinatorManager.requestDownstreamAddress(any())).thenReturn(mTestAddress);
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(DEFAULT_USING_BPF_OFFLOAD);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(false /* default value */);
 
@@ -288,7 +294,7 @@
         mLooper = new TestLooper();
         mHandler = new Handler(mLooper.getLooper());
         return new IpServer(IFACE_NAME, mHandler, interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mRoutingCoordinatorManager, mCallback, mTetherConfig, mAddressCoordinator,
+                mRoutingCoordinatorManager, mCallback, mTetherConfig,
                 mTetheringMetrics, mDependencies);
 
     }
@@ -340,10 +346,14 @@
         initStateMachine(TETHERING_BLUETOOTH);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         if (isAtLeastT()) {
-            inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                    eq(CONNECTIVITY_SCOPE_GLOBAL), eq(true));
+            inOrder.verify(mRoutingCoordinatorManager)
+                    .requestStickyDownstreamAddress(
+                            eq(TETHERING_BLUETOOTH),
+                            eq(CONNECTIVITY_SCOPE_GLOBAL),
+                            any());
+            inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
             inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                     IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         }
@@ -364,7 +374,7 @@
         initTetheredStateMachine(TETHERING_BLUETOOTH, null);
 
         dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mNetd).tetherInterfaceRemove(IFACE_NAME);
         inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
@@ -375,7 +385,7 @@
                     argThat(cfg -> assertContainsFlag(cfg.flags, IF_STATE_DOWN)));
         }
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg -> cfg.flags.length == 0));
-        inOrder.verify(mAddressCoordinator).releaseDownstream(any());
+        inOrder.verify(mRoutingCoordinatorManager).releaseDownstream(any());
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_AVAILABLE, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
@@ -383,7 +393,7 @@
         verify(mTetheringMetrics).updateErrorCode(eq(TETHERING_BLUETOOTH),
                 eq(TETHER_ERROR_NO_ERROR));
         verify(mTetheringMetrics).sendReport(eq(TETHERING_BLUETOOTH));
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
@@ -391,9 +401,10 @@
         initStateMachine(TETHERING_USB);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_TETHERED);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_GLOBAL), eq(true));
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_GLOBAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                 IFACE_NAME.equals(cfg.ifName) && assertContainsFlag(cfg.flags, IF_STATE_UP)));
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
@@ -405,17 +416,18 @@
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), mLinkPropertiesCaptor.capture());
         assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
-    public void canBeTetheredAsWifiP2p() throws Exception {
+    public void canBeTetheredAsWifiP2p_NotUsingDedicatedIp() throws Exception {
         initStateMachine(TETHERING_WIFI_P2P);
 
         dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
-        InOrder inOrder = inOrder(mCallback, mNetd, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true));
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_LOCAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
                   IFACE_NAME.equals(cfg.ifName) && assertNotContainsFlag(cfg.flags, IF_STATE_UP)));
         inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
@@ -427,7 +439,35 @@
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), mLinkPropertiesCaptor.capture());
         assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
-        verifyNoMoreInteractions(mNetd, mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
+    }
+
+    @Test
+    public void canBeTetheredAsWifiP2p_UsingDedicatedIp() throws Exception {
+        initStateMachine(TETHERING_WIFI_P2P, false /* usingLegacyDhcp */, DEFAULT_USING_BPF_OFFLOAD,
+                true /* shouldEnableWifiP2pDedicatedIp */);
+
+        dispatchCommand(IpServer.CMD_TETHER_REQUESTED, STATE_LOCAL_ONLY);
+        InOrder inOrder = inOrder(mCallback, mNetd, mRoutingCoordinatorManager);
+        // When using WiFi P2p dedicated IP, the IpServer just picks the IP address without
+        // requesting for it at RoutingCoordinatorManager.
+        inOrder.verify(mRoutingCoordinatorManager, never())
+                .requestStickyDownstreamAddress(anyInt(), anyInt(), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
+        inOrder.verify(mNetd).interfaceSetCfg(argThat(cfg ->
+                IFACE_NAME.equals(cfg.ifName) && assertNotContainsFlag(cfg.flags, IF_STATE_UP)));
+        inOrder.verify(mNetd).tetherInterfaceAdd(IFACE_NAME);
+        inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
+        inOrder.verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME),
+                any(), any());
+        inOrder.verify(mCallback).updateInterfaceState(
+                mIpServer, STATE_LOCAL_ONLY, TETHER_ERROR_NO_ERROR);
+        inOrder.verify(mCallback).updateLinkProperties(
+                eq(mIpServer), mLinkPropertiesCaptor.capture());
+        assertIPv4AddressAndDirectlyConnectedRoute(mLinkPropertiesCaptor.getValue());
+        assertEquals(List.of(new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS)),
+                mLinkPropertiesCaptor.getValue().getLinkAddresses());
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager);
     }
 
     @Test
@@ -533,15 +573,9 @@
         initTetheredStateMachine(TETHERING_BLUETOOTH, UPSTREAM_IFACE);
 
         clearInvocations(
-                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
+                mNetd, mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
         dispatchCommand(IpServer.CMD_TETHER_UNREQUESTED);
-        InOrder inOrder =
-                inOrder(
-                        mNetd,
-                        mCallback,
-                        mAddressCoordinator,
-                        mBpfCoordinator,
-                        mRoutingCoordinatorManager);
+        InOrder inOrder = inOrder(mNetd, mCallback, mBpfCoordinator, mRoutingCoordinatorManager);
         inOrder.verify(mBpfCoordinator).maybeDetachProgram(IFACE_NAME, UPSTREAM_IFACE);
         inOrder.verify(mRoutingCoordinatorManager)
                 .removeInterfaceForward(IFACE_NAME, UPSTREAM_IFACE);
@@ -556,15 +590,14 @@
         inOrder.verify(mNetd).networkRemoveInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
         inOrder.verify(mNetd, times(isAtLeastT() ? 2 : 1)).interfaceSetCfg(
                 argThat(cfg -> IFACE_NAME.equals(cfg.ifName)));
-        inOrder.verify(mAddressCoordinator).releaseDownstream(any());
+        inOrder.verify(mRoutingCoordinatorManager).releaseDownstream(any());
         inOrder.verify(mBpfCoordinator).tetherOffloadClientClear(mIpServer);
         inOrder.verify(mBpfCoordinator).removeIpServer(mIpServer);
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_AVAILABLE, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(
                 eq(mIpServer), any(LinkProperties.class));
-        verifyNoMoreInteractions(
-                mNetd, mCallback, mAddressCoordinator, mBpfCoordinator, mRoutingCoordinatorManager);
+        verifyNoMoreInteractions(mNetd, mCallback, mRoutingCoordinatorManager, mBpfCoordinator);
     }
 
     @Test
@@ -701,9 +734,10 @@
 
         final ArgumentCaptor<LinkProperties> lpCaptor =
                 ArgumentCaptor.forClass(LinkProperties.class);
-        InOrder inOrder = inOrder(mNetd, mCallback, mAddressCoordinator);
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(true));
+        InOrder inOrder = inOrder(mNetd, mCallback, mRoutingCoordinatorManager);
+        inOrder.verify(mRoutingCoordinatorManager).requestStickyDownstreamAddress(anyInt(),
+                eq(CONNECTIVITY_SCOPE_LOCAL), any());
+        inOrder.verify(mRoutingCoordinatorManager, never()).requestDownstreamAddress(any());
         inOrder.verify(mNetd).networkAddInterface(INetd.LOCAL_NET_ID, IFACE_NAME);
         // One for ipv4 route, one for ipv6 link local route.
         inOrder.verify(mNetd, times(2)).networkAddRoute(eq(INetd.LOCAL_NET_ID), eq(IFACE_NAME),
@@ -711,18 +745,18 @@
         inOrder.verify(mCallback).updateInterfaceState(
                 mIpServer, STATE_LOCAL_ONLY, TETHER_ERROR_NO_ERROR);
         inOrder.verify(mCallback).updateLinkProperties(eq(mIpServer), lpCaptor.capture());
-        verifyNoMoreInteractions(mCallback, mAddressCoordinator);
+        verifyNoMoreInteractions(mCallback, mRoutingCoordinatorManager);
 
         // Simulate the DHCP server receives DHCPDECLINE on MirrorLink and then signals
         // onNewPrefixRequest callback.
         final LinkAddress newAddress = new LinkAddress("192.168.100.125/24");
-        when(mAddressCoordinator.requestDownstreamAddress(any(), anyInt(),
-                anyBoolean())).thenReturn(newAddress);
+        when(mRoutingCoordinatorManager.requestDownstreamAddress(any())).thenReturn(newAddress);
         eventCallbacks.onNewPrefixRequest(new IpPrefix("192.168.42.0/24"));
         mLooper.dispatchAll();
 
-        inOrder.verify(mAddressCoordinator).requestDownstreamAddress(any(),
-                eq(CONNECTIVITY_SCOPE_LOCAL), eq(false));
+        inOrder.verify(mRoutingCoordinatorManager, never())
+                .requestStickyDownstreamAddress(anyInt(), anyInt(), any());
+        inOrder.verify(mRoutingCoordinatorManager).requestDownstreamAddress(any());
         inOrder.verify(mNetd).tetherApplyDnsInterfaces();
         inOrder.verify(mCallback).updateLinkProperties(eq(mIpServer), lpCaptor.capture());
         verifyNoMoreInteractions(mCallback);
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
index 5d22977..dd10cc3 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/BpfCoordinatorTest.java
@@ -2032,7 +2032,7 @@
         final BpfCoordinator coordinator = makeBpfCoordinator();
         initBpfCoordinatorForRule4(coordinator);
         resetNetdAndBpfMaps();
-        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Prepare add/delete rule events.
         final ArrayList<ConntrackEvent> addRuleEvents = new ArrayList<>();
@@ -2049,49 +2049,44 @@
         // Add rules, verify counter increases.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(addRuleEvents.get(i));
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? i + 1 : 0);
+            assertEquals(supportActiveSessionsMetrics ? i + 1 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Add the same events again should not increase the counter because
         // all events are already exist.
         for (final ConntrackEvent event : addRuleEvents) {
             mConsumer.accept(event);
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+            assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Verify removing non-existent items won't change the counters.
         for (int i = 5; i < 8; i++) {
             mConsumer.accept(new TestConntrackEvent.Builder().setMsgType(
                     IPCTNL_MSG_CT_DELETE).setProto(IPPROTO_TCP).setRemotePort(i).build());
-            assertConsumerCountersEquals(supportActiveSessionsMetrics ? 5 : 0);
+            assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                    coordinator.getLastMaxConnectionAndResetToCurrent());
         }
 
         // Verify remove the rules decrease the counter.
         // Note the max counter returns the max, so it returns the count before deleting.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(delRuleEvents.get(i));
-            assertEquals(supportActiveSessionsMetrics ? 4 - i : 0,
-                    mConsumer.getCurrentConnectionCount());
-            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
-                    mConsumer.getLastMaxConnectionCount());
-            assertEquals(supportActiveSessionsMetrics ? 5 - i : 0,
-                    mConsumer.getLastMaxConnectionAndResetToCurrent());
         }
+        // The maximum number of rules observed is still 5.
+        assertEquals(supportActiveSessionsMetrics ? 5 : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // After the reset, the maximum number of rules observed is 0.
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Verify remove these rules again doesn't decrease the counter.
         for (int i = 0; i < 5; i++) {
             mConsumer.accept(delRuleEvents.get(i));
-            assertConsumerCountersEquals(0);
+            assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
         }
     }
 
-    // Helper method to assert all counter values inside consumer.
-    private void assertConsumerCountersEquals(int expectedCount) {
-        assertEquals(expectedCount, mConsumer.getCurrentConnectionCount());
-        assertEquals(expectedCount, mConsumer.getLastMaxConnectionCount());
-        assertEquals(expectedCount, mConsumer.getLastMaxConnectionAndResetToCurrent());
-    }
-
     @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
     // BPF IPv4 forwarding only supports on S+.
     @IgnoreUpTo(Build.VERSION_CODES.R)
@@ -2121,7 +2116,7 @@
         coordinator.tetherOffloadClientAdd(mIpServer, clientB);
         assertClientInfoExists(mIpServer, clientA);
         assertClientInfoExists(mIpServer, clientB);
-        assertEquals(0, mConsumer.getLastMaxConnectionAndResetToCurrent());
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Add some rules for both clients.
         final int addr1RuleCount = 5;
@@ -2145,31 +2140,24 @@
                     .build());
         }
 
-        assertConsumerCountersEquals(
-                supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0);
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Remove 1 client. Since the 1st poll will return the LastMaxCounter and
-        // update it to the current, the max counter will be kept at 1st poll, while
-        // the current counter reflect the rule decreasing.
+        // update it to the current, the max counter will be kept at 1st poll.
         coordinator.tetherOffloadClientRemove(mIpServer, clientA);
+        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // And the counter be updated at 2nd poll.
         assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getCurrentConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr1RuleCount + addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionAndResetToCurrent());
-        // And all counters be updated at 2nd poll.
-        assertConsumerCountersEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0);
+                coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Remove other client.
         coordinator.tetherOffloadClientRemove(mIpServer, clientB);
-        assertEquals(0, mConsumer.getCurrentConnectionCount());
         assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionCount());
-        assertEquals(supportActiveSessionsMetrics ? addr2RuleCount : 0,
-                mConsumer.getLastMaxConnectionAndResetToCurrent());
-        // All counters reach zero at 2nd poll.
-        assertConsumerCountersEquals(0);
+                coordinator.getLastMaxConnectionAndResetToCurrent());
+        // Verify the counter reach zero at 2nd poll.
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
     }
 
     @FeatureFlag(name = TETHER_ACTIVE_SESSIONS_METRICS)
@@ -2191,7 +2179,7 @@
         final BpfCoordinator coordinator = makeBpfCoordinator();
         initBpfCoordinatorForRule4(coordinator);
         resetNetdAndBpfMaps();
-        assertConsumerCountersEquals(0);
+        assertEquals(0, coordinator.getLastMaxConnectionAndResetToCurrent());
 
         // Prepare the counter value.
         for (int i = 0; i < 5; i++) {
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
index a5c06f3..1608e1a 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -24,7 +24,9 @@
 import static android.net.TetheringManager.TETHERING_USB;
 import static android.net.TetheringManager.TETHERING_WIFI;
 import static android.net.TetheringManager.TETHERING_WIFI_P2P;
+import static android.net.ip.IpServer.CMD_NOTIFY_PREFIX_CONFLICT;
 
+import static com.android.net.module.util.PrivateAddressCoordinator.TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 
 import static org.junit.Assert.assertEquals;
@@ -33,6 +35,9 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
@@ -46,10 +51,14 @@
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.ip.IpServer;
+import android.os.IBinder;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.net.module.util.IIpv4PrefixRequest;
+import com.android.net.module.util.PrivateAddressCoordinator;
+
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -71,7 +80,7 @@
     @Mock private IpServer mWifiP2pIpServer;
     @Mock private Context mContext;
     @Mock private ConnectivityManager mConnectivityMgr;
-    @Mock private TetheringConfiguration mConfig;
+    @Mock private PrivateAddressCoordinator.Dependencies mDeps;
 
     private PrivateAddressCoordinator mPrivateAddressCoordinator;
     private final LinkAddress mBluetoothAddress = new LinkAddress("192.168.44.1/24");
@@ -91,12 +100,26 @@
             new IpPrefix("172.16.0.0/12"),
             new IpPrefix("10.0.0.0/8")));
 
+    private void setUpIpServer(IpServer ipServer, int interfaceType) throws Exception {
+        when(ipServer.interfaceType()).thenReturn(interfaceType);
+        final IIpv4PrefixRequest request = mock(IIpv4PrefixRequest.class);
+        when(ipServer.getIpv4PrefixRequest()).thenReturn(request);
+        when(request.asBinder()).thenReturn(mock(IBinder.class));
+        doAnswer(
+                        invocation -> {
+                            ipServer.sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+                            return null;
+                        })
+                .when(request)
+                .onIpv4PrefixConflict(any());
+    }
+
     private void setUpIpServers() throws Exception {
-        when(mUsbIpServer.interfaceType()).thenReturn(TETHERING_USB);
-        when(mEthernetIpServer.interfaceType()).thenReturn(TETHERING_ETHERNET);
-        when(mHotspotIpServer.interfaceType()).thenReturn(TETHERING_WIFI);
-        when(mLocalHotspotIpServer.interfaceType()).thenReturn(TETHERING_WIFI);
-        when(mWifiP2pIpServer.interfaceType()).thenReturn(TETHERING_WIFI_P2P);
+        setUpIpServer(mUsbIpServer, TETHERING_USB);
+        setUpIpServer(mEthernetIpServer, TETHERING_ETHERNET);
+        setUpIpServer(mHotspotIpServer, TETHERING_WIFI);
+        setUpIpServer(mLocalHotspotIpServer, TETHERING_WIFI);
+        setUpIpServer(mWifiP2pIpServer, TETHERING_WIFI_P2P);
     }
 
     @Before
@@ -106,25 +129,32 @@
         when(mContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(mConnectivityMgr);
         when(mContext.getSystemService(ConnectivityManager.class)).thenReturn(mConnectivityMgr);
         when(mConnectivityMgr.getAllNetworks()).thenReturn(mAllNetworks);
-        when(mConfig.shouldEnableWifiP2pDedicatedIp()).thenReturn(false);
-        when(mConfig.isRandomPrefixBaseEnabled()).thenReturn(false);
         setUpIpServers();
         mPrivateAddressCoordinator =
-                spy(
-                        new PrivateAddressCoordinator(
-                                mConnectivityMgr::getAllNetworks,
-                                mConfig.isRandomPrefixBaseEnabled(),
-                                mConfig.shouldEnableWifiP2pDedicatedIp()));
+                spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
     }
 
-    private LinkAddress requestDownstreamAddress(final IpServer ipServer, int scope,
-            boolean useLastAddress) {
-        final LinkAddress address = mPrivateAddressCoordinator.requestDownstreamAddress(
-                ipServer, scope, useLastAddress);
+    private LinkAddress requestStickyDownstreamAddress(final IpServer ipServer, int scope)
+            throws Exception {
+        final LinkAddress address =
+                mPrivateAddressCoordinator.requestStickyDownstreamAddress(
+                        ipServer.interfaceType(), scope, ipServer.getIpv4PrefixRequest());
         when(ipServer.getAddress()).thenReturn(address);
         return address;
     }
 
+    private LinkAddress requestDownstreamAddress(final IpServer ipServer) throws Exception {
+        final LinkAddress address =
+                mPrivateAddressCoordinator.requestDownstreamAddress(
+                        ipServer.getIpv4PrefixRequest());
+        when(ipServer.getAddress()).thenReturn(address);
+        return address;
+    }
+
+    private void releaseDownstream(final IpServer ipServer) {
+        mPrivateAddressCoordinator.releaseDownstream(ipServer.getIpv4PrefixRequest());
+    }
+
     private void updateUpstreamPrefix(UpstreamNetworkState ns) {
         mPrivateAddressCoordinator.updateUpstreamPrefix(
                 ns.linkProperties, ns.networkCapabilities, ns.network);
@@ -133,25 +163,22 @@
     @Test
     public void testRequestDownstreamAddressWithoutUsingLastAddress() throws Exception {
         final IpPrefix bluetoothPrefix = asIpPrefix(mBluetoothAddress);
-        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix hotspotPrefix = asIpPrefix(address);
         assertNotEquals(hotspotPrefix, bluetoothPrefix);
 
-        final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix newHotspotPrefix = asIpPrefix(newAddress);
         assertNotEquals(hotspotPrefix, newHotspotPrefix);
         assertNotEquals(bluetoothPrefix, newHotspotPrefix);
 
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(usbPrefix, bluetoothPrefix);
         assertNotEquals(usbPrefix, newHotspotPrefix);
 
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
     }
 
     @Test
@@ -159,50 +186,47 @@
         // - Test bluetooth prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(mBluetoothAddress.getAddress().getAddress()));
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
         assertNotEquals(asIpPrefix(mBluetoothAddress), hotspotPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mHotspotIpServer);
 
         // - Test previous enabled hotspot prefix(cached prefix) is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(hotspotAddress.getAddress().getAddress()));
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(asIpPrefix(mBluetoothAddress), usbPrefix);
         assertNotEquals(hotspotPrefix, usbPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mUsbIpServer);
 
         // - Test wifi p2p prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
                 getSubAddress(mLegacyWifiP2pAddress.getAddress().getAddress()));
-        final LinkAddress etherAddress = requestDownstreamAddress(mEthernetIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress etherAddress = requestDownstreamAddress(mEthernetIpServer);
         final IpPrefix etherPrefix = asIpPrefix(etherAddress);
         assertNotEquals(asIpPrefix(mLegacyWifiP2pAddress), etherPrefix);
         assertNotEquals(asIpPrefix(mBluetoothAddress), etherPrefix);
         assertNotEquals(hotspotPrefix, etherPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mEthernetIpServer);
+        releaseDownstream(mEthernetIpServer);
     }
 
     @Test
     public void testRequestLastDownstreamAddress() throws Exception {
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress usbAddress =
+                requestStickyDownstreamAddress(mUsbIpServer, CONNECTIVITY_SCOPE_GLOBAL);
 
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
+        releaseDownstream(mHotspotIpServer);
+        releaseDownstream(mUsbIpServer);
 
-        final LinkAddress newHotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress newHotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertEquals(hotspotAddress, newHotspotAddress);
-        final LinkAddress newUsbAddress = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress newUsbAddress =
+                requestStickyDownstreamAddress(mUsbIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertEquals(usbAddress, newUsbAddress);
 
         final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
@@ -259,10 +283,11 @@
     }
 
     private void verifyNotifyConflictAndRelease(final IpServer ipServer) throws Exception {
-        verify(ipServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.releaseDownstream(ipServer);
+        verify(ipServer).sendMessage(CMD_NOTIFY_PREFIX_CONFLICT);
+        releaseDownstream(ipServer);
+        final int interfaceType = ipServer.interfaceType();
         reset(ipServer);
-        setUpIpServers();
+        setUpIpServer(ipServer, interfaceType);
     }
 
     private int getSubAddress(final byte... ipv4Address) {
@@ -273,40 +298,22 @@
     }
 
     private void assertReseveredWifiP2pPrefix() throws Exception {
-        LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        LinkAddress address =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         final IpPrefix hotspotPrefix = asIpPrefix(address);
         final IpPrefix legacyWifiP2pPrefix = asIpPrefix(mLegacyWifiP2pAddress);
         assertNotEquals(legacyWifiP2pPrefix, hotspotPrefix);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-    }
-
-    @Test
-    public void testEnableLegacyWifiP2PAddress() throws Exception {
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
-                getSubAddress(mLegacyWifiP2pAddress.getAddress().getAddress()));
-        // No matter #shouldEnableWifiP2pDedicatedIp() is enabled or not, legacy wifi p2p prefix
-        // is resevered.
-        assertReseveredWifiP2pPrefix();
-
-        when(mConfig.shouldEnableWifiP2pDedicatedIp()).thenReturn(true);
-        assertReseveredWifiP2pPrefix();
-
-        // If #shouldEnableWifiP2pDedicatedIp() is enabled, wifi P2P gets the configured address.
-        LinkAddress address = requestDownstreamAddress(mWifiP2pIpServer,
-                CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
-        assertEquals(mLegacyWifiP2pAddress, address);
-        mPrivateAddressCoordinator.releaseDownstream(mWifiP2pIpServer);
+        releaseDownstream(mHotspotIpServer);
     }
 
     @Test
     public void testEnableSapAndLohsConcurrently() throws Exception {
-        final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
+        final LinkAddress hotspotAddress =
+                requestStickyDownstreamAddress(mHotspotIpServer, CONNECTIVITY_SCOPE_GLOBAL);
         assertNotNull(hotspotAddress);
 
-        final LinkAddress localHotspotAddress = requestDownstreamAddress(mLocalHotspotIpServer,
-                CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
+        final LinkAddress localHotspotAddress =
+                requestStickyDownstreamAddress(mLocalHotspotIpServer, CONNECTIVITY_SCOPE_LOCAL);
         assertNotNull(localHotspotAddress);
 
         final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
@@ -317,7 +324,7 @@
 
     @Test
     public void testStartedPrefixRange() throws Exception {
-        when(mConfig.isRandomPrefixBaseEnabled()).thenReturn(true);
+        when(mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)).thenReturn(true);
 
         startedPrefixBaseTest("192.168.0.0/16", 0);
 
@@ -343,14 +350,9 @@
     private void startedPrefixBaseTest(final String expected, final int randomIntForPrefixBase)
             throws Exception {
         mPrivateAddressCoordinator =
-                spy(
-                        new PrivateAddressCoordinator(
-                                mConnectivityMgr::getAllNetworks,
-                                mConfig.isRandomPrefixBaseEnabled(),
-                                mConfig.shouldEnableWifiP2pDedicatedIp()));
+                spy(new PrivateAddressCoordinator(mConnectivityMgr::getAllNetworks, mDeps));
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomIntForPrefixBase);
-        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
+        final LinkAddress address = requestDownstreamAddress(mHotspotIpServer);
         final IpPrefix prefixBase = new IpPrefix(expected);
         assertTrue(address + " is not part of " + prefixBase,
                 prefixBase.containsPrefix(asIpPrefix(address)));
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
index 9a4945e..d0c036f 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -96,6 +96,7 @@
 import static org.mockito.Matchers.anyString;
 import static org.mockito.Matchers.eq;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeast;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
@@ -191,7 +192,9 @@
 import com.android.internal.util.test.FakeSettingsProvider;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.InterfaceParams;
+import com.android.net.module.util.PrivateAddressCoordinator;
 import com.android.net.module.util.RoutingCoordinatorManager;
+import com.android.net.module.util.RoutingCoordinatorService;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -292,7 +295,7 @@
     @Mock private BluetoothPanShim mBluetoothPanShim;
     @Mock private TetheredInterfaceRequestShim mTetheredInterfaceRequestShim;
     @Mock private TetheringMetrics mTetheringMetrics;
-    @Mock private RoutingCoordinatorManager mRoutingCoordinatorManager;
+    @Mock private PrivateAddressCoordinator.Dependencies mPrivateAddressCoordinatorDependencies;
 
     private final MockIpServerDependencies mIpServerDependencies =
             spy(new MockIpServerDependencies());
@@ -316,12 +319,12 @@
     private TetheringConfiguration mConfig;
     private EntitlementManager mEntitleMgr;
     private OffloadController mOffloadCtrl;
-    private PrivateAddressCoordinator mPrivateAddressCoordinator;
     private SoftApCallback mSoftApCallback;
     private SoftApCallback mLocalOnlyHotspotCallback;
     private UpstreamNetworkMonitor mUpstreamNetworkMonitor;
     private UpstreamNetworkMonitor.EventListener mEventListener;
     private TetheredInterfaceCallbackShim mTetheredInterfaceCallbackShim;
+    private RoutingCoordinatorManager mRoutingCoordinatorManager;
 
     private TestConnectivityManager mCm;
     private boolean mForceEthernetServiceUnavailable = false;
@@ -487,8 +490,16 @@
         }
 
         @Override
-        public RoutingCoordinatorManager getRoutingCoordinator(final Context context,
-                SharedLog log) {
+        public RoutingCoordinatorManager getRoutingCoordinator(
+                final Context context, SharedLog log) {
+            ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
+            when(mPrivateAddressCoordinatorDependencies.isFeatureEnabled(anyString()))
+                    .thenReturn(false);
+            RoutingCoordinatorService service = new RoutingCoordinatorService(
+                    getINetd(context, log),
+                            cm::getAllNetworks,
+                            mPrivateAddressCoordinatorDependencies);
+            mRoutingCoordinatorManager = spy(new RoutingCoordinatorManager(context, service));
             return mRoutingCoordinatorManager;
         }
 
@@ -535,13 +546,6 @@
         }
 
         @Override
-        public PrivateAddressCoordinator makePrivateAddressCoordinator(Context ctx,
-                TetheringConfiguration cfg) {
-            mPrivateAddressCoordinator = super.makePrivateAddressCoordinator(ctx, cfg);
-            return mPrivateAddressCoordinator;
-        }
-
-        @Override
         public BluetoothPanShim makeBluetoothPanShim(BluetoothPan pan) {
             try {
                 when(mBluetoothPanShim.requestTetheredInterface(
@@ -681,6 +685,7 @@
                 new IntentFilter(ACTION_TETHER_STATE_CHANGED));
 
         mCm = spy(new TestConnectivityManager(mServiceContext, mock(IConnectivityManager.class)));
+        when(mCm.getAllNetworks()).thenReturn(new Network[] {});
 
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI)).thenReturn(true);
         when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_DIRECT)).thenReturn(true);
@@ -864,6 +869,9 @@
             assertTrue(TestConnectivityManager.looksLikeDefaultRequest(reqCaptor.getValue()));
         }
 
+        // Ignore calls to {@link ConnectivityManager#getallNetworks}.
+        verify(mCm, atLeast(0)).getAllNetworks();
+
         // The default network request is only ever filed once.
         verifyNoMoreInteractions(mCm);
     }
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 34689bc..6b646ec 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
@@ -87,13 +87,13 @@
 import android.util.ArrayMap;
 
 import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.networkstack.tethering.UpstreamNetworkState;
 import com.android.networkstack.tethering.metrics.TetheringMetrics.DataUsage;
 import com.android.networkstack.tethering.metrics.TetheringMetrics.Dependencies;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
 import org.junit.After;
@@ -104,7 +104,8 @@
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
-@RunWith(AndroidJUnit4.class)
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner.class)
 @SmallTest
 public final class TetheringMetricsTest {
     @Rule public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
@@ -158,7 +159,7 @@
         mThread = new HandlerThread("TetheringMetricsTest");
         mThread.start();
         mHandler = new Handler(mThread.getLooper());
-        doReturn(mHandler).when(mDeps).createHandler(any());
+        doReturn(mHandler).when(mDeps).createHandler();
         // Set up the usage for upstream types.
         mMockUpstreamUsageBaseline.put(UT_CELLULAR, new DataUsage(100L, 200L));
         mMockUpstreamUsageBaseline.put(UT_WIFI, new DataUsage(400L, 800L));
@@ -498,7 +499,7 @@
     private void verifyEmptyUsageForAllUpstreamTypes() {
         mHandler.post(() -> {
             for (UpstreamType type : UpstreamType.values()) {
-                assertEquals(EMPTY, mTetheringMetrics.getDataUsageFromUpstreamType(type));
+                assertEquals(EMPTY, mTetheringMetrics.getLastReportedUsageFromUpstreamType(type));
             }
         });
         HandlerUtils.waitForIdle(mHandler, DEFAULT_TIMEOUT);
@@ -555,7 +556,8 @@
 
         mHandler.post(() -> {
             for (UpstreamType type : UpstreamType.values()) {
-                final DataUsage dataUsage = mTetheringMetrics.getDataUsageFromUpstreamType(type);
+                final DataUsage dataUsage =
+                        mTetheringMetrics.getLastReportedUsageFromUpstreamType(type);
                 if (TetheringMetrics.isUsageSupportedForUpstreamType(type)) {
                     assertEquals(mMockUpstreamUsageBaseline.get(type), dataUsage);
                 } else {
@@ -610,12 +612,21 @@
         incrementCurrentTime(cellDuration);
         updateUpstreamDataUsage(UT_CELLULAR, cellUsageDiff);
 
+        // Change the upstream back to Wi-FI and update the data usage
+        runAndWaitForIdle(() ->
+                mTetheringMetrics.maybeUpdateUpstreamType(buildUpstreamState(TRANSPORT_WIFI)));
+        final long wifiDuration2 = 50 * SECOND_IN_MILLIS;
+        final long wifiUsageDiff2 = 1000L;
+        incrementCurrentTime(wifiDuration2);
+        updateUpstreamDataUsage(UT_WIFI, wifiUsageDiff2);
+
         // Stop tethering and verify that the data usage is uploaded.
         updateErrorAndSendReport(TETHERING_WIFI, TETHER_ERROR_NO_ERROR);
         UpstreamEvents.Builder upstreamEvents = UpstreamEvents.newBuilder();
         addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration, wifiUsageDiff, wifiUsageDiff);
         addUpstreamEvent(upstreamEvents, UT_BLUETOOTH, bluetoothDuration, btUsageDiff, btUsageDiff);
         addUpstreamEvent(upstreamEvents, UT_CELLULAR, cellDuration, cellUsageDiff, cellUsageDiff);
+        addUpstreamEvent(upstreamEvents, UT_WIFI, wifiDuration2, wifiUsageDiff2, wifiUsageDiff2);
         verifyReport(DownstreamType.DS_TETHERING_WIFI, ErrorCode.EC_NO_ERROR,
                 UserType.USER_SETTINGS, upstreamEvents,
                 currentTimeMillis() - wifiTetheringStartTime);
diff --git a/bpf/loader/initrc-doc/README.txt b/bpf/loader/initrc-doc/README.txt
index 42e1fc2..2b22326 100644
--- a/bpf/loader/initrc-doc/README.txt
+++ b/bpf/loader/initrc-doc/README.txt
@@ -1,20 +1,42 @@
 This directory contains comment stripped versions of
   //system/bpf/bpfloader/bpfloader.rc
-from previous versions of Android.
+or
+  //packages/modules/Connectivity/bpf/loader/netbpfload.rc
+(as appropriate) from previous versions of Android.
 
 Generated via:
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android11-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android12-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android13-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/android14-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
-  (cd ../../../../../system/bpf && git cat-file -p remotes/aosp/main:bpfloader/bpfloader.rc;              ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android11-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android12-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android13-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd ../../../../../../system/bpf && git cat-file -p remotes/aosp/android14-release:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+  git cat-file -p remotes/aosp/android14-qpr2-release:netbpfload/netbpfload.rc | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2-24Q1.rc
+  git cat-file -p remotes/aosp/android14-qpr3-release:netbpfload/netbpfload.rc | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR3-24Q2.rc
+  git cat-file -p remotes/aosp/android15-release:netbpfload/netbpfload.rc      | egrep -v '^ *#' > bpfloader-sdk35-15-V-24Q3.rc
+  git cat-file -p remotes/aosp/main:bpf/loader/netbpfload.rc                   | egrep -v '^ *#' > bpfloader-sdk35-15-V-QPR1-24Q4.rc
+
+see also:
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android11-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android12-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android13-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-qpr1-release/bpfloader/bpfloader.rc
+  https://android.googlesource.com/platform/system/bpf/+/refs/heads/android14-qpr2-release/bpfloader/ (rc file is gone in QPR2)
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android14-qpr2-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android14-qpr3-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android15-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/android15-qpr1-release/netbpfload/netbpfload.rc
+  https://android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/main/netbpfload/netbpfload.rc
+or:
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q1-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q2-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q3-release/netbpfload/netbpfload.rc
+  https://googleplex-android.googlesource.com/platform/packages/modules/Connectivity/+/refs/heads/24Q4-release/bpf/loader/netbpfload.rc
 
 this is entirely equivalent to:
   (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/sc-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/tm-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
   (cd /android1/system/bpf && git cat-file -p remotes/goog/udc-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
-  (cd /android1/system/bpf && git cat-file -p remotes/goog/main:bpfloader/bpfloader.rc;    ) | egrep -v '^ *#' > bpfloader-sdk34-14-U-QPR2.rc
 
 it is also equivalent to:
   (cd /android1/system/bpf && git cat-file -p remotes/goog/rvc-qpr-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk30-11-R.rc
@@ -29,34 +51,66 @@
 
 Key takeaways:
 
-= R bpfloader:
+= R bpfloader (platform)
   - CHOWN + SYS_ADMIN
   - asynchronous startup
   - platform only
   - proc file setup handled by initrc
 
-= S bpfloader
+= S bpfloader (platform)
   - adds NET_ADMIN
   - synchronous startup
   - platform + mainline tethering offload
 
-= T bpfloader
+= T bpfloader (platform)
   - platform + mainline networking (including tethering offload)
   - supported btf for maps via exec of btfloader
 
-= U bpfloader
+= U bpfloader (platform)
   - proc file setup moved into bpfloader binary
   - explicitly specified user and groups:
     group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
     user root
 
-= U QPR2 bpfloader
+= U QPR2 [24Q1] bpfloader (platform netbpfload -> platform bpfloader)
   - drops support of btf for maps
   - invocation of /system/bin/netbpfload binary, which after handling *all*
     networking bpf related things executes the platform /system/bin/bpfloader
     which handles non-networking bpf.
+  - Note: this does not (by itself) call into apex NetBpfLoad
+
+= U QPR3 [24Q2] bpfloader (platform netbpfload -> apex netbpfload -> platform bpfloader)
+  - platform NetBpfload *always* execs into apex NetBpfLoad,
+  - shipped with mainline tethering apex that includes NetBpfLoad binary.
+
+= V [24Q3] bpfloader (apex netbpfload -> platform bpfloader)
+  - no significant changes, though it does hard require the apex NetBpfLoad
+    by virtue of the platform NetBpfLoad no longer being present.
+    ie. the apex must override the platform 'bpfloader' service for 35+:
+    the V FRC M-2024-08+ tethering apex does this.
+
+= V QPR1 [24Q4] bpfloader (apex netbpfload -> platform bpfloader)
+  - made netd start earlier (previously happened in parallel to zygote)
+  - renamed and moved the trigger out of netbpload.rc into
+    //system/core/rootdir/init.rc
+  - the new sequence is:
+      trigger post-fs-data        (logd available, starts apexd)
+      trigger load-bpf-programs   (does: exec_start bpfloader)
+      trigger bpf-progs-loaded    (does: start netd)
+      trigger zygote-start
+  - this is more or less irrelevant from the point of view of the bpfloader,
+    but it does mean netd init could fail and abort the boot earlier,
+    before 'A/B update_verifier marks a successful boot'.
+    Though note that due to netd being started asynchronously, it is racy.
 
 Note that there is now a copy of 'netbpfload' provided by the tethering apex
 mainline module at /apex/com.android.tethering/bin/netbpfload, which due
 to the use of execve("/system/bin/bpfloader") relies on T+ selinux which was
 added for btf map support (specifically the ability to exec the "btfloader").
+
+= mainline tethering apex M-2024-08+ overrides the platform service for V+
+  thus loading mainline (ie. networking) bpf programs from mainline 'NetBpfLoad'
+  and platform ones from platform 'bpfloader'.
+
+= mainline tethering apex M-2024-09+ changes T+ behaviour (U QPR3+ unaffected)
+  netd -> netd_updatable.so -> ctl.start=mdnsd_netbpfload -> load net bpf programs
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2-24Q1.rc
similarity index 100%
copy from bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
copy to bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2-24Q1.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3-24Q2.rc
similarity index 100%
rename from bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3-24Q2.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc b/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
deleted file mode 100644
index 8f3f462..0000000
--- a/bpf/loader/initrc-doc/bpfloader-sdk34-14-U-QPR3.rc
+++ /dev/null
@@ -1,11 +0,0 @@
-on load_bpf_programs
-    exec_start bpfloader
-
-service bpfloader /system/bin/netbpfload
-    capabilities CHOWN SYS_ADMIN NET_ADMIN
-    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
-    user root
-    rlimit memlock 1073741824 1073741824
-    oneshot
-    reboot_on_failure reboot,bpfloader-failed
-    updatable
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-24Q3.rc
similarity index 100%
rename from bpf/loader/initrc-doc/bpfloader-sdk35-15-V.rc
rename to bpf/loader/initrc-doc/bpfloader-sdk35-15-V-24Q3.rc
diff --git a/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc
new file mode 100644
index 0000000..e2639ac
--- /dev/null
+++ b/bpf/loader/initrc-doc/bpfloader-sdk35-15-V-QPR1-24Q4.rc
@@ -0,0 +1,5 @@
+service bpfloader /system/bin/false
+    user root
+    oneshot
+    reboot_on_failure reboot,netbpfload-missing
+    updatable
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 5dea851..8e4c2c6 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -201,7 +201,7 @@
     }
 }
 
-Status BpfHandler::init(const char* cg2_path) {
+static inline void waitForBpf() {
     // Note: netd *can* be restarted, so this might get called a second time after boot is complete
     // at which point we don't need to (and shouldn't) wait for (more importantly start) loading bpf
 
@@ -229,6 +229,21 @@
     }
 
     ALOGI("BPF programs are loaded");
+}
+
+Status BpfHandler::init(const char* cg2_path) {
+    // This wait is effectively a no-op on U QPR3+ devices (as netd starts
+    // *after* the synchronous 'exec_start bpfloader' which calls NetBpfLoad)
+    // but checking for U QPR3 is hard.
+    //
+    // Waiting should not be required on U QPR3+ devices,
+    // ...
+    //
+    // ...unless someone changed 'exec_start bpfloader' to 'start bpfloader'
+    // in the rc file.
+    //
+    // TODO: should be: if (!modules::sdklevel::IsAtLeastW())
+    if (android_get_device_api_level() <= __ANDROID_API_V__) waitForBpf();
 
     RETURN_IF_NOT_OK(initPrograms(cg2_path));
     RETURN_IF_NOT_OK(initMaps());
diff --git a/bpf/progs/offload.c b/bpf/progs/offload.c
index 7e1184d..631908a 100644
--- a/bpf/progs/offload.c
+++ b/bpf/progs/offload.c
@@ -85,9 +85,8 @@
 
     // Since the program never writes via DPA (direct packet access) auto-pull/unclone logic does
     // not trigger and thus we need to manually make sure we can read packet headers via DPA.
-    // Note: this is a blind best effort pull, which may fail or pull less - this doesn't matter.
     // It has to be done early cause it will invalidate any skb->data/data_end derived pointers.
-    try_make_writable(skb, l2_header_size + IP6_HLEN + TCP_HLEN);
+    if (bpf_skb_pull_data(skb, l2_header_size + IP6_HLEN)) return TC_ACT_PIPE;
 
     void* data = (void*)(long)skb->data;
     const void* data_end = (void*)(long)skb->data_end;
@@ -110,6 +109,14 @@
     // If hardware offload is running and programming flows based on conntrack entries,
     // try not to interfere with it.
     if (ip6->nexthdr == IPPROTO_TCP) {
+        // don't need to check return code, as it's effectively checked in the next 'if' below
+        bpf_skb_pull_data(skb, l2_header_size + IP6_HLEN + TCP_HLEN);
+
+        data = (void*)(long)skb->data;
+        data_end = (void*)(long)skb->data_end;
+        eth = is_ethernet ? data : NULL;  // used iff is_ethernet
+        ip6 = is_ethernet ? (void*)(eth + 1) : data;
+
         struct tcphdr* tcph = (void*)(ip6 + 1);
 
         // Make sure we can get at the tcp header
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index 14b70d0..60120bc 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -35,3 +35,12 @@
     description: "Controls whether the Android Thread Ephemeral Key feature is enabled"
     bug: "348323500"
 }
+
+flag {
+    name: "set_nat64_configuration_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether the setConfiguration API of NAT64 feature is enabled"
+    bug: "368456504"
+}
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 09a3681..08129eb 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -506,6 +506,13 @@
     field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.ThreadConfiguration> CREATOR;
   }
 
+  @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public static final class ThreadConfiguration.Builder {
+    ctor @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public ThreadConfiguration.Builder();
+    ctor @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") public ThreadConfiguration.Builder(@NonNull android.net.thread.ThreadConfiguration);
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @NonNull public android.net.thread.ThreadConfiguration build();
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @NonNull public android.net.thread.ThreadConfiguration.Builder setNat64Enabled(boolean);
+  }
+
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController {
     method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void activateEphemeralKeyMode(@NonNull java.time.Duration, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method public void createRandomizedDataset(@NonNull String, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<android.net.thread.ActiveOperationalDataset,android.net.thread.ThreadNetworkException>);
@@ -520,6 +527,7 @@
     method @RequiresPermission(android.Manifest.permission.ACCESS_NETWORK_STATE) public void registerStateCallback(@NonNull java.util.concurrent.Executor, @NonNull android.net.thread.ThreadNetworkController.StateCallback);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void scheduleMigration(@NonNull android.net.thread.PendingOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @FlaggedApi("com.android.net.thread.flags.channel_max_powers_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setChannelMaxPowers(@NonNull @Size(min=1) android.util.SparseIntArray, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.set_nat64_configuration_enabled") @RequiresPermission(android.Manifest.permission.THREAD_NETWORK_PRIVILEGED) public void setConfiguration(@NonNull android.net.thread.ThreadConfiguration, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void setEnabled(boolean, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method @FlaggedApi("com.android.net.thread.flags.configuration_enabled") @RequiresPermission(android.Manifest.permission.THREAD_NETWORK_PRIVILEGED) public void unregisterConfigurationCallback(@NonNull java.util.function.Consumer<android.net.thread.ThreadConfiguration>);
     method @RequiresPermission(allOf={android.Manifest.permission.ACCESS_NETWORK_STATE, "android.permission.THREAD_NETWORK_PRIVILEGED"}) public void unregisterOperationalDatasetCallback(@NonNull android.net.thread.ThreadNetworkController.OperationalDatasetCallback);
diff --git a/networksecurity/OWNERS b/networksecurity/OWNERS
index 1a4130a..0c838c0 100644
--- a/networksecurity/OWNERS
+++ b/networksecurity/OWNERS
@@ -1,4 +1,5 @@
 # Bug component: 1479456
 
+bessiej@google.com
 sandrom@google.com
 tweek@google.com
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
index b2ef345..fd73b29 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyDownloader.java
@@ -15,6 +15,7 @@
  */
 package com.android.server.net.ct;
 
+import android.annotation.NonNull;
 import android.annotation.RequiresApi;
 import android.app.DownloadManager;
 import android.content.BroadcastReceiver;
@@ -31,10 +32,13 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
 import java.security.KeyFactory;
+import java.security.PublicKey;
 import java.security.Signature;
 import java.security.spec.X509EncodedKeySpec;
 import java.util.Base64;
+import java.util.Optional;
 
 /** Helper class to download certificate transparency log files. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@@ -42,41 +46,23 @@
 
     private static final String TAG = "CertificateTransparencyDownloader";
 
-    // TODO: move key to a DeviceConfig flag.
-    private static final byte[] PUBLIC_KEY_BYTES =
-            Base64.getDecoder()
-                    .decode(
-                            "MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsu0BHGnQ++W2CTdyZyxv"
-                                + "HHRALOZPlnu/VMVgo2m+JZ8MNbAOH2cgXb8mvOj8flsX/qPMuKIaauO+PwROMjiq"
-                                + "fUpcFm80Kl7i97ZQyBDYKm3MkEYYpGN+skAR2OebX9G2DfDqFY8+jUpOOWtBNr3L"
-                                + "rmVcwx+FcFdMjGDlrZ5JRmoJ/SeGKiORkbbu9eY1Wd0uVhz/xI5bQb0OgII7hEj+"
-                                + "i/IPbJqOHgB8xQ5zWAJJ0DmG+FM6o7gk403v6W3S8qRYiR84c50KppGwe4YqSMkF"
-                                + "bLDleGQWLoaDSpEWtESisb4JiLaY4H+Kk0EyAhPSb+49JfUozYl+lf7iFN3qRq/S"
-                                + "IXXTh6z0S7Qa8EYDhKGCrpI03/+qprwy+my6fpWHi6aUIk4holUCmWvFxZDfixox"
-                                + "K0RlqbFDl2JXMBquwlQpm8u5wrsic1ksIv9z8x9zh4PJqNpCah0ciemI3YGRQqSe"
-                                + "/mRRXBiSn9YQBUPcaeqCYan+snGADFwHuXCd9xIAdFBolw9R9HTedHGUfVXPJDiF"
-                                + "4VusfX6BRR/qaadB+bqEArF/TzuDUr6FvOR4o8lUUxgLuZ/7HO+bHnaPFKYHHSm+"
-                                + "+z1lVDhhYuSZ8ax3T0C3FZpb7HMjZtpEorSV5ElKJEJwrhrBCMOD8L01EoSPrGlS"
-                                + "1w22i9uGHMn/uGQKo28u7AsCAwEAAQ==");
-
     private final Context mContext;
     private final DataStore mDataStore;
     private final DownloadHelper mDownloadHelper;
     private final CertificateTransparencyInstaller mInstaller;
-    private final byte[] mPublicKey;
+
+    @NonNull private Optional<PublicKey> mPublicKey = Optional.empty();
 
     @VisibleForTesting
     CertificateTransparencyDownloader(
             Context context,
             DataStore dataStore,
             DownloadHelper downloadHelper,
-            CertificateTransparencyInstaller installer,
-            byte[] publicKey) {
+            CertificateTransparencyInstaller installer) {
         mContext = context;
         mDataStore = dataStore;
         mDownloadHelper = downloadHelper;
         mInstaller = installer;
-        mPublicKey = publicKey;
     }
 
     CertificateTransparencyDownloader(Context context, DataStore dataStore) {
@@ -84,8 +70,7 @@
                 context,
                 dataStore,
                 new DownloadHelper(context),
-                new CertificateTransparencyInstaller(),
-                PUBLIC_KEY_BYTES);
+                new CertificateTransparencyInstaller());
     }
 
     void registerReceiver() {
@@ -98,6 +83,20 @@
         }
     }
 
+    void setPublicKey(String publicKey) throws GeneralSecurityException {
+        mPublicKey =
+                Optional.of(
+                        KeyFactory.getInstance("RSA")
+                                .generatePublic(
+                                        new X509EncodedKeySpec(
+                                                Base64.getDecoder().decode(publicKey))));
+    }
+
+    @VisibleForTesting
+    void resetPublicKey() {
+        mPublicKey = Optional.empty();
+    }
+
     void startMetadataDownload(String metadataUrl) {
         long downloadId = download(metadataUrl);
         if (downloadId == -1) {
@@ -202,9 +201,11 @@
     }
 
     private boolean verify(Uri file, Uri signature) throws IOException, GeneralSecurityException {
+        if (!mPublicKey.isPresent()) {
+            throw new InvalidKeyException("Missing public key for signature verification");
+        }
         Signature verifier = Signature.getInstance("SHA256withRSA");
-        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
-        verifier.initVerify(keyFactory.generatePublic(new X509EncodedKeySpec(mPublicKey)));
+        verifier.initVerify(mPublicKey.get());
         ContentResolver contentResolver = mContext.getContentResolver();
 
         try (InputStream fileStream = contentResolver.openInputStream(file);
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index a263546..914af06 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -23,6 +23,7 @@
 import android.text.TextUtils;
 import android.util.Log;
 
+import java.security.GeneralSecurityException;
 import java.util.concurrent.Executors;
 
 /** Listener class for the Certificate Transparency Phenotype flags. */
@@ -57,21 +58,35 @@
             return;
         }
 
+        String newPublicKey =
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_PUBLIC_KEY,
+                        /* defaultValue= */ "");
         String newVersion =
-                DeviceConfig.getString(Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_VERSION, "");
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_VERSION,
+                        /* defaultValue= */ "");
         String newContentUrl =
                 DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_CONTENT_URL, "");
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_CONTENT_URL,
+                        /* defaultValue= */ "");
         String newMetadataUrl =
                 DeviceConfig.getString(
-                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_METADATA_URL, "");
-        if (TextUtils.isEmpty(newVersion)
+                        Config.NAMESPACE_NETWORK_SECURITY,
+                        Config.FLAG_METADATA_URL,
+                        /* defaultValue= */ "");
+        if (TextUtils.isEmpty(newPublicKey)
+                || TextUtils.isEmpty(newVersion)
                 || TextUtils.isEmpty(newContentUrl)
                 || TextUtils.isEmpty(newMetadataUrl)) {
             return;
         }
 
         if (Config.DEBUG) {
+            Log.d(TAG, "newPublicKey=" + newPublicKey);
             Log.d(TAG, "newVersion=" + newVersion);
             Log.d(TAG, "newContentUrl=" + newContentUrl);
             Log.d(TAG, "newMetadataUrl=" + newMetadataUrl);
@@ -88,6 +103,13 @@
             return;
         }
 
+        try {
+            mCertificateTransparencyDownloader.setPublicKey(newPublicKey);
+        } catch (GeneralSecurityException e) {
+            Log.e(TAG, "Error setting the public Key", e);
+            return;
+        }
+
         // TODO: handle the case where there is already a pending download.
 
         mDataStore.setProperty(Config.VERSION_PENDING, newVersion);
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index 2a6b8e2..611a5c7 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -40,6 +40,7 @@
     static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
     static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
     static final String FLAG_VERSION = FLAGS_PREFIX + "version";
+    static final String FLAG_PUBLIC_KEY = FLAGS_PREFIX + "public_key";
 
     // properties
     static final String VERSION_PENDING = "version_pending";
diff --git a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
index a056c35..1aad028 100644
--- a/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
+++ b/networksecurity/tests/unit/src/com/android/server/net/ct/CertificateTransparencyDownloaderTest.java
@@ -48,9 +48,10 @@
 import java.security.GeneralSecurityException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
+import java.security.PublicKey;
 import java.security.Signature;
+import java.util.Base64;
 
 /** Tests for the {@link CertificateTransparencyDownloader}. */
 @RunWith(JUnit4.class)
@@ -60,18 +61,20 @@
     @Mock private CertificateTransparencyInstaller mCertificateTransparencyInstaller;
 
     private PrivateKey mPrivateKey;
+    private PublicKey mPublicKey;
     private Context mContext;
     private File mTempFile;
     private DataStore mDataStore;
     private CertificateTransparencyDownloader mCertificateTransparencyDownloader;
 
     @Before
-    public void setUp() throws IOException, NoSuchAlgorithmException {
+    public void setUp() throws IOException, GeneralSecurityException {
         MockitoAnnotations.initMocks(this);
 
         KeyPairGenerator instance = KeyPairGenerator.getInstance("RSA");
         KeyPair keyPair = instance.generateKeyPair();
         mPrivateKey = keyPair.getPrivate();
+        mPublicKey = keyPair.getPublic();
 
         mContext = InstrumentationRegistry.getInstrumentation().getContext();
         mTempFile = File.createTempFile("datastore-test", ".properties");
@@ -80,16 +83,13 @@
 
         mCertificateTransparencyDownloader =
                 new CertificateTransparencyDownloader(
-                        mContext,
-                        mDataStore,
-                        mDownloadHelper,
-                        mCertificateTransparencyInstaller,
-                        keyPair.getPublic().getEncoded());
+                        mContext, mDataStore, mDownloadHelper, mCertificateTransparencyInstaller);
     }
 
     @After
     public void tearDown() {
         mTempFile.delete();
+        mCertificateTransparencyDownloader.resetPublicKey();
     }
 
     @Test
@@ -155,6 +155,8 @@
         long metadataId = 123;
         File metadataFile = sign(logListFile);
         Uri metadataUri = Uri.fromFile(metadataFile);
+        mCertificateTransparencyDownloader.setPublicKey(
+                Base64.getEncoder().encodeToString(mPublicKey.getEncoded()));
 
         setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
         when(mCertificateTransparencyInstaller.install(any(), eq(version))).thenReturn(true);
@@ -212,6 +214,28 @@
         assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
     }
 
+    @Test
+    public void testDownloader_handleContentCompleteMissingVerificationPublicKey()
+            throws Exception {
+        String version = "666";
+        long contentId = 666;
+        File logListFile = File.createTempFile("log_list", "json");
+        Uri contentUri = Uri.fromFile(logListFile);
+        long metadataId = 123;
+        File metadataFile = sign(logListFile);
+        Uri metadataUri = Uri.fromFile(metadataFile);
+
+        setUpDownloadComplete(version, metadataId, metadataUri, contentId, contentUri);
+
+        mCertificateTransparencyDownloader.onReceive(
+                mContext, makeDownloadCompleteIntent(contentId));
+
+        verify(mCertificateTransparencyInstaller, never()).install(any(), eq(version));
+        assertThat(mDataStore.getProperty(Config.VERSION)).isNull();
+        assertThat(mDataStore.getProperty(Config.CONTENT_URL)).isNull();
+        assertThat(mDataStore.getProperty(Config.METADATA_URL)).isNull();
+    }
+
     private Intent makeDownloadCompleteIntent(long downloadId) {
         return new Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
                 .putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId);
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
index 6bf186a..dd6ed2e 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTraceHandler.h
@@ -88,6 +88,13 @@
   // Connects to the system Perfetto daemon and registers the trace handler.
   static void InitPerfettoTracing();
 
+  // This prevents Perfetto from holding the data source lock when calling
+  // OnSetup, OnStart, or OnStop. The lock is still held by the LockedHandle
+  // returned by GetDataSourceLocked. Disabling this lock prevents a deadlock
+  // where OnStop holds this lock waiting for the poller to stop, but the poller
+  // is running the callback that is trying to acquire the lock.
+  static constexpr bool kRequiresCallbacksUnderLock = false;
+
   // When isTest is true, skip non-hermetic code.
   NetworkTraceHandler(bool isTest = false) : mIsTest(isTest) {}
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
index b16d8bd..c833422 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -33,8 +33,8 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.DnsUtils;
+import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.SharedLog;
-import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -210,9 +210,22 @@
 
         void ensureRunningOnHandlerThread() {
             synchronized (pendingTasks) {
-                MdnsUtils.ensureRunningOnHandlerThread(handler);
+                HandlerUtils.ensureRunningOnHandlerThread(handler);
             }
         }
+
+        public void runWithScissorsForDumpIfReady(@NonNull Runnable function) {
+            final Handler handler;
+            synchronized (pendingTasks) {
+                if (this.handler == null) {
+                    Log.d(TAG, "The handler is not ready. Ignore the DiscoveryManager dump");
+                    return;
+                } else {
+                    handler = this.handler;
+                }
+            }
+            HandlerUtils.runWithScissorsForDump(handler, function, 10_000);
+        }
     }
 
     /**
@@ -469,7 +482,7 @@
      * Dump DiscoveryManager state.
      */
     public void dump(PrintWriter pw) {
-        discoveryExecutor.checkAndRunOnHandlerThread(() -> {
+        discoveryExecutor.runWithScissorsForDumpIfReady(() -> {
             pw.println("Clients:");
             // Dump ServiceTypeClients
             for (MdnsServiceTypeClient serviceTypeClient
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 c575d40..36fad31 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClient.java
@@ -16,7 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
index e84cead..cfd8e9a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPacketRepeater.java
@@ -27,6 +27,7 @@
 import android.os.Looper;
 import android.os.Message;
 
+import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.SharedLog;
 
 import java.io.IOException;
@@ -167,9 +168,7 @@
      * @return true if probing was in progress, false if this was a no-op
      */
     public boolean stop(int id) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException("stop can only be called from the looper thread");
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         // Since this is run on the looper thread, messages cannot be currently processing and are
         // all in the handler queue; unless this method is called from a message, but the current
         // message cannot be cancelled.
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
index cfeca5d..e52dd2f 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsQueryScheduler.java
@@ -107,7 +107,7 @@
         final QueryTaskConfig nextRunConfig = currentConfig.getConfigForNextRun(queryMode);
         long timeToRun;
         if (mLastScheduledQueryTaskArgs == null && !forceEnableBackoff) {
-            timeToRun = now + nextRunConfig.delayUntilNextTaskWithoutBackoffMs;
+            timeToRun = now + nextRunConfig.delayBeforeTaskWithoutBackoffMs;
         } else {
             timeToRun = calculateTimeToRun(mLastScheduledQueryTaskArgs,
                     nextRunConfig, now, minRemainingTtl, lastSentTime, numOfQueriesBeforeBackoff,
@@ -133,7 +133,7 @@
     private static long calculateTimeToRun(@Nullable ScheduledQueryTaskArgs taskArgs,
             QueryTaskConfig queryTaskConfig, long now, long minRemainingTtl, long lastSentTime,
             int numOfQueriesBeforeBackoff, boolean forceEnableBackoff) {
-        final long baseDelayInMs = queryTaskConfig.delayUntilNextTaskWithoutBackoffMs;
+        final long baseDelayInMs = queryTaskConfig.delayBeforeTaskWithoutBackoffMs;
         if (!(forceEnableBackoff
                 || queryTaskConfig.shouldUseQueryBackoff(numOfQueriesBeforeBackoff))) {
             return lastSentTime + baseDelayInMs;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
index db3845a..a89b004 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
@@ -16,9 +16,9 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.MdnsConstants.IPV4_SOCKET_ADDR;
 import static com.android.server.connectivity.mdns.MdnsConstants.IPV6_SOCKET_ADDR;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.RequiresApi;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
index 22f7a03..4ae8701 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -18,8 +18,8 @@
 
 import static com.android.net.module.util.DnsUtils.equalsIgnoreDnsCase;
 import static com.android.net.module.util.DnsUtils.toDnsUpperCase;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.MdnsResponse.EXPIRATION_NEVER;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
 
 import static java.lang.Math.min;
 
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 a5dd536..4f01599 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -16,11 +16,12 @@
 
 package com.android.server.connectivity.mdns;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.MdnsSearchOptions.AGGRESSIVE_QUERY_MODE;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.ServiceExpiredCallback;
 import static com.android.server.connectivity.mdns.MdnsServiceCache.findMatchedResponse;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.Clock;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.server.connectivity.mdns.util.MdnsUtils.buildMdnsServiceInfoFromResponse;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -41,10 +42,7 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.net.DatagramPacket;
-import java.net.Inet4Address;
-import java.net.Inet6Address;
 import java.net.InetSocketAddress;
-import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -309,57 +307,6 @@
         serviceCache.unregisterServiceExpiredCallback(cacheKey);
     }
 
-    private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(@NonNull MdnsResponse response,
-            @NonNull String[] serviceTypeLabels, long elapsedRealtimeMillis) {
-        String[] hostName = null;
-        int port = 0;
-        if (response.hasServiceRecord()) {
-            hostName = response.getServiceRecord().getServiceHost();
-            port = response.getServiceRecord().getServicePort();
-        }
-
-        final List<String> ipv4Addresses = new ArrayList<>();
-        final List<String> ipv6Addresses = new ArrayList<>();
-        if (response.hasInet4AddressRecord()) {
-            for (MdnsInetAddressRecord inetAddressRecord : response.getInet4AddressRecords()) {
-                final Inet4Address inet4Address = inetAddressRecord.getInet4Address();
-                ipv4Addresses.add((inet4Address == null) ? null : inet4Address.getHostAddress());
-            }
-        }
-        if (response.hasInet6AddressRecord()) {
-            for (MdnsInetAddressRecord inetAddressRecord : response.getInet6AddressRecords()) {
-                final Inet6Address inet6Address = inetAddressRecord.getInet6Address();
-                ipv6Addresses.add((inet6Address == null) ? null : inet6Address.getHostAddress());
-            }
-        }
-        String serviceInstanceName = response.getServiceInstanceName();
-        if (serviceInstanceName == null) {
-            throw new IllegalStateException(
-                    "mDNS response must have non-null service instance name");
-        }
-        List<String> textStrings = null;
-        List<MdnsServiceInfo.TextEntry> textEntries = null;
-        if (response.hasTextRecord()) {
-            textStrings = response.getTextRecord().getStrings();
-            textEntries = response.getTextRecord().getEntries();
-        }
-        Instant now = Instant.now();
-        // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address.
-        return new MdnsServiceInfo(
-                serviceInstanceName,
-                serviceTypeLabels,
-                response.getSubtypes(),
-                hostName,
-                port,
-                ipv4Addresses,
-                ipv6Addresses,
-                textStrings,
-                textEntries,
-                response.getInterfaceIndex(),
-                response.getNetwork(),
-                now.plusMillis(response.getMinRemainingTtl(elapsedRealtimeMillis)));
-    }
-
     private List<MdnsResponse> getExistingServices() {
         return featureFlags.isQueryWithKnownAnswerEnabled()
                 ? serviceCache.getCachedServices(cacheKey) : Collections.emptyList();
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
index 5c9ec09..b640c32 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsSocketProvider.java
@@ -19,7 +19,8 @@
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.mdns.util.MdnsUtils.isNetworkMatched;
 
 import android.annotation.NonNull;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java b/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
index 70451f3..4d7e4bc 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MulticastPacketReader.java
@@ -16,7 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
-import static com.android.server.connectivity.mdns.util.MdnsUtils.ensureRunningOnHandlerThread;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.os.Handler;
diff --git a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
index d2cd463..4e74159 100644
--- a/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
+++ b/service-t/src/com/android/server/connectivity/mdns/QueryTaskConfig.java
@@ -55,22 +55,22 @@
     private final int queriesPerBurst;
     private final int timeBetweenBurstsInMs;
     private final int burstCounter;
-    final long delayUntilNextTaskWithoutBackoffMs;
+    final long delayBeforeTaskWithoutBackoffMs;
     private final boolean isFirstBurst;
-    private final long queryCount;
+    private final long queryIndex;
 
-    QueryTaskConfig(long queryCount, int transactionId,
+    QueryTaskConfig(long queryIndex, int transactionId,
             boolean expectUnicastResponse, boolean isFirstBurst, int burstCounter,
             int queriesPerBurst, int timeBetweenBurstsInMs,
-            long delayUntilNextTaskWithoutBackoffMs) {
+            long delayBeforeTaskWithoutBackoffMs) {
         this.transactionId = transactionId;
         this.expectUnicastResponse = expectUnicastResponse;
         this.queriesPerBurst = queriesPerBurst;
         this.timeBetweenBurstsInMs = timeBetweenBurstsInMs;
         this.burstCounter = burstCounter;
-        this.delayUntilNextTaskWithoutBackoffMs = delayUntilNextTaskWithoutBackoffMs;
+        this.delayBeforeTaskWithoutBackoffMs = delayBeforeTaskWithoutBackoffMs;
         this.isFirstBurst = isFirstBurst;
-        this.queryCount = queryCount;
+        this.queryIndex = queryIndex;
     }
 
     QueryTaskConfig(int queryMode) {
@@ -82,26 +82,26 @@
         // Config the scan frequency based on the scan mode.
         if (queryMode == AGGRESSIVE_QUERY_MODE) {
             this.timeBetweenBurstsInMs = INITIAL_AGGRESSIVE_TIME_BETWEEN_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs =
+            this.delayBeforeTaskWithoutBackoffMs =
                     TIME_BETWEEN_RETRANSMISSION_QUERIES_IN_BURST_MS;
         } else if (queryMode == PASSIVE_QUERY_MODE) {
             // In passive scan mode, sends a single burst of QUERIES_PER_BURST queries, and then
             // in each TIME_BETWEEN_BURSTS interval, sends QUERIES_PER_BURST_PASSIVE_MODE
             // queries.
             this.timeBetweenBurstsInMs = MAX_TIME_BETWEEN_ACTIVE_PASSIVE_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
+            this.delayBeforeTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
         } else {
             // In active scan mode, sends a burst of QUERIES_PER_BURST queries,
             // TIME_BETWEEN_QUERIES_IN_BURST_MS apart, then waits for the scan interval, and
             // then repeats. The scan interval starts as INITIAL_TIME_BETWEEN_BURSTS_MS and
             // doubles until it maxes out at TIME_BETWEEN_BURSTS_MS.
             this.timeBetweenBurstsInMs = INITIAL_TIME_BETWEEN_BURSTS_MS;
-            this.delayUntilNextTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
+            this.delayBeforeTaskWithoutBackoffMs = TIME_BETWEEN_QUERIES_IN_BURST_MS;
         }
-        this.queryCount = 0;
+        this.queryIndex = 0;
     }
 
-    long getDelayUntilNextTaskWithoutBackoff(boolean isFirstQueryInBurst,
+    long getDelayBeforeNextTaskWithoutBackoff(boolean isFirstQueryInBurst,
             boolean isLastQueryInBurst, int queryMode) {
         if (isFirstQueryInBurst && queryMode == AGGRESSIVE_QUERY_MODE) {
             return 0;
@@ -137,7 +137,7 @@
      * Get new QueryTaskConfig for next run.
      */
     public QueryTaskConfig getConfigForNextRun(int queryMode) {
-        long newQueryCount = queryCount + 1;
+        long newQueryCount = queryIndex + 1;
         int newTransactionId = transactionId + 1;
         if (newTransactionId > UNSIGNED_SHORT_MAX_VALUE) {
             newTransactionId = 1;
@@ -162,7 +162,7 @@
                 getNextExpectUnicastResponse(isLastQueryInBurst, queryMode), newIsFirstBurst,
                 newBurstCounter, newQueriesPerBurst,
                 getNextTimeBetweenBurstsMs(isLastQueryInBurst, queryMode),
-                getDelayUntilNextTaskWithoutBackoff(
+                getDelayBeforeNextTaskWithoutBackoff(
                         isFirstQueryInBurst, isLastQueryInBurst, queryMode));
     }
 
@@ -174,6 +174,6 @@
         if (burstCounter != 0 || isFirstBurst) {
             return false;
         }
-        return queryCount > numOfQueriesBeforeBackoff;
+        return queryIndex > numOfQueriesBeforeBackoff;
     }
 }
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 8745941..41b15dd 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
@@ -24,18 +24,22 @@
 import android.annotation.Nullable;
 import android.net.Network;
 import android.os.Build;
-import android.os.Handler;
 import android.os.SystemClock;
 import android.util.ArraySet;
 import android.util.Pair;
 
 import com.android.server.connectivity.mdns.MdnsConstants;
+import com.android.server.connectivity.mdns.MdnsInetAddressRecord;
 import com.android.server.connectivity.mdns.MdnsPacket;
 import com.android.server.connectivity.mdns.MdnsPacketWriter;
 import com.android.server.connectivity.mdns.MdnsRecord;
+import com.android.server.connectivity.mdns.MdnsResponse;
+import com.android.server.connectivity.mdns.MdnsServiceInfo;
 
 import java.io.IOException;
 import java.net.DatagramPacket;
+import java.net.Inet4Address;
+import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.nio.ByteBuffer;
@@ -43,6 +47,7 @@
 import java.nio.charset.Charset;
 import java.nio.charset.CharsetEncoder;
 import java.nio.charset.StandardCharsets;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -82,21 +87,6 @@
         }
     }
 
-    /*** Ensure that current running thread is same as given handler thread */
-    public static void ensureRunningOnHandlerThread(@NonNull Handler handler) {
-        if (!isRunningOnHandlerThread(handler)) {
-            throw new IllegalStateException(
-                    "Not running on Handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
-    /*** Check that current running thread is same as given handler thread */
-    public static boolean isRunningOnHandlerThread(@NonNull Handler handler) {
-        if (handler.getLooper().getThread() == Thread.currentThread()) {
-            return true;
-        }
-        return false;
-    }
 
     /*** Check whether the target network matches the current network */
     public static boolean isNetworkMatched(@Nullable Network targetNetwork,
@@ -318,4 +308,62 @@
         }
         return true;
     }
+
+    /**
+     * Build MdnsServiceInfo object from given MdnsResponse, service type labels and current time.
+     *
+     * @param response target service response
+     * @param serviceTypeLabels service type labels
+     * @param elapsedRealtimeMillis current time.
+     */
+    public static MdnsServiceInfo buildMdnsServiceInfoFromResponse(@NonNull MdnsResponse response,
+            @NonNull String[] serviceTypeLabels, long elapsedRealtimeMillis) {
+        String[] hostName = null;
+        int port = 0;
+        if (response.hasServiceRecord()) {
+            hostName = response.getServiceRecord().getServiceHost();
+            port = response.getServiceRecord().getServicePort();
+        }
+
+        final List<String> ipv4Addresses = new ArrayList<>();
+        final List<String> ipv6Addresses = new ArrayList<>();
+        if (response.hasInet4AddressRecord()) {
+            for (MdnsInetAddressRecord inetAddressRecord : response.getInet4AddressRecords()) {
+                final Inet4Address inet4Address = inetAddressRecord.getInet4Address();
+                ipv4Addresses.add((inet4Address == null) ? null : inet4Address.getHostAddress());
+            }
+        }
+        if (response.hasInet6AddressRecord()) {
+            for (MdnsInetAddressRecord inetAddressRecord : response.getInet6AddressRecords()) {
+                final Inet6Address inet6Address = inetAddressRecord.getInet6Address();
+                ipv6Addresses.add((inet6Address == null) ? null : inet6Address.getHostAddress());
+            }
+        }
+        String serviceInstanceName = response.getServiceInstanceName();
+        if (serviceInstanceName == null) {
+            throw new IllegalStateException(
+                    "mDNS response must have non-null service instance name");
+        }
+        List<String> textStrings = null;
+        List<MdnsServiceInfo.TextEntry> textEntries = null;
+        if (response.hasTextRecord()) {
+            textStrings = response.getTextRecord().getStrings();
+            textEntries = response.getTextRecord().getEntries();
+        }
+        Instant now = Instant.now();
+        // TODO: Throw an error message if response doesn't have Inet6 or Inet4 address.
+        return new MdnsServiceInfo(
+                serviceInstanceName,
+                serviceTypeLabels,
+                response.getSubtypes(),
+                hostName,
+                port,
+                ipv4Addresses,
+                ipv6Addresses,
+                textStrings,
+                textEntries,
+                response.getInterfaceIndex(),
+                response.getNetwork(),
+                now.plusMillis(response.getMinRemainingTtl(elapsedRealtimeMillis)));
+    }
 }
\ No newline at end of file
diff --git a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
index cadc04d..1ac99e4 100644
--- a/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
+++ b/service-t/src/com/android/server/ethernet/EthernetNetworkFactory.java
@@ -202,20 +202,6 @@
         return;
     }
 
-    private static NetworkCapabilities mixInCapabilities(NetworkCapabilities nc,
-            NetworkCapabilities addedNc) {
-       final NetworkCapabilities.Builder builder = new NetworkCapabilities.Builder(nc);
-       for (int transport : addedNc.getTransportTypes()) builder.addTransportType(transport);
-       for (int capability : addedNc.getCapabilities()) builder.addCapability(capability);
-       return builder.build();
-    }
-
-    private static NetworkCapabilities createDefaultNetworkCapabilities() {
-        return NetworkCapabilities.Builder
-                .withoutDefaultCapabilities()
-                .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET).build();
-    }
-
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
     protected boolean removeInterface(String interfaceName) {
         NetworkInterfaceState iface = mTrackingInterfaces.remove(interfaceName);
@@ -556,14 +542,6 @@
             maybeRestart();
         }
 
-        private void ensureRunningOnEthernetHandlerThread() {
-            if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-                throw new IllegalStateException(
-                        "Not running on the Ethernet thread: "
-                                + Thread.currentThread().getName());
-            }
-        }
-
         private void handleOnLinkPropertiesChange(LinkProperties linkProperties) {
             mLinkProperties = linkProperties;
             if (mNetworkAgent != null) {
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 71f289e..67d0891 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -49,6 +49,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.HandlerUtils;
 import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
@@ -302,11 +303,7 @@
     }
 
     private void ensureRunningOnEthernetServiceThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on EthernetService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
     }
 
     /**
diff --git a/service/Android.bp b/service/Android.bp
index 94061a4..e6caf9d 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -90,6 +90,7 @@
     static_libs: [
         "libnet_utils_device_common_bpfjni",
         "libnet_utils_device_common_bpfutils",
+        "libnet_utils_device_common_timerfdjni",
     ],
     shared_libs: [
         "liblog",
diff --git a/service/ServiceConnectivityResources/Android.bp b/service/ServiceConnectivityResources/Android.bp
index 2621256..be9b2b5 100644
--- a/service/ServiceConnectivityResources/Android.bp
+++ b/service/ServiceConnectivityResources/Android.bp
@@ -33,6 +33,7 @@
         "com.android.tethering",
     ],
     certificate: ":com.android.connectivity.resources.certificate",
+    updatable: true,
 }
 
 android_app_certificate {
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index cb62ae1..665e6f9 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -2028,7 +2028,8 @@
             mCdmps = null;
         }
 
-        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+        mRoutingCoordinatorService =
+                new RoutingCoordinatorService(netd, this::getAllNetworks, mContext);
         mMulticastRoutingCoordinatorService =
                 mDeps.makeMulticastRoutingCoordinatorService(mHandler);
 
@@ -9121,11 +9122,7 @@
     }
 
     private void ensureRunningOnConnectivityServiceThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
     }
 
     @VisibleForTesting
@@ -13050,10 +13047,7 @@
         }
 
         private void ensureRunningOnConnectivityServiceThread() {
-            if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-                throw new IllegalStateException("Not running on ConnectivityService thread: "
-                                + Thread.currentThread().getName());
-            }
+            HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         }
 
         /**
diff --git a/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
index 31108fc..c7d96de 100644
--- a/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
+++ b/service/src/com/android/server/connectivity/AutomaticOnOffKeepaliveTracker.java
@@ -25,6 +25,7 @@
 import static android.system.OsConstants.SOL_SOCKET;
 import static android.system.OsConstants.SO_SNDTIMEO;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.net.module.util.netlink.NetlinkUtils.IO_TIMEOUT_MS;
 
 import android.annotation.IntDef;
@@ -440,7 +441,7 @@
      */
     @Nullable
     public AutomaticOnOffKeepalive getKeepaliveForBinder(@NonNull final IBinder token) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
 
         return CollectionUtils.findFirst(mAutomaticOnOffKeepalives,
                 it -> it.mCallback.asBinder().equals(token));
@@ -580,7 +581,7 @@
     }
 
     private void cleanupAutoOnOffKeepalive(@NonNull final AutomaticOnOffKeepalive autoKi) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         mKeepaliveStatsTracker.onStopKeepalive(autoKi.getNetwork(), autoKi.mKi.getSlot());
         autoKi.close();
         if (null != autoKi.mAlarmListener) mAlarmManager.cancel(autoKi.mAlarmListener);
@@ -693,7 +694,7 @@
      * This should be only be called in ConnectivityService handler thread.
      */
     public void dump(IndentingPrintWriter pw) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         mKeepaliveTracker.dump(pw);
         // Reading DeviceConfig will check if the calling uid and calling package name are the same.
         // Clear calling identity to align the calling uid and package so that it won't fail if cts
@@ -771,7 +772,7 @@
     private boolean isAnyTcpSocketConnectedForFamily(FileDescriptor fd, int family, int networkMark,
             int networkMask)
             throws ErrnoException, InterruptedIOException {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         // Build SocketDiag messages and cache it.
         if (mSockDiagMsg.get(family) == null) {
             mSockDiagMsg.put(family, InetDiagMessage.buildInetDiagReqForAliveTcpSockets(family));
@@ -843,13 +844,6 @@
         return mark;
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mConnectivityServiceHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     private long getTcpPollingIntervalMs(@NonNull AutomaticOnOffKeepalive ki) {
         final boolean useLowTimer = mTestLowTcpPollingTimerUntilMs > System.currentTimeMillis();
         // Adjust the polling interval to be smaller than the keepalive delay to preserve
diff --git a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
index f5fa4fb..14a935f 100644
--- a/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
+++ b/service/src/com/android/server/connectivity/CarrierPrivilegeAuthenticator.java
@@ -19,6 +19,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 import static com.android.server.connectivity.ConnectivityFlags.CARRIER_SERVICE_CHANGED_USE_CALLBACK;
 
 import android.annotation.NonNull;
@@ -168,7 +169,7 @@
     private void simConfigChanged() {
         //  If mRequestRestrictedWifiEnabled is false, constructor calls simConfigChanged
         if (mRequestRestrictedWifiEnabled) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
         }
         synchronized (mLock) {
             unregisterCarrierPrivilegesListeners();
@@ -212,7 +213,7 @@
         public void onCarrierPrivilegesChanged(
                 @NonNull List<String> privilegedPackageNames,
                 @NonNull int[] privilegedUids) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
             if (mUseCallbacksForServiceChanged) return;
             // Re-trigger the synchronous check (which is also very cheap due
             // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -223,7 +224,7 @@
         @Override
         public void onCarrierServiceChanged(@Nullable final String carrierServicePackageName,
                 final int carrierServiceUid) {
-            ensureRunningOnHandlerThread();
+            ensureRunningOnHandlerThread(mHandler);
             if (!mUseCallbacksForServiceChanged) {
                 // Re-trigger the synchronous check (which is also very cheap due
                 // to caching in CarrierPrivilegesTracker). This allows consistency
@@ -465,13 +466,6 @@
         }
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     public void dump(IndentingPrintWriter pw) {
         pw.println("CarrierPrivilegeAuthenticator:");
         pw.println("mRequestRestrictedWifiEnabled = " + mRequestRestrictedWifiEnabled);
diff --git a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
index 21dbb45..8acd1c8 100644
--- a/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
+++ b/service/src/com/android/server/connectivity/KeepaliveStatsTracker.java
@@ -18,6 +18,8 @@
 
 import static android.telephony.SubscriptionManager.OnSubscriptionsChangedListener;
 
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
+
 import android.annotation.NonNull;
 import android.content.BroadcastReceiver;
 import android.content.Context;
@@ -466,7 +468,7 @@
             int intervalSeconds,
             int appUid,
             boolean isAutoKeepalive) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         final int keepaliveId = getKeepaliveId(network, slot);
         if (keepaliveId == INVALID_KEEPALIVE_ID) return;
@@ -538,21 +540,21 @@
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been paused. */
     public void onPauseKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         onKeepaliveActive(network, slot, /* keepaliveActive= */ false);
     }
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been resumed. */
     public void onResumeKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
         onKeepaliveActive(network, slot, /* keepaliveActive= */ true);
     }
 
     /** Inform the KeepaliveStatsTracker a keepalive has just been stopped. */
     public void onStopKeepalive(@NonNull Network network, int slot) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         if (!isEnabled()) return;
 
         final int keepaliveId = getKeepaliveId(network, slot);
@@ -615,7 +617,7 @@
      */
     @VisibleForTesting
     public @NonNull DailykeepaliveInfoReported buildKeepaliveMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         final long timeNow = mDependencies.getElapsedRealtime();
         return buildKeepaliveMetrics(timeNow);
     }
@@ -673,7 +675,7 @@
      */
     @VisibleForTesting
     public @NonNull DailykeepaliveInfoReported buildAndResetMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         final long timeNow = mDependencies.getElapsedRealtime();
 
         final DailykeepaliveInfoReported metrics = buildKeepaliveMetrics(timeNow);
@@ -750,7 +752,7 @@
 
     /** Writes the stored metrics to ConnectivityStatsLog and resets. */
     public void writeAndResetMetrics() {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         // Keepalive stats use repeated atoms, which are only supported on T+. If written to statsd
         // on S- they will bootloop the system, so they must not be sent on S-. See b/289471411.
         if (!SdkLevel.isAtLeastT()) {
@@ -771,17 +773,10 @@
 
     /** Dump KeepaliveStatsTracker state. */
     public void dump(IndentingPrintWriter pw) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mConnectivityServiceHandler);
         pw.println("KeepaliveStatsTracker enabled: " + isEnabled());
         pw.increaseIndent();
         pw.println(buildKeepaliveMetrics().toString());
         pw.decreaseIndent();
     }
-
-    private void ensureRunningOnHandlerThread() {
-        if (mConnectivityServiceHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
 }
diff --git a/service/src/com/android/server/connectivity/Nat464Xlat.java b/service/src/com/android/server/connectivity/Nat464Xlat.java
index a979681..37aef22 100644
--- a/service/src/com/android/server/connectivity/Nat464Xlat.java
+++ b/service/src/com/android/server/connectivity/Nat464Xlat.java
@@ -20,6 +20,7 @@
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 
 import static com.android.net.module.util.CollectionUtils.contains;
+import static com.android.net.module.util.HandlerUtils.ensureRunningOnHandlerThread;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -500,7 +501,7 @@
         // Once this code is converted to StateMachine, it will be possible to use deferMessage to
         // ensure it stays in STARTING state until the interfaceLinkStateChanged notification fires,
         // and possibly use a timeout (or provide some guarantees at the lower layer) to address #1.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarting() || !up || !Objects.equals(mIface, iface)) {
             return;
         }
@@ -524,7 +525,7 @@
      * Must be called on the handler thread.
      */
     public void handleInterfaceRemoved(String iface) {
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!Objects.equals(mIface, iface)) {
             return;
         }
@@ -546,7 +547,7 @@
     @Nullable
     public Inet6Address translateV4toV6(@NonNull Inet4Address addr) {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarted()) return null;
 
         return convertv4ToClatv6(mNat64PrefixInUse, addr);
@@ -574,7 +575,7 @@
     @Nullable
     public Inet6Address getClatv6SrcAddress() {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
 
         return mIPv6Address;
     }
@@ -585,7 +586,7 @@
     @Nullable
     public Inet4Address getClatv4SrcAddress() {
         // Variables in Nat464Xlat should only be accessed from handler thread.
-        ensureRunningOnHandlerThread();
+        ensureRunningOnHandlerThread(mNetwork.handler());
         if (!isStarted()) return null;
 
         final LinkAddress v4Addr = getLinkAddress(mIface);
@@ -594,13 +595,6 @@
         return (Inet4Address) v4Addr.getAddress();
     }
 
-    private void ensureRunningOnHandlerThread() {
-        if (mNetwork.handler().getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on handler thread: " + Thread.currentThread().getName());
-        }
-    }
-
     /**
      * Dump the NAT64 xlat information.
      *
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index 76993a6..94b655f 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -68,6 +68,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.WakeupMessage;
+import com.android.net.module.util.HandlerUtils;
 import com.android.server.ConnectivityService;
 
 import java.io.PrintWriter;
@@ -1138,11 +1139,7 @@
      *         already present.
      */
     public boolean addRequest(NetworkRequest networkRequest) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         NetworkRequest existing = mNetworkRequests.get(networkRequest.requestId);
         if (existing == networkRequest) return false;
         if (existing != null) {
@@ -1161,11 +1158,7 @@
      * Remove the specified request from this network.
      */
     public void removeRequest(int requestId) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         NetworkRequest existing = mNetworkRequests.get(requestId);
         if (existing == null) return;
         updateRequestCounts(REMOVE, existing);
@@ -1187,11 +1180,7 @@
      * network.
      */
     public NetworkRequest requestAt(int index) {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         return mNetworkRequests.valueAt(index);
     }
 
@@ -1222,11 +1211,7 @@
      * Returns the number of requests of any type currently satisfied by this network.
      */
     public int numNetworkRequests() {
-        if (mHandler.getLooper().getThread() != Thread.currentThread()) {
-            throw new IllegalStateException(
-                    "Not running on ConnectivityService thread: "
-                            + Thread.currentThread().getName());
-        }
+        HandlerUtils.ensureRunningOnHandlerThread(mHandler);
         return mNetworkRequests.size();
     }
 
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 85258f8..66e1dad 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -435,6 +435,7 @@
     sdk_version: "core_platform",
     srcs: [
         "device/com/android/net/module/util/FdEventsReader.java",
+        "device/com/android/net/module/util/HandlerUtils.java",
         "device/com/android/net/module/util/SharedLog.java",
         "framework/com/android/net/module/util/ByteUtils.java",
         "framework/com/android/net/module/util/CollectionUtils.java",
@@ -649,6 +650,7 @@
     name: "net-utils-all-srcs",
     srcs: [
         "device/**/*.java",
+        ":framework-connectivity-shared-srcs",
         ":net-utils-framework-common-srcs",
     ],
     visibility: ["//visibility:private"],
diff --git a/staticlibs/device/com/android/net/module/util/HandlerUtils.java b/staticlibs/device/com/android/net/module/util/HandlerUtils.java
index c620368..991df8f 100644
--- a/staticlibs/device/com/android/net/module/util/HandlerUtils.java
+++ b/staticlibs/device/com/android/net/module/util/HandlerUtils.java
@@ -102,4 +102,37 @@
         if (e != null) throw e;
         return true;
     }
+
+    /**
+     * Ensures that the current running thread is the same as the thread associated with the given
+     * handler.
+     *
+     * @param handler The handler whose thread to compare.
+     * @throws IllegalStateException if the thread associated with the given handler is not the same
+     *                               as the current running thread.
+     * @hide
+     */
+    public static void ensureRunningOnHandlerThread(@NonNull Handler handler) {
+        if (!isRunningOnHandlerThread(handler)) {
+            throw new IllegalStateException(
+                    "Not running on Handler thread: " + Thread.currentThread().getName());
+        }
+    }
+
+    /**
+     * Checks if the current running thread is the same as the thread associated with the given
+     * handler.
+     *
+     * @param handler The handler whose thread to compare.
+     * @return {@code true} if the thread associated with the given handler is the same as the
+     *         current running thread, {@code false} otherwise.
+     *
+     * @hide
+     */
+    public static boolean isRunningOnHandlerThread(@NonNull Handler handler) {
+        if (handler.getLooper().getThread() == Thread.currentThread()) {
+            return true;
+        }
+        return false;
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl b/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl
new file mode 100644
index 0000000..cc1c19c
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/IIpv4PrefixRequest.aidl
@@ -0,0 +1,29 @@
+/*
+ * 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.net.IpPrefix;
+import android.net.LinkAddress;
+
+/** @hide */
+// TODO: b/350630377 - This @Descriptor annotation workaround is to prevent the class from being
+// jarjared which changes the DESCRIPTOR and casues "java.lang.SecurityException: Binder invocation
+// to an incorrect interface" when calling the IPC.
+@Descriptor("value=no.jarjar.com.android.net.module.util.IIpv4PrefixRequest")
+interface IIpv4PrefixRequest {
+    void onIpv4PrefixConflict(in IpPrefix ipPrefix);
+}
diff --git a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
index 72a4a94..7688e6a 100644
--- a/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
+++ b/staticlibs/device/com/android/net/module/util/IRoutingCoordinator.aidl
@@ -16,8 +16,14 @@
 
 package com.android.net.module.util;
 
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 
+import com.android.net.module.util.IIpv4PrefixRequest;
+
 /** @hide */
 // TODO: b/350630377 - This @Descriptor annotation workaround is to prevent the DESCRIPTOR from
 // being jarjared which changes the DESCRIPTOR and casues "java.lang.SecurityException: Binder
@@ -96,4 +102,41 @@
     *         cause of the failure.
     */
     void removeInterfaceForward(in String fromIface, in String toIface);
+
+    /** Update the prefix of an upstream. */
+    void updateUpstreamPrefix(in @nullable LinkProperties lp,
+                              in @nullable NetworkCapabilities nc,
+                              in Network network);
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    void removeUpstreamPrefix(in Network network);
+
+    /** Remove the deprecated upstream networks if any. */
+    void maybeRemoveDeprecatedUpstreams();
+
+   /**
+    * Request an IPv4 address for the downstream. Return the last time used address for the
+    * provided (interfaceType, scope) pair if possible.
+    *
+    * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+    * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+    * @param request a {@link IIpv4PrefixRequest} to report conflicts
+    * @return an IPv4 address allocated for the downstream, could be null
+    */
+    @nullable
+    LinkAddress requestStickyDownstreamAddress(
+            in int interfaceType,
+            in int scope,
+            in IIpv4PrefixRequest request);
+   /**
+    * Request an IPv4 address for the downstream.
+    *
+    * @param request a {@link IIpv4PrefixRequest} to report conflicts
+    * @return an IPv4 address allocated for the downstream, could be null
+    */
+    @nullable
+    LinkAddress requestDownstreamAddress(in IIpv4PrefixRequest request);
+
+    /** Release the IPv4 address allocated for the downstream. */
+    void releaseDownstream(in IIpv4PrefixRequest request);
 }
diff --git a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
similarity index 74%
rename from Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
rename to staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
index 1d5df61..7fcbd4e 100644
--- a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
+++ b/staticlibs/device/com/android/net/module/util/PrivateAddressCoordinator.java
@@ -13,7 +13,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.android.networkstack.tethering;
+package com.android.net.module.util;
 
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -24,24 +24,22 @@
 import static com.android.net.module.util.Inet4AddressUtils.inet4AddressToIntHTH;
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
 import static com.android.net.module.util.Inet4AddressUtils.prefixLengthToV4NetmaskIntHTH;
-import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 
 import static java.util.Arrays.asList;
 
+import android.content.Context;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkCapabilities;
-import android.net.ip.IpServer;
+import android.os.RemoteException;
 import android.util.ArrayMap;
-import android.util.ArraySet;
+import android.util.IndentingPrintWriter;
 
-import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.VisibleForTesting;
-import com.android.internal.util.IndentingPrintWriter;
 
 import java.net.Inet4Address;
 import java.net.InetAddress;
@@ -49,6 +47,8 @@
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Random;
 import java.util.Set;
 import java.util.function.Supplier;
@@ -60,39 +60,66 @@
  * coordinator is responsible for recording all of network assigned addresses and dispatched
  * free address to downstream interfaces.
  *
- * This class is not thread-safe and should be accessed on the same tethering internal thread.
+ * This class is not thread-safe.
  * @hide
  */
 public class PrivateAddressCoordinator {
     // WARNING: Keep in sync with chooseDownstreamAddress
     public static final int PREFIX_LENGTH = 24;
 
+    public static final String TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION =
+            "tether_force_random_prefix_base_selection";
+
     // Upstream monitor would be stopped when tethering is down. When tethering restart, downstream
     // address may be requested before coordinator get current upstream notification. To ensure
     // coordinator do not select conflict downstream prefix, mUpstreamPrefixMap would not be cleared
     // when tethering is down. Instead tethering would remove all deprecated upstreams from
     // mUpstreamPrefixMap when tethering is starting. See #maybeRemoveDeprecatedUpstreams().
     private final ArrayMap<Network, List<IpPrefix>> mUpstreamPrefixMap;
-    private final ArraySet<IpServer> mDownstreams;
+    // The downstreams are indexed by Ipv4PrefixRequest, which is a wrapper of the Binder object of
+    // IIpv4PrefixRequest.
+    private final ArrayMap<Ipv4PrefixRequest, LinkAddress> mDownstreams;
     private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
     private static final String LEGACY_BLUETOOTH_IFACE_ADDRESS = "192.168.44.1/24";
     private final List<IpPrefix> mTetheringPrefixes;
     // A supplier that returns ConnectivityManager#getAllNetworks.
     private final Supplier<Network[]> mGetAllNetworksSupplier;
-    private final boolean mIsRandomPrefixBaseEnabled;
-    private final boolean mShouldEnableWifiP2pDedicatedIp;
+    private final Dependencies mDeps;
     // keyed by downstream type(TetheringManager.TETHERING_*).
     private final ArrayMap<AddressKey, LinkAddress> mCachedAddresses;
     private final Random mRandom;
 
+    /** Capture PrivateAddressCoordinator dependencies for injection. */
+    public static class Dependencies {
+        private final Context mContext;
+
+        Dependencies(Context context) {
+            mContext = context;
+        }
+
+        /**
+         * Check whether or not one specific experimental feature is enabled according to {@link
+         * DeviceConfigUtils}.
+         *
+         * @param featureName The feature's name to look up.
+         * @return true if this feature is enabled, or false if disabled.
+         */
+        public boolean isFeatureEnabled(String featureName) {
+            return DeviceConfigUtils.isTetheringFeatureEnabled(mContext, featureName);
+        }
+    }
+
+    public PrivateAddressCoordinator(Supplier<Network[]> getAllNetworksSupplier, Context context) {
+        this(getAllNetworksSupplier, new Dependencies(context));
+    }
+
+    @VisibleForTesting
     public PrivateAddressCoordinator(Supplier<Network[]> getAllNetworksSupplier,
-            boolean isRandomPrefixBase,
-            boolean shouldEnableWifiP2pDedicatedIp) {
-        mDownstreams = new ArraySet<>();
+                                     Dependencies deps) {
+        mDownstreams = new ArrayMap<>();
         mUpstreamPrefixMap = new ArrayMap<>();
         mGetAllNetworksSupplier = getAllNetworksSupplier;
-        mIsRandomPrefixBaseEnabled = isRandomPrefixBase;
-        mShouldEnableWifiP2pDedicatedIp = shouldEnableWifiP2pDedicatedIp;
+        mDeps = deps;
         mCachedAddresses = new ArrayMap<AddressKey, LinkAddress>();
         // Reserved static addresses for bluetooth and wifi p2p.
         mCachedAddresses.put(new AddressKey(TETHERING_BLUETOOTH, CONNECTIVITY_SCOPE_GLOBAL),
@@ -141,12 +168,18 @@
     }
 
     private void handleMaybePrefixConflict(final List<IpPrefix> prefixes) {
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
+        for (Map.Entry<Ipv4PrefixRequest, LinkAddress> entry : mDownstreams.entrySet()) {
+            final Ipv4PrefixRequest request = entry.getKey();
+            final LinkAddress downstream = entry.getValue();
+            final IpPrefix target = asIpPrefix(downstream);
 
             for (IpPrefix source : prefixes) {
                 if (isConflictPrefix(source, target)) {
-                    downstream.sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
+                    try {
+                        request.getRequest().onIpv4PrefixConflict(target);
+                    } catch (RemoteException ignored) {
+                        // ignore
+                    }
                     break;
                 }
             }
@@ -172,37 +205,51 @@
         mUpstreamPrefixMap.removeAll(toBeRemoved);
     }
 
+    // TODO: There needs to be a reserveDownstreamAddress() method for the cases where
+    // TetheringRequest has been set a static IPv4 address.
+
     /**
-     * Pick a random available address and mark its prefix as in use for the provided IpServer,
-     * returns null if there is no available address.
+     * Request a downstream address for the provided IIpv4PrefixRequest.
+     *
+     * This method will first try to return the last time used address for the provided
+     * (interfaceType, scope) pair if possible. If not, it will pick a random available address and
+     * mark its prefix as in use for the provided IIpv4PrefixRequest.
      */
     @Nullable
-    public LinkAddress requestDownstreamAddress(final IpServer ipServer, final int scope,
-            boolean useLastAddress) {
-        if (mShouldEnableWifiP2pDedicatedIp
-                && ipServer.interfaceType() == TETHERING_WIFI_P2P) {
-            return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
-        }
-
-        final AddressKey addrKey = new AddressKey(ipServer.interfaceType(), scope);
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, final int scope,
+            IIpv4PrefixRequest request) {
+        final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
+        final AddressKey addrKey = new AddressKey(interfaceType, scope);
         // This ensures that tethering isn't started on 2 different interfaces with the same type.
         // Once tethering could support multiple interface with the same type,
         // TetheringSoftApCallback would need to handle it among others.
         final LinkAddress cachedAddress = mCachedAddresses.get(addrKey);
-        if (useLastAddress && cachedAddress != null
-                && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
-            mDownstreams.add(ipServer);
+        if (cachedAddress != null && !isConflictWithUpstream(asIpPrefix(cachedAddress))) {
+            mDownstreams.put(wrappedRequest, cachedAddress);
             return cachedAddress;
         }
 
+        final LinkAddress newAddress = requestDownstreamAddress(request);
+        if (newAddress != null) {
+            mCachedAddresses.put(addrKey, newAddress);
+        }
+        return newAddress;
+    }
+
+    /**
+     * Pick a random available address and mark its prefix as in use for the provided
+     * IIpv4PrefixRequest. Return null if there is no available address.
+     */
+    @Nullable
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        final Ipv4PrefixRequest wrappedRequest = new Ipv4PrefixRequest(request);
         final int prefixIndex = getRandomPrefixIndex();
         for (int i = 0; i < mTetheringPrefixes.size(); i++) {
             final IpPrefix prefixRange = mTetheringPrefixes.get(
                     (prefixIndex + i) % mTetheringPrefixes.size());
             final LinkAddress newAddress = chooseDownstreamAddress(prefixRange);
             if (newAddress != null) {
-                mDownstreams.add(ipServer);
-                mCachedAddresses.put(addrKey, newAddress);
+                mDownstreams.put(wrappedRequest, newAddress);
                 return newAddress;
             }
         }
@@ -212,7 +259,7 @@
     }
 
     private int getRandomPrefixIndex() {
-        if (!mIsRandomPrefixBaseEnabled) return 0;
+        if (!mDeps.isFeatureEnabled(TETHER_FORCE_RANDOM_PREFIX_BASE_SELECTION)) return 0;
 
         final int random = getRandomInt() & 0xffffff;
         // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
@@ -305,8 +352,8 @@
     }
 
     /** Release downstream record for IpServer. */
-    public void releaseDownstream(final IpServer ipServer) {
-        mDownstreams.remove(ipServer);
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        mDownstreams.remove(new Ipv4PrefixRequest(request));
     }
 
     /** Clear current upstream prefixes records. */
@@ -346,8 +393,8 @@
 
         // IpServer may use manually-defined address (mStaticIpv4ServerAddr) which does not include
         // in mCachedAddresses.
-        for (IpServer downstream : mDownstreams) {
-            final IpPrefix target = getDownstreamPrefix(downstream);
+        for (LinkAddress downstream : mDownstreams.values()) {
+            final IpPrefix target = asIpPrefix(downstream);
 
             if (isConflictPrefix(prefix, target)) return target;
         }
@@ -355,11 +402,33 @@
         return null;
     }
 
-    @NonNull
-    private IpPrefix getDownstreamPrefix(final IpServer downstream) {
-        final LinkAddress address = downstream.getAddress();
+    private static IpPrefix asIpPrefix(LinkAddress addr) {
+        return new IpPrefix(addr.getAddress(), addr.getPrefixLength());
+    }
 
-        return asIpPrefix(address);
+    private static final class Ipv4PrefixRequest {
+        private final IIpv4PrefixRequest mRequest;
+
+        Ipv4PrefixRequest(IIpv4PrefixRequest request) {
+            mRequest = request;
+        }
+
+        public IIpv4PrefixRequest getRequest() {
+            return mRequest;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (this == obj) return true;
+            if (!(obj instanceof Ipv4PrefixRequest)) return false;
+            return Objects.equals(
+                    mRequest.asBinder(), ((Ipv4PrefixRequest) obj).mRequest.asBinder());
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(mRequest.asBinder());
+        }
     }
 
     private static class AddressKey {
@@ -390,6 +459,7 @@
         }
     }
 
+    // TODO: dump PrivateAddressCoordinator when dumping RoutingCoordinatorService.
     void dump(final IndentingPrintWriter pw) {
         pw.println("mTetheringPrefixes:");
         pw.increaseIndent();
@@ -407,8 +477,8 @@
 
         pw.println("mDownstreams:");
         pw.increaseIndent();
-        for (IpServer ipServer : mDownstreams) {
-            pw.println(ipServer.interfaceType() + " - " + ipServer.getAddress());
+        for (LinkAddress downstream : mDownstreams.values()) {
+            pw.println(downstream);
         }
         pw.decreaseIndent();
 
diff --git a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
index 02e3643..f5af30c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorManager.java
@@ -17,17 +17,27 @@
 package com.android.net.module.util;
 
 import android.content.Context;
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 import android.os.IBinder;
 import android.os.RemoteException;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 /**
  * A manager class for talking to the routing coordinator service.
  *
  * This class should only be used by the connectivity and tethering module. This is enforced
  * by the build rules. Do not change build rules to gain access to this class from elsewhere.
+ *
+ * This class has following functionalities:
+ * - Manage routes and forwarding for networks.
+ * - Manage IPv4 prefix allocation for network interfaces.
+ *
  * @hide
  */
 public class RoutingCoordinatorManager {
@@ -154,4 +164,77 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    // PrivateAddressCoordinator methods:
+
+    /** Update the prefix of an upstream. */
+    public void updateUpstreamPrefix(LinkProperties lp, NetworkCapabilities nc, Network network) {
+        try {
+            mService.updateUpstreamPrefix(lp, nc, network);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    public void removeUpstreamPrefix(Network network) {
+        try {
+            mService.removeUpstreamPrefix(network);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Remove the deprecated upstream networks if any. */
+    public void maybeRemoveDeprecatedUpstreams() {
+        try {
+            mService.maybeRemoveDeprecatedUpstreams();
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Request an IPv4 address for the downstream. Return the last time used address for the
+     * provided (interfaceType, scope) pair if possible.
+     *
+     * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+     * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Nullable
+    public LinkAddress requestStickyDownstreamAddress(
+            int interfaceType,
+            int scope,
+            IIpv4PrefixRequest request) {
+        try {
+            return mService.requestStickyDownstreamAddress(interfaceType, scope, request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Request an IPv4 address for the downstream.
+     *
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        try {
+            return mService.requestDownstreamAddress(request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /** Release the IPv4 address allocated for the downstream. */
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        try {
+            mService.releaseDownstream(request);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
index c75b860..51eb47c 100644
--- a/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
+++ b/staticlibs/device/com/android/net/module/util/RoutingCoordinatorService.java
@@ -19,8 +19,13 @@
 import static com.android.net.module.util.NetdUtils.toRouteInfoParcel;
 
 import android.annotation.NonNull;
+import android.content.Context;
 import android.net.INetd;
 
+import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.RouteInfo;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -28,8 +33,10 @@
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
 
 import java.util.Objects;
+import java.util.function.Supplier;
 
 /**
  * Class to coordinate routing across multiple clients.
@@ -45,8 +52,22 @@
     private static final String TAG = RoutingCoordinatorService.class.getSimpleName();
     private final INetd mNetd;
 
-    public RoutingCoordinatorService(@NonNull INetd netd) {
+    private final Object mPrivateAddressCoordinatorLock = new Object();
+    @GuardedBy("mPrivateAddressCoordinatorLock")
+    private final PrivateAddressCoordinator mPrivateAddressCoordinator;
+
+    public RoutingCoordinatorService(@NonNull INetd netd,
+                                     @NonNull Supplier<Network[]> getAllNetworksSupplier,
+                                     @NonNull Context context) {
+        this(netd, getAllNetworksSupplier, new PrivateAddressCoordinator.Dependencies(context));
+    }
+
+    @VisibleForTesting
+    public RoutingCoordinatorService(@NonNull INetd netd,
+                                     @NonNull Supplier<Network[]> getAllNetworksSupplier,
+                                     @NonNull PrivateAddressCoordinator.Dependencies pacDeps) {
         mNetd = netd;
+        mPrivateAddressCoordinator = new PrivateAddressCoordinator(getAllNetworksSupplier, pacDeps);
     }
 
     /**
@@ -225,4 +246,91 @@
             }
         }
     }
+
+    // PrivateAddressCoordinator methods:
+
+    /** Update the prefix of an upstream. */
+    @Override
+    public void updateUpstreamPrefix(LinkProperties lp, NetworkCapabilities nc, Network network) {
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.updateUpstreamPrefix(lp, nc, network);
+                    }
+                });
+    }
+
+    /** Remove the upstream prefix of the given {@link Network}. */
+    @Override
+    public void removeUpstreamPrefix(Network network) {
+        Objects.requireNonNull(network);
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.removeUpstreamPrefix(network);
+                    }
+                });
+    }
+
+    /** Remove the deprecated upstream networks if any. */
+    @Override
+    public void maybeRemoveDeprecatedUpstreams() {
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.maybeRemoveDeprecatedUpstreams();
+                    }
+                });
+    }
+
+    /**
+     * Request an IPv4 address for the downstream. Return the last time used address for the
+     * provided (interfaceType, scope) pair if possible.
+     *
+     * @param interfaceType the Tethering type (see TetheringManager#TETHERING_*).
+     * @param scope CONNECTIVITY_SCOPE_GLOBAL or CONNECTIVITY_SCOPE_LOCAL
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Override
+    public LinkAddress requestStickyDownstreamAddress(int interfaceType, int scope,
+            IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        return BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        return mPrivateAddressCoordinator.requestStickyDownstreamAddress(
+                                interfaceType, scope, request);
+                    }
+                });
+    }
+
+    /**
+     * Request an IPv4 address for the downstream.
+     *
+     * @param request a {@link IIpv4PrefixRequest} to report conflicts
+     * @return an IPv4 address allocated for the downstream, could be null
+     */
+    @Override
+    public LinkAddress requestDownstreamAddress(IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        return BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        return mPrivateAddressCoordinator.requestDownstreamAddress(request);
+                    }
+                });
+    }
+
+    /** Release the IPv4 address allocated for the downstream. */
+    @Override
+    public void releaseDownstream(IIpv4PrefixRequest request) {
+        Objects.requireNonNull(request);
+        BinderUtils.withCleanCallingIdentity(
+                () -> {
+                    synchronized (mPrivateAddressCoordinatorLock) {
+                        mPrivateAddressCoordinator.releaseDownstream(request);
+                    }
+                });
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
index f34159e..541a375 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
@@ -30,7 +30,6 @@
 import static android.system.OsConstants.SO_RCVTIMEO;
 import static android.system.OsConstants.SO_SNDTIMEO;
 
-import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
 import static com.android.net.module.util.netlink.NetlinkConstants.hexify;
 import static com.android.net.module.util.netlink.NetlinkConstants.NLMSG_DONE;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTNL_FAMILY_IP6MR;
@@ -58,7 +57,6 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 
 /**
@@ -227,96 +225,6 @@
     }
 
     /**
-     * Sends an RTM_NEWLINK message to kernel to set a network interface up or down.
-     *
-     * @param ifName  The name of the network interface to modify.
-     * @param isUp    {@code true} to set the interface up, {@code false} to set it down.
-     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
-     */
-    public static boolean sendRtmSetLinkStateRequest(@NonNull String ifName, boolean isUp) {
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
-                ifName, 1 /*sequenceNumber*/, isUp);
-        if (msg == null) {
-            return false;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
-            return true;
-        } catch (ErrnoException e) {
-            Log.e(TAG, "Fail to set the interface " + ifName + " " + (isUp ? "up" : "down"), e);
-            return false;
-        }
-    }
-
-    /**
-     * Sends an RTM_NEWLINK message to kernel to rename a network interface.
-     *
-     * @param ifName     The current name of the network interface.
-     * @param newIfName  The new name to assign to the interface.
-     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
-     */
-    public static boolean sendRtmSetLinkNameRequest(
-            @NonNull String ifName, @NonNull String newIfName) {
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
-                ifName, 1 /*sequenceNumber*/, newIfName);
-        if (msg == null) {
-            return false;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
-            return true;
-        } catch (ErrnoException e) {
-            Log.e(TAG, "Fail to rename the interface from " + ifName + " to " + newIfName, e);
-            return false;
-        }
-    }
-
-    /**
-     * Gets the information of a network interface using a Netlink message.
-     * <p>
-     * This method sends a Netlink message to the kernel to request information about the specified
-     * network interface and returns a {@link RtNetlinkLinkMessage} containing the interface status.
-     *
-     * @param ifName The name of the network interface to query.
-     * @return An {@link RtNetlinkLinkMessage} containing the interface status, or {@code null} if
-     *         the interface does not exist or an error occurred during the query.
-     */
-    @Nullable
-    public static RtNetlinkLinkMessage getLinkRequest(@NonNull String ifName) {
-        final int ifIndex = new OsAccess().if_nametoindex(ifName);
-        if (ifIndex == OsAccess.INVALID_INTERFACE_INDEX) {
-            return null;
-        }
-
-        final AtomicReference<RtNetlinkLinkMessage> recvMsg = new AtomicReference<>();
-        final Consumer<RtNetlinkLinkMessage> handleNlMsg = (msg) -> {
-            if (msg.getHeader().nlmsg_type == RTM_NEWLINK
-                    && msg.getIfinfoHeader().index == ifIndex) {
-                recvMsg.set(msg);
-            }
-        };
-
-        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createGetLinkMessage(
-                ifName, 1 /*sequenceNumber*/);
-        if (msg == null) {
-            return null;
-        }
-
-        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
-        try {
-            NetlinkUtils.getAndProcessNetlinkDumpMessages(
-                    bytes, NETLINK_ROUTE, RtNetlinkLinkMessage.class, handleNlMsg);
-        } catch (SocketException | InterruptedIOException | ErrnoException e) {
-            // Nothing we can do here.
-        }
-        return recvMsg.get();
-    }
-
-    /**
      * Create netlink socket with the given netlink protocol type and buffersize.
      *
      * @param nlProto the netlink protocol
diff --git a/staticlibs/native/timerfdutils/Android.bp b/staticlibs/native/timerfdutils/Android.bp
new file mode 100644
index 0000000..939a2d2
--- /dev/null
+++ b/staticlibs/native/timerfdutils/Android.bp
@@ -0,0 +1,46 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_team: "trendy_team_fwk_core_networking",
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+cc_library_static {
+    name: "libnet_utils_device_common_timerfdjni",
+    srcs: [
+        "com_android_net_module_util_TimerFdUtils.cpp",
+    ],
+    header_libs: [
+        "jni_headers",
+    ],
+    shared_libs: [
+        "liblog",
+        "libnativehelper_compat_libc++",
+    ],
+    cflags: [
+        "-Wall",
+        "-Werror",
+        "-Wno-unused-parameter",
+    ],
+    sdk_version: "current",
+    min_sdk_version: "30",
+    apex_available: [
+        "com.android.tethering",
+        "//apex_available:platform",
+    ],
+    visibility: [
+        "//packages/modules/Connectivity:__subpackages__",
+    ],
+}
diff --git a/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp b/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
new file mode 100644
index 0000000..c4c960d
--- /dev/null
+++ b/staticlibs/native/timerfdutils/com_android_net_module_util_TimerFdUtils.cpp
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <errno.h>
+#include <jni.h>
+#include <nativehelper/JNIHelp.h>
+#include <nativehelper/scoped_utf_chars.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#include <time.h>
+#include <unistd.h>
+
+#define MSEC_PER_SEC 1000
+#define NSEC_PER_MSEC 1000000
+
+namespace android {
+
+static jint
+com_android_net_module_util_TimerFdUtils_createTimerFd(JNIEnv *env,
+                                                       jclass clazz) {
+  int tfd;
+  tfd = timerfd_create(CLOCK_BOOTTIME, 0);
+  if (tfd == -1) {
+    jniThrowErrnoException(env, "createTimerFd", tfd);
+  }
+  return tfd;
+}
+
+static void
+com_android_net_module_util_TimerFdUtils_setTime(JNIEnv *env, jclass clazz,
+                                                 jint tfd, jlong milliseconds) {
+  struct itimerspec new_value;
+  new_value.it_value.tv_sec = milliseconds / MSEC_PER_SEC;
+  new_value.it_value.tv_nsec = (milliseconds % MSEC_PER_SEC) * NSEC_PER_MSEC;
+  // Set the interval time to 0 because it's designed for repeated timer expirations after the
+  // initial expiration, which doesn't fit the current usage.
+  new_value.it_interval.tv_sec = 0;
+  new_value.it_interval.tv_nsec = 0;
+
+  int ret = timerfd_settime(tfd, 0, &new_value, NULL);
+  if (ret == -1) {
+    jniThrowErrnoException(env, "setTime", ret);
+  }
+}
+
+/*
+ * JNI registration.
+ */
+static const JNINativeMethod gMethods[] = {
+    /* name, signature, funcPtr */
+    {"createTimerFd", "()I",
+     (void *)com_android_net_module_util_TimerFdUtils_createTimerFd},
+    {"setTime", "(IJ)V",
+     (void *)com_android_net_module_util_TimerFdUtils_setTime},
+};
+
+int register_com_android_net_module_util_TimerFdUtils(JNIEnv *env,
+                                                      char const *class_name) {
+  return jniRegisterNativeMethods(env, class_name, gMethods, NELEM(gMethods));
+}
+
+}; // namespace android
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index 2885460..419b338 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -29,6 +29,10 @@
     get_ipv6_addresses,
     get_hardware_address,
     is_send_raw_packet_downstream_supported,
+    is_packet_capture_supported,
+    start_capture_packets,
+    stop_capture_packets,
+    get_matched_packet_counts,
     send_raw_packet_downstream,
 )
 from net_tests_utils.host.python.assert_utils import UnexpectedBehaviorError
@@ -208,6 +212,144 @@
         "Send raw packet should not be supported.",
     )
 
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "success"  # Successful command output
+      start_capture_packets(
+          self.mock_ad, TEST_IFACE_NAME
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture start"
+          f" {TEST_IFACE_NAME}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          start_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Start capturing packets should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_start_capture_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          start_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Start capturing packets should not be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "success"  # Successful command output
+      stop_capture_packets(
+          self.mock_ad, TEST_IFACE_NAME
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture stop"
+          f" {TEST_IFACE_NAME}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          stop_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Stop capturing packets should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_stop_capture_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          stop_capture_packets(
+              self.mock_ad, TEST_IFACE_NAME
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Stop capturing packets should not be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_success(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = "10"  # Successful command output
+      get_matched_packet_counts(
+          self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+      )
+      mock_adb_shell.assert_called_once_with(
+          self.mock_ad,
+          "cmd network_stack capture matched-packet-counts"
+          f" {TEST_IFACE_NAME} {TEST_PACKET_IN_HEX}"
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_failure(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.return_value = (  # Unexpected command output
+          "Any Unexpected Output"
+      )
+      with asserts.assert_raises(UnexpectedBehaviorError):
+          get_matched_packet_counts(
+              self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+          )
+      asserts.assert_true(
+          is_packet_capture_supported(self.mock_ad),
+          "Get matched packet counts should be supported.",
+      )
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_matched_packet_counts_unsupported(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+      mock_adb_shell.side_effect = AdbError(
+          cmd="", stdout="Unknown command", stderr="", ret_code=3
+      )
+      with asserts.assert_raises(UnsupportedOperationException):
+          get_matched_packet_counts(
+              self.mock_ad, TEST_IFACE_NAME, TEST_PACKET_IN_HEX
+          )
+      asserts.assert_false(
+          is_packet_capture_supported(self.mock_ad),
+          "Get matched packet counts should not be supported.",
+      )
+
   @parameterized.parameters(
       ("2,2048,1", ApfCapabilities(2, 2048, 1)),  # Valid input
       ("3,1024,0", ApfCapabilities(3, 1024, 0)),  # Valid input
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
index f2c902f..845a2c3 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/HandlerUtilsTest.kt
@@ -19,11 +19,14 @@
 import android.os.HandlerThread
 import com.android.testutils.DevSdkIgnoreRunner
 import com.android.testutils.DevSdkIgnoreRunner.MonitorThreadLeak
+import com.android.testutils.waitForIdle
 import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 import org.junit.After
 import org.junit.Test
 import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
 
 const val THREAD_BLOCK_TIMEOUT_MS = 1000L
 const val TEST_REPEAT_COUNT = 100
@@ -52,6 +55,24 @@
         }
     }
 
+    @Test
+    fun testIsRunningOnHandlerThread() {
+        assertFalse(HandlerUtils.isRunningOnHandlerThread(handler))
+        handler.post{
+            assertTrue(HandlerUtils.isRunningOnHandlerThread(handler))
+        }
+        handler.waitForIdle(THREAD_BLOCK_TIMEOUT_MS)
+    }
+
+    @Test
+    fun testEnsureRunningOnHandlerThread() {
+        assertFailsWith<IllegalStateException>{ HandlerUtils.ensureRunningOnHandlerThread(handler) }
+        handler.post{
+            HandlerUtils.ensureRunningOnHandlerThread(handler)
+        }
+        handler.waitForIdle(THREAD_BLOCK_TIMEOUT_MS)
+    }
+
     @After
     fun tearDown() {
         handlerThread.quitSafely()
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt b/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
index b04561c..035ce0f 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/RoutingCoordinatorServiceTest.kt
@@ -16,7 +16,9 @@
 
 package com.android.net.module.util
 
+import android.content.Context
 import android.net.INetd
+import android.net.Network
 import android.os.Build
 import android.util.Log
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
@@ -34,7 +36,9 @@
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
 class RoutingCoordinatorServiceTest {
     val mNetd = mock(INetd::class.java)
-    val mService = RoutingCoordinatorService(mNetd)
+    val mGetAllNetworksSupplier = { emptyArray<Network>() }
+    val mContext = mock(Context::class.java)
+    val mService = RoutingCoordinatorService(mNetd, mGetAllNetworksSupplier, mContext)
 
     @Test
     fun testInterfaceForward() {
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 8c71a91..13e1dc0 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -33,6 +33,7 @@
     ],
     static_libs: [
         "androidx.test.ext.junit",
+        "collector-device-lib",
         "kotlin-reflect",
         "libnanohttpd",
         "net-tests-utils-host-device-common",
diff --git a/staticlibs/testutils/devicetests/NSResponder.kt b/staticlibs/testutils/devicetests/NSResponder.kt
index f7619cd..f094407 100644
--- a/staticlibs/testutils/devicetests/NSResponder.kt
+++ b/staticlibs/testutils/devicetests/NSResponder.kt
@@ -35,12 +35,12 @@
 private const val NS_TYPE = 135.toShort()
 
 /**
- * A class that can be used to reply to Neighbor Solicitation packets on a [TapPacketReader].
+ * A class that can be used to reply to Neighbor Solicitation packets on a [PollPacketReader].
  */
 class NSResponder(
-    reader: TapPacketReader,
-    table: Map<Inet6Address, MacAddress>,
-    name: String = NSResponder::class.java.simpleName
+        reader: PollPacketReader,
+        table: Map<Inet6Address, MacAddress>,
+        name: String = NSResponder::class.java.simpleName
 ) : PacketResponder(reader, Icmpv6Filter(), name) {
     companion object {
         private val TAG = NSResponder::class.simpleName
@@ -49,7 +49,7 @@
     // Copy the map if not already immutable (toMap) to make sure it is not modified
     private val table = table.toMap()
 
-    override fun replyToPacket(packet: ByteArray, reader: TapPacketReader) {
+    override fun replyToPacket(packet: ByteArray, reader: PollPacketReader) {
         if (packet.size < IPV6_HEADER_LENGTH) {
             return
         }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt b/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
index cf0490c..f4c8657 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ArpResponder.kt
@@ -30,17 +30,17 @@
 private val ARP_REPLY_IPV4 = byteArrayOf(0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x02)
 
 /**
- * A class that can be used to reply to ARP packets on a [TapPacketReader].
+ * A class that can be used to reply to ARP packets on a [PollPacketReader].
  */
 class ArpResponder(
-    reader: TapPacketReader,
-    table: Map<Inet4Address, MacAddress>,
-    name: String = ArpResponder::class.java.simpleName
+        reader: PollPacketReader,
+        table: Map<Inet4Address, MacAddress>,
+        name: String = ArpResponder::class.java.simpleName
 ) : PacketResponder(reader, ArpRequestFilter(), name) {
     // Copy the map if not already immutable (toMap) to make sure it is not modified
     private val table = table.toMap()
 
-    override fun replyToPacket(packet: ByteArray, reader: TapPacketReader) {
+    override fun replyToPacket(packet: ByteArray, reader: PollPacketReader) {
         val targetIp = InetAddress.getByAddress(
                 packet.copyFromIndexWithLength(ARP_TARGET_IPADDR_OFFSET, 4))
                 as Inet4Address
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
index 93422ad..be6947f 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/AutoReleaseNetworkCallbackRule.kt
@@ -98,10 +98,10 @@
         cellRequestCb = null
     }
 
-    private fun addCallback(
-        cb: TestableNetworkCallback,
-        registrar: (TestableNetworkCallback) -> Unit
-    ): TestableNetworkCallback {
+    private fun <T> addCallback(
+        cb: T,
+        registrar: (NetworkCallback) -> Unit
+    ): T where T : NetworkCallback {
         registrar(cb)
         cbToCleanup.add(cb)
         return cb
@@ -142,17 +142,24 @@
     /**
      * File a callback for a NetworkRequest.
      *
-     * This will fail tests (throw) if the cell network cannot be obtained, or if it was already
-     * requested.
-     *
      * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
      * otherwise it will be automatically unrequested after the test.
      */
     @JvmOverloads
     fun registerNetworkCallback(
+        request: NetworkRequest
+    ): TestableNetworkCallback = registerNetworkCallback(request, TestableNetworkCallback())
+
+    /**
+     * File a callback for a NetworkRequest.
+     *
+     * Tests may call [unregisterNetworkCallback] once they are done using the returned [Network],
+     * otherwise it will be automatically unrequested after the test.
+     */
+    fun <T> registerNetworkCallback(
         request: NetworkRequest,
-        cb: TestableNetworkCallback = TestableNetworkCallback()
-    ) = addCallback(cb) { cm.registerNetworkCallback(request, it) }
+        cb: T
+    ) where T : NetworkCallback = addCallback(cb) { cm.registerNetworkCallback(request, it) }
 
     /**
      * @see ConnectivityManager.registerDefaultNetworkCallback
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
new file mode 100644
index 0000000..ea86281
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ConnectivityDiagnosticsCollector.kt
@@ -0,0 +1,340 @@
+/*
+ * 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.Manifest.permission.NETWORK_SETTINGS
+import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE
+import android.content.pm.PackageManager.FEATURE_TELEPHONY
+import android.content.pm.PackageManager.FEATURE_WIFI
+import android.device.collectors.BaseMetricListener
+import android.device.collectors.DataRecord
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.telephony.TelephonyManager
+import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.modules.utils.build.SdkLevel.isAtLeastS
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.PrintWriter
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import kotlin.test.assertNull
+import org.json.JSONObject
+import org.junit.AssumptionViolatedException
+import org.junit.runner.Description
+import org.junit.runner.Result
+import org.junit.runner.notification.Failure
+
+/**
+ * A diagnostics collector that outputs diagnostics files as test artifacts.
+ *
+ * <p>Collects diagnostics automatically by default on non-local builds. Can be enabled/disabled
+ * manually with:
+ * ```
+ * atest MyModule -- \
+ *     --module-arg MyModule:instrumentation-arg:connectivity-diagnostics-on-failure:=false
+ * ```
+ */
+class ConnectivityDiagnosticsCollector : BaseMetricListener() {
+    companion object {
+        private const val ARG_RUN_ON_FAILURE = "connectivity-diagnostics-on-failure"
+        private const val COLLECTOR_DIR = "run_listeners/connectivity_diagnostics"
+        private const val FILENAME_SUFFIX = "_conndiag.txt"
+        private const val MAX_DUMPS = 20
+
+        private val TAG = ConnectivityDiagnosticsCollector::class.simpleName
+        var instance: ConnectivityDiagnosticsCollector? = null
+    }
+
+    private var failureHeader: String? = null
+    private val buffer = ByteArrayOutputStream()
+    private val collectorDir: File by lazy {
+        createAndEmptyDirectory(COLLECTOR_DIR)
+    }
+    private val outputFiles = mutableSetOf<String>()
+    private val cbHelper = NetworkCallbackHelper()
+    private val networkCallback = MonitoringNetworkCallback()
+
+    inner class MonitoringNetworkCallback : NetworkCallback() {
+        val currentMobileDataNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentVpnNetworks = mutableMapOf<Network, NetworkCapabilities>()
+        val currentWifiNetworks = mutableMapOf<Network, NetworkCapabilities>()
+
+        override fun onLost(network: Network) {
+            currentWifiNetworks.remove(network)
+            currentMobileDataNetworks.remove(network)
+        }
+
+        override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
+            if (nc.hasTransport(TRANSPORT_VPN)) {
+                currentVpnNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_WIFI)) {
+                currentWifiNetworks[network] = nc
+            } else if (nc.hasTransport(TRANSPORT_CELLULAR)) {
+                currentMobileDataNetworks[network] = nc
+            }
+        }
+    }
+
+    override fun onSetUp() {
+        assertNull(instance, "ConnectivityDiagnosticsCollectors were set up multiple times")
+        instance = this
+        TryTestConfig.setDiagnosticsCollector { throwable ->
+            if (runOnFailure(throwable)) {
+                collectTestFailureDiagnostics(throwable)
+            }
+        }
+    }
+
+    override fun onCleanUp() {
+        instance = null
+    }
+
+    override fun onTestRunStart(runData: DataRecord?, description: Description?) {
+        runAsShell(NETWORK_SETTINGS) {
+            cbHelper.registerNetworkCallback(
+                NetworkRequest.Builder()
+                    .addCapability(NET_CAPABILITY_INTERNET)
+                    .addTransportType(TRANSPORT_WIFI)
+                    .addTransportType(TRANSPORT_CELLULAR)
+                    .build(), networkCallback
+            )
+        }
+    }
+
+    override fun onTestRunEnd(runData: DataRecord?, result: Result?) {
+        // onTestRunEnd is called regardless of success/failure, and the Result contains summary of
+        // run/failed/ignored... tests.
+        cbHelper.unregisterAll()
+    }
+
+    override fun onTestFail(testData: DataRecord, description: Description, failure: Failure) {
+        // TODO: find a way to disable this behavior only on local runs, to avoid slowing them down
+        // when iterating on failing tests.
+        if (!runOnFailure(failure.exception)) return
+        if (outputFiles.size >= MAX_DUMPS) return
+        Log.i(TAG, "Collecting diagnostics for test failure. Disable by running tests with: " +
+                "atest MyModule -- " +
+                "--module-arg MyModule:instrumentation-arg:$ARG_RUN_ON_FAILURE:=false")
+        collectTestFailureDiagnostics(failure.exception)
+
+        val baseFilename = "${description.className}#${description.methodName}_failure"
+        flushBufferToFileMetric(testData, baseFilename)
+    }
+
+    override fun onTestEnd(testData: DataRecord, description: Description) {
+        // Tests may call methods like collectDumpsysConnectivity to collect diagnostics at any time
+        // during the run, for example to observe state at various points to investigate a flake
+        // and compare passing/failing cases.
+        // Flush the contents of the buffer to a file when the test ends, even when successful.
+        if (buffer.size() == 0) return
+        if (outputFiles.size >= MAX_DUMPS) return
+
+        // Flush any data that the test added to the buffer for dumping
+        val baseFilename = "${description.className}#${description.methodName}_testdump"
+        flushBufferToFileMetric(testData, baseFilename)
+    }
+
+    private fun runOnFailure(exception: Throwable): Boolean {
+        // Assumption failures (assumeTrue/assumeFalse) are not actual failures
+        if (exception is AssumptionViolatedException) return false
+
+        // Do not run on local builds (which have ro.build.version.incremental set to eng.username)
+        // to avoid slowing down local runs.
+        val enabledByDefault = !Build.VERSION.INCREMENTAL.startsWith("eng.")
+        return argsBundle.getString(ARG_RUN_ON_FAILURE)?.toBooleanStrictOrNull() ?: enabledByDefault
+    }
+
+    private fun flushBufferToFileMetric(testData: DataRecord, baseFilename: String) {
+        var filename = baseFilename
+        // In case a method was run multiple times (typically retries), append a number
+        var i = 2
+        while (outputFiles.contains(filename)) {
+            filename = baseFilename + "_$i"
+            i++
+        }
+        val outFile = File(collectorDir, filename + FILENAME_SUFFIX)
+        outputFiles.add(filename)
+        FileOutputStream(outFile).use { fos ->
+            failureHeader?.let {
+                fos.write(it.toByteArray())
+                fos.write("\n".toByteArray())
+            }
+            fos.write(buffer.toByteArray())
+        }
+        failureHeader = null
+        buffer.reset()
+        val fileKey = "${ConnectivityDiagnosticsCollector::class.qualifiedName}_$filename"
+        testData.addFileMetric(fileKey, outFile)
+    }
+
+    private fun maybeCollectFailureHeader() {
+        if (failureHeader != null) {
+            Log.i(TAG, "Connectivity diagnostics failure header already collected, skipping")
+            return
+        }
+
+        val instr = InstrumentationRegistry.getInstrumentation()
+        val ctx = instr.context
+        val pm = ctx.packageManager
+        val hasWifi = pm.hasSystemFeature(FEATURE_WIFI)
+        val hasMobileData = pm.hasSystemFeature(FEATURE_TELEPHONY)
+        val tm = if (hasMobileData) ctx.getSystemService(TelephonyManager::class.java) else null
+        // getAdoptedShellPermissions is S+. Optimistically assume that tests are not holding on
+        // shell permissions during failure/cleanup on R.
+        val canUseShell = !isAtLeastS() ||
+                instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty()
+        val headerObj = JSONObject()
+        if (canUseShell) {
+            runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) {
+                headerObj.apply {
+                    put("deviceSerial", Build.getSerial())
+                    // The network callback filed on start cannot get the WifiInfo as it would need
+                    // to keep NETWORK_SETTINGS permission throughout the test run. Try to
+                    // obtain it while holding the permission at the end of the test.
+                    val wifiInfo = networkCallback.currentWifiNetworks.keys.firstOrNull()?.let {
+                        getWifiInfo(it)
+                    }
+                    put("ssid", wifiInfo?.ssid)
+                    put("bssid", wifiInfo?.bssid)
+                    put("simState", tm?.simState ?: SIM_STATE_UNKNOWN)
+                    put("mccMnc", tm?.simOperator)
+                }
+            }
+        } else {
+            Log.w(TAG, "The test is still holding shell permissions, cannot collect privileged " +
+                    "device info")
+            headerObj.put("shellPermissionsUnavailable", true)
+        }
+        failureHeader = headerObj.apply {
+            put("time", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now()))
+            put(
+                "wifiEnabled",
+                hasWifi && ctx.getSystemService(WifiManager::class.java).isWifiEnabled
+            )
+            put("connectedWifiCount", networkCallback.currentWifiNetworks.size)
+            put("validatedWifiCount", networkCallback.currentWifiNetworks.filterValues {
+                it.hasCapability(NET_CAPABILITY_VALIDATED)
+            }.size)
+            put("mobileDataConnectivityPossible", tm?.isDataConnectivityPossible ?: false)
+            put("connectedMobileDataCount", networkCallback.currentMobileDataNetworks.size)
+            put("validatedMobileDataCount",
+                networkCallback.currentMobileDataNetworks.filterValues {
+                    it.hasCapability(NET_CAPABILITY_VALIDATED)
+                }.size
+            )
+        }.toString()
+    }
+
+    private class WifiInfoCallback : NetworkCallback {
+        private val network: Network
+        val wifiInfoFuture = CompletableFuture<WifiInfo?>()
+        constructor(network: Network) : super() {
+            this.network = network
+        }
+        @RequiresApi(Build.VERSION_CODES.S)
+        constructor(network: Network, flags: Int) : super(flags) {
+            this.network = network
+        }
+        override fun onCapabilitiesChanged(net: Network, nc: NetworkCapabilities) {
+            if (network == net) {
+                wifiInfoFuture.complete(nc.transportInfo as? WifiInfo)
+            }
+        }
+    }
+
+    private fun getWifiInfo(network: Network): WifiInfo? {
+        // Get the SSID via network callbacks, as the Networks are obtained via callbacks, and
+        // synchronous calls (CM#getNetworkCapabilities) and callbacks should not be mixed.
+        // A new callback needs to be filed and received while holding NETWORK_SETTINGS permission.
+        val cb = if (isAtLeastS()) {
+            WifiInfoCallback(network, FLAG_INCLUDE_LOCATION_INFO)
+        } else {
+            WifiInfoCallback(network)
+        }
+        cbHelper.registerNetworkCallback(
+            NetworkRequest.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
+        return try {
+            cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS)
+        } catch (e: TimeoutException) {
+            null
+        } finally {
+            cbHelper.unregisterNetworkCallback(cb)
+        }
+    }
+
+    /**
+     * Add connectivity diagnostics to the test data dump.
+     *
+     * <p>This collects a set of diagnostics that are relevant to connectivity test failures.
+     * <p>The dump will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectTestFailureDiagnostics(exceptionContext: Throwable? = null) {
+        maybeCollectFailureHeader()
+        collectDumpsysConnectivity(exceptionContext)
+    }
+
+    /**
+     * Add dumpsys connectivity to the test data dump.
+     *
+     * <p>The dump will be collected immediately, and exported to a test artifact file when the
+     * test ends.
+     * @param exceptionContext An exception to write a stacktrace to the dump for context.
+     */
+    fun collectDumpsysConnectivity(exceptionContext: Throwable? = null) {
+        Log.i(TAG, "Collecting dumpsys connectivity for test artifacts")
+        PrintWriter(buffer).let {
+            it.println("--- Dumpsys connectivity at ${ZonedDateTime.now()} ---")
+            maybeWriteExceptionContext(it, exceptionContext)
+            it.flush()
+        }
+        ParcelFileDescriptor.AutoCloseInputStream(
+            InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
+                "dumpsys connectivity --dump-priority HIGH")).use {
+            it.copyTo(buffer)
+        }
+    }
+
+    private fun maybeWriteExceptionContext(writer: PrintWriter, exceptionContext: Throwable?) {
+        if (exceptionContext == null) return
+        writer.println("At: ")
+        exceptionContext.printStackTrace(writer)
+    }
+}
\ No newline at end of file
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt b/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
index 8b88224..5729452 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/MdnsTestUtils.kt
@@ -28,8 +28,6 @@
 import com.android.net.module.util.NetworkStackConstants.IPV6_HEADER_LEN
 import com.android.net.module.util.NetworkStackConstants.UDP_HEADER_LEN
 import com.android.net.module.util.TrackRecord
-import com.android.testutils.IPv6UdpFilter
-import com.android.testutils.TapPacketReader
 import java.net.Inet6Address
 import java.net.InetAddress
 import kotlin.test.assertEquals
@@ -246,7 +244,7 @@
             as Inet6Address
 }
 
-fun TapPacketReader.pollForMdnsPacket(
+fun PollPacketReader.pollForMdnsPacket(
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS,
     predicate: (TestDnsPacket) -> Boolean
 ): TestDnsPacket? {
@@ -264,7 +262,7 @@
     }
 }
 
-fun TapPacketReader.pollForProbe(
+fun PollPacketReader.pollForProbe(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
@@ -272,7 +270,7 @@
     it.isProbeFor("$serviceName.$serviceType.local")
 }
 
-fun TapPacketReader.pollForAdvertisement(
+fun PollPacketReader.pollForAdvertisement(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
@@ -280,19 +278,19 @@
     it.isReplyFor("$serviceName.$serviceType.local")
 }
 
-fun TapPacketReader.pollForQuery(
+fun PollPacketReader.pollForQuery(
     recordName: String,
     vararg requiredTypes: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
 ): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isQueryFor(recordName, *requiredTypes) }
 
-fun TapPacketReader.pollForReply(
+fun PollPacketReader.pollForReply(
     recordName: String,
     type: Int,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
 ): TestDnsPacket? = pollForMdnsPacket(timeoutMs) { it.isReplyFor(recordName, type) }
 
-fun TapPacketReader.pollForReply(
+fun PollPacketReader.pollForReply(
     serviceName: String,
     serviceType: String,
     timeoutMs: Long = MDNS_REGISTRATION_TIMEOUT_MS
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt b/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
index 964c6c6..62d0e82 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PacketResponder.kt
@@ -21,24 +21,24 @@
 private const val POLL_FREQUENCY_MS = 1000L
 
 /**
- * A class that can be used to reply to packets from a [TapPacketReader].
+ * A class that can be used to reply to packets from a [PollPacketReader].
  *
  * A reply thread will be created to reply to incoming packets asynchronously.
- * The receiver creates a new read head on the [TapPacketReader], to read packets, so it does not
- * affect packets obtained through [TapPacketReader.popPacket].
+ * The receiver creates a new read head on the [PollPacketReader], to read packets, so it does not
+ * affect packets obtained through [PollPacketReader.popPacket].
  *
- * @param reader a [TapPacketReader] to obtain incoming packets and reply to them.
+ * @param reader a [PollPacketReader] to obtain incoming packets and reply to them.
  * @param packetFilter A filter to apply to incoming packets.
  * @param name Name to use for the internal responder thread.
  */
 abstract class PacketResponder(
-    private val reader: TapPacketReader,
-    private val packetFilter: Predicate<ByteArray>,
-    name: String
+        private val reader: PollPacketReader,
+        private val packetFilter: Predicate<ByteArray>,
+        name: String
 ) {
     private val replyThread = ReplyThread(name)
 
-    protected abstract fun replyToPacket(packet: ByteArray, reader: TapPacketReader)
+    protected abstract fun replyToPacket(packet: ByteArray, reader: PollPacketReader)
 
     /**
      * Start the [PacketResponder].
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java b/staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
similarity index 91%
rename from staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java
rename to staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
index b25b9f2..dbc7eb0 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReader.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PollPacketReader.java
@@ -35,19 +35,19 @@
 import kotlin.LazyKt;
 
 /**
- * A packet reader that runs on a TAP interface.
+ * A packet reader that can poll for received packets and send responses on a fd.
  *
  * It also implements facilities to reply to received packets.
  */
-public class TapPacketReader extends PacketReader {
-    private final FileDescriptor mTapFd;
+public class PollPacketReader extends PacketReader {
+    private final FileDescriptor mFd;
     private final ArrayTrackRecord<byte[]> mReceivedPackets = new ArrayTrackRecord<>();
     private final Lazy<ArrayTrackRecord<byte[]>.ReadHead> mReadHead =
             LazyKt.lazy(mReceivedPackets::newReadHead);
 
-    public TapPacketReader(Handler h, FileDescriptor tapFd, int maxPacketSize) {
+    public PollPacketReader(Handler h, FileDescriptor fd, int maxPacketSize) {
         super(h, maxPacketSize);
-        mTapFd = tapFd;
+        mFd = fd;
     }
 
 
@@ -63,7 +63,7 @@
 
     @Override
     protected FileDescriptor createFd() {
-        return mTapFd;
+        return mFd;
     }
 
     @Override
@@ -119,7 +119,7 @@
     }
 
     /*
-     * Send a response on the TAP interface.
+     * Send a response on the fd.
      *
      * The passed ByteBuffer is flipped after use.
      *
@@ -127,7 +127,7 @@
      * @throws IOException if the interface can't be written to.
      */
     public void sendResponse(final ByteBuffer packet) throws IOException {
-        try (FileOutputStream out = new FileOutputStream(mTapFd)) {
+        try (FileOutputStream out = new FileOutputStream(mFd)) {
             byte[] packetBytes = new byte[packet.limit()];
             packet.get(packetBytes);
             packet.flip();  // So we can reuse it in the future.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java b/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
index 51d57bc..6709555 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/RouterAdvertisementResponder.java
@@ -62,18 +62,18 @@
     private static final String TAG = "RouterAdvertisementResponder";
     private static final Inet6Address DNS_SERVER =
             (Inet6Address) InetAddresses.parseNumericAddress("2001:4860:4860::64");
-    private final TapPacketReader mPacketReader;
+    private final PollPacketReader mPacketReader;
     // Maps IPv6 address to MacAddress and isRouter boolean.
     private final Map<Inet6Address, Pair<MacAddress, Boolean>> mNeighborMap = new ArrayMap<>();
     private final IpPrefix mPrefix;
 
-    public RouterAdvertisementResponder(TapPacketReader packetReader, IpPrefix prefix) {
+    public RouterAdvertisementResponder(PollPacketReader packetReader, IpPrefix prefix) {
         super(packetReader, RouterAdvertisementResponder::isRsOrNs, TAG);
         mPacketReader = packetReader;
         mPrefix = Objects.requireNonNull(prefix);
     }
 
-    public RouterAdvertisementResponder(TapPacketReader packetReader) {
+    public RouterAdvertisementResponder(PollPacketReader packetReader) {
         this(packetReader, makeRandomPrefix());
     }
 
@@ -148,7 +148,7 @@
                 buildSllaOption(srcMac));
     }
 
-    private static void sendResponse(TapPacketReader reader, ByteBuffer buffer) {
+    private static void sendResponse(PollPacketReader reader, ByteBuffer buffer) {
         try {
             reader.sendResponse(buffer);
         } catch (IOException e) {
@@ -158,7 +158,7 @@
         }
     }
 
-    private void replyToRouterSolicitation(TapPacketReader reader, MacAddress dstMac) {
+    private void replyToRouterSolicitation(PollPacketReader reader, MacAddress dstMac) {
         for (Map.Entry<Inet6Address, Pair<MacAddress, Boolean>> it : mNeighborMap.entrySet()) {
             final boolean isRouter = it.getValue().second;
             if (!isRouter) {
@@ -169,7 +169,7 @@
         }
     }
 
-    private void replyToNeighborSolicitation(TapPacketReader reader, MacAddress dstMac,
+    private void replyToNeighborSolicitation(PollPacketReader reader, MacAddress dstMac,
             Inet6Address dstIp, Inet6Address targetIp) {
         final Pair<MacAddress, Boolean> neighbor = mNeighborMap.get(targetIp);
         if (neighbor == null) {
@@ -190,7 +190,7 @@
     }
 
     @Override
-    protected void replyToPacket(byte[] packet, TapPacketReader reader) {
+    protected void replyToPacket(byte[] packet, PollPacketReader reader) {
         final ByteBuffer buf = ByteBuffer.wrap(packet);
         // Messages are filtered by parent class, so it is safe to assume that packet is either an
         // RS or NS.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
index 701666c..adf7619 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TapPacketReaderRule.kt
@@ -31,9 +31,9 @@
 private const val HANDLER_TIMEOUT_MS = 10_000L
 
 /**
- * A [TestRule] that sets up a [TapPacketReader] on a [TestNetworkInterface] for use in the test.
+ * A [TestRule] that sets up a [PollPacketReader] on a [TestNetworkInterface] for use in the test.
  *
- * @param maxPacketSize Maximum size of packets read in the [TapPacketReader] buffer.
+ * @param maxPacketSize Maximum size of packets read in the [PollPacketReader] buffer.
  * @param autoStart Whether to initialize the interface and start the reader automatically for every
  *                  test. If false, each test must either call start() and stop(), or be annotated
  *                  with TapPacketReaderTest before using the reader or interface.
@@ -50,21 +50,21 @@
     // referenced before they could be initialized (typically if autoStart is false and the test
     // does not call start or use @TapPacketReaderTest).
     lateinit var iface: TestNetworkInterface
-    lateinit var reader: TapPacketReader
+    lateinit var reader: PollPacketReader
 
     @Volatile
     private var readerRunning = false
 
     /**
      * Indicates that the [TapPacketReaderRule] should initialize its [TestNetworkInterface] and
-     * start the [TapPacketReader] before the test, and tear them down afterwards.
+     * start the [PollPacketReader] before the test, and tear them down afterwards.
      *
      * For use when [TapPacketReaderRule] is created with autoStart = false.
      */
     annotation class TapPacketReaderTest
 
     /**
-     * Initialize the tap interface and start the [TapPacketReader].
+     * Initialize the tap interface and start the [PollPacketReader].
      *
      * Tests using this method must also call [stop] before exiting.
      * @param handler Handler to run the reader on. Callers are responsible for safely terminating
@@ -85,13 +85,13 @@
         }
         val usedHandler = handler ?: HandlerThread(
                 TapPacketReaderRule::class.java.simpleName).apply { start() }.threadHandler
-        reader = TapPacketReader(usedHandler, iface.fileDescriptor.fileDescriptor, maxPacketSize)
+        reader = PollPacketReader(usedHandler, iface.fileDescriptor.fileDescriptor, maxPacketSize)
         reader.startAsyncForTest()
         readerRunning = true
     }
 
     /**
-     * Stop the [TapPacketReader].
+     * Stop the [PollPacketReader].
      *
      * Tests calling [start] must call this method before exiting. If a handler was specified in
      * [start], all messages on that handler must also be processed after calling this method and
diff --git a/staticlibs/testutils/host/python/apf_test_base.py b/staticlibs/testutils/host/python/apf_test_base.py
index 9a30978..2552aa3 100644
--- a/staticlibs/testutils/host/python/apf_test_base.py
+++ b/staticlibs/testutils/host/python/apf_test_base.py
@@ -15,7 +15,7 @@
 from mobly import asserts
 from net_tests_utils.host.python import adb_utils, apf_utils, assert_utils, multi_devices_test_base, tether_utils
 from net_tests_utils.host.python.tether_utils import UpstreamType
-
+import time
 
 class ApfTestBase(multi_devices_test_base.MultiDevicesTestBase):
 
@@ -39,6 +39,7 @@
     )
 
     # Fetch device properties and storing them locally for later use.
+    # TODO: refactor to separate instances to store client and server device
     self.server_iface_name, client_network = (
         tether_utils.setup_hotspot_and_client_for_upstream_type(
             self.serverDevice, self.clientDevice, UpstreamType.NONE
@@ -50,6 +51,21 @@
     self.server_mac_address = apf_utils.get_hardware_address(
         self.serverDevice, self.server_iface_name
     )
+    self.client_mac_address = apf_utils.get_hardware_address(
+        self.clientDevice, self.client_iface_name
+    )
+    self.server_ipv4_addresses = apf_utils.get_ipv4_addresses(
+        self.serverDevice, self.server_iface_name
+    )
+    self.client_ipv4_addresses = apf_utils.get_ipv4_addresses(
+        self.clientDevice, self.client_iface_name
+    )
+    self.server_ipv6_addresses = apf_utils.get_ipv6_addresses(
+        self.serverDevice, self.server_iface_name
+    )
+    self.client_ipv6_addresses = apf_utils.get_ipv6_addresses(
+        self.clientDevice, self.client_iface_name
+    )
 
     # Enable doze mode to activate APF.
     adb_utils.set_doze_mode(self.clientDevice, True)
@@ -81,4 +97,19 @@
         > count_before_test
     )
 
-    # TODO: Verify the packet is not actually received.
+  def send_packet_and_expect_reply_received(
+      self, send_packet: str, counter_name: str, receive_packet: str
+  ) -> None:
+    try:
+        apf_utils.start_capture_packets(self.serverDevice, self.server_iface_name)
+
+        self.send_packet_and_expect_counter_increased(send_packet, counter_name)
+
+        assert_utils.expect_with_retry(
+            lambda: apf_utils.get_matched_packet_counts(
+                self.serverDevice, self.server_iface_name, receive_packet
+            )
+            == 1
+        )
+    finally:
+        apf_utils.stop_capture_packets(self.serverDevice, self.server_iface_name)
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index e84ba3e..7fe60bd 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -178,6 +178,29 @@
         "Cannot get hardware address for " + iface_name
     )
 
+def is_packet_capture_supported(
+        ad: android_device.AndroidDevice,
+) -> bool:
+
+  # Invoke the shell command with empty argument and see how NetworkStack respond.
+  # If supported, an IllegalArgumentException with help page will be printed.
+  functions_with_args = (
+    # list all functions and args with (func, *args) tuple
+    (start_capture_packets, (ad, "")),
+    (stop_capture_packets, (ad, "")),
+    (get_matched_packet_counts, (ad, "", ""))
+  )
+
+  for func, args in functions_with_args:
+    try:
+      func(*args)
+    except UnsupportedOperationException:
+      return False
+    except Exception:
+      continue
+
+  # If no UnsupportOperationException is thrown, regard it as supported
+  return True
 
 def is_send_raw_packet_downstream_supported(
     ad: android_device.AndroidDevice,
@@ -224,25 +247,92 @@
         representation of a packet starting from L2 header.
   """
 
-  cmd = (
-      "cmd network_stack send-raw-packet-downstream"
-      f" {iface_name} {packet_in_hex}"
-  )
+  cmd = f"cmd network_stack send-raw-packet-downstream {iface_name} {packet_in_hex}"
 
   # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
-  try:
-    output = adb_utils.adb_shell(ad, cmd)
-  except AdbError as e:
-    output = str(e.stdout)
-  if output:
-    if "Unknown command" in output:
-      raise UnsupportedOperationException(
-          "send-raw-packet-downstream command is not supported."
-      )
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output:
     raise assert_utils.UnexpectedBehaviorError(
-        f"Got unexpected output: {output} for command: {cmd}."
+      f"Got unexpected output: {adb_output} for command: {cmd}."
     )
 
+def start_capture_packets(
+        ad: android_device.AndroidDevice,
+        iface_name: str
+) -> None:
+  """Starts packet capturing on a specified network interface.
+
+  This function initiates packet capture on the given network interface of an
+  Android device using an ADB shell command. It handles potential errors
+  related to unsupported commands or unexpected output.
+  This command only supports downstream tethering interface.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+  """
+  cmd = f"cmd network_stack capture start {iface_name}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output != "success":
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected output: {adb_output} for command: {cmd}."
+    )
+
+def stop_capture_packets(
+        ad: android_device.AndroidDevice,
+        iface_name: str
+) -> None:
+  """Stops packet capturing on a specified network interface.
+
+  This function terminates packet capture on the given network interface of an
+  Android device using an ADB shell command. It handles potential errors
+  related to unsupported commands or unexpected output.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+  """
+  cmd = f"cmd network_stack capture stop {iface_name}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  if adb_output != "success":
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected output: {adb_output} for command: {cmd}."
+    )
+
+def get_matched_packet_counts(
+        ad: android_device.AndroidDevice,
+        iface_name: str,
+        packet_in_hex: str
+) -> int:
+  """Gets the number of captured packets matching a specific hexadecimal pattern.
+
+  This function retrieves the count of captured packets on the specified
+  network interface that match a given hexadecimal pattern. It uses an ADB
+  shell command and handles potential errors related to unsupported commands,
+  unexpected output, or invalid output format.
+
+  Args:
+    ad: The Android device object.
+    iface_name: The name of the network interface (e.g., "wlan0").
+    packet_in_hex: The hexadecimal string representing the packet pattern.
+
+  Returns:
+    The number of matched packets as an integer.
+  """
+  cmd = f"cmd network_stack capture matched-packet-counts {iface_name} {packet_in_hex}"
+
+  # Expect no output or Unknown command if NetworkStack is too old. Throw otherwise.
+  adb_output = AdbOutputHandler(ad, cmd).get_output()
+  try:
+    return int(adb_output)
+  except ValueError as e:
+    raise assert_utils.UnexpectedBehaviorError(
+      f"Got unexpected exception: {e} for command: {cmd}."
+    )
 
 @dataclass
 class ApfCapabilities:
@@ -304,3 +394,19 @@
       f"Supported apf version {caps.apf_version_supported} < expected version"
       f" {expected_version}",
   )
+
+class AdbOutputHandler:
+  def __init__(self, ad, cmd):
+    self._ad = ad
+    self._cmd = cmd
+
+  def get_output(self) -> str:
+    try:
+      return adb_utils.adb_shell(self._ad, self._cmd)
+    except AdbError as e:
+      output = str(e.stdout)
+      if "Unknown command" in output:
+        raise UnsupportedOperationException(
+          f"{self._cmd} is not supported."
+        )
+      return output
\ No newline at end of file
diff --git a/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt b/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
index 9f28234..dcd422c 100644
--- a/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
+++ b/staticlibs/testutils/hostdevice/com/android/testutils/Cleanup.kt
@@ -20,6 +20,7 @@
 
 import com.android.testutils.FunctionalUtils.ThrowingRunnable
 import com.android.testutils.FunctionalUtils.ThrowingSupplier
+import java.util.function.Consumer
 import javax.annotation.CheckReturnValue
 
 /**
@@ -73,11 +74,23 @@
  * });
  */
 
+object TryTestConfig {
+    internal var diagnosticsCollector: Consumer<Throwable>? = null
+
+    /**
+     * Set the diagnostics collector to be used in case of failure in [tryTest].
+     */
+    fun setDiagnosticsCollector(collector: Consumer<Throwable>) {
+        diagnosticsCollector = collector
+    }
+}
+
 @CheckReturnValue
 fun <T> tryTest(block: () -> T) = TryExpr(
         try {
             Result.success(block())
         } catch (e: Throwable) {
+            TryTestConfig.diagnosticsCollector?.accept(e)
             Result.failure(e)
         })
 
diff --git a/tests/common/Android.bp b/tests/common/Android.bp
index e95a81a..920492f 100644
--- a/tests/common/Android.bp
+++ b/tests/common/Android.bp
@@ -78,7 +78,7 @@
     name: "ConnectivityCoverageTestsLib",
     min_sdk_version: "30",
     static_libs: [
-        "FrameworksNetTestsLib",
+        "ConnectivityUnitTestsLib",
         "NetdStaticLibTestsLib",
         "NetworkStaticLibTestsLib",
         "NetworkStackTestsLib",
diff --git a/tests/cts/net/AndroidTestTemplate.xml b/tests/cts/net/AndroidTestTemplate.xml
index 24431a6..7590a2b 100644
--- a/tests/cts/net/AndroidTestTemplate.xml
+++ b/tests/cts/net/AndroidTestTemplate.xml
@@ -56,7 +56,14 @@
              the runner will only run the tests annotated with that annotation, but if it does not,
              the runner will run all the tests. -->
         <option name="include-annotation" value="com.android.testutils.filters.{MODULE}" />
+        <option name="device-listeners" value="com.android.testutils.ConnectivityDiagnosticsCollector" />
     </test>
+    <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
+        <!-- Pattern matching the fileKey used by ConnectivityDiagnosticsCollector when calling addFileMetric -->
+        <option name="pull-pattern-keys" value="com.android.testutils.ConnectivityDiagnosticsCollector.*" />
+        <option name="log-data-type" value="CONNDIAG" />
+        <option name="collect-on-run-ended-only" value="true" />
+    </metrics_collector>
     <!-- When this test is run in a Mainline context (e.g. with `mts-tradefed`), only enable it if
         one of the Mainline modules below is present on the device used for testing. -->
     <object type="module_controller" class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
index b62db04..0e9ea0c 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityManagerTest.java
@@ -3061,6 +3061,7 @@
 
         try {
             final Network cellNetwork = networkCallbackRule.requestCell();
+            ensureCellIsValidatedBeforeMockingValidationUrls();
             final Network wifiNetwork = prepareValidatedNetwork();
 
             final TestableNetworkCallback defaultCb =
diff --git a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
index 041e6cb..1de4cf9 100644
--- a/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
+++ b/tests/cts/net/src/android/net/cts/DscpPolicyTest.kt
@@ -71,7 +71,7 @@
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.RouterAdvertisementResponder
 import com.android.testutils.SC_V2
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnDscpPolicyStatusUpdated
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
@@ -135,7 +135,7 @@
     private lateinit var srcAddressV6: Inet6Address
     private lateinit var iface: TestNetworkInterface
     private lateinit var tunNetworkCallback: TestNetworkCallback
-    private lateinit var reader: TapPacketReader
+    private lateinit var reader: PollPacketReader
     private lateinit var arpResponder: ArpResponder
     private lateinit var raResponder: RouterAdvertisementResponder
 
@@ -169,7 +169,7 @@
         }
 
         handlerThread.start()
-        reader = TapPacketReader(
+        reader = PollPacketReader(
                 handlerThread.threadHandler,
                 iface.fileDescriptor.fileDescriptor,
                 MAX_PACKET_LENGTH)
diff --git a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
index 61ebd8f..1e2a212 100644
--- a/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/EthernetManagerTest.kt
@@ -72,7 +72,7 @@
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.RouterAdvertisementResponder
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.assertThrows
 import com.android.testutils.runAsShell
@@ -151,7 +151,7 @@
         hasCarrier: Boolean
     ) {
         private val tapInterface: TestNetworkInterface
-        private val packetReader: TapPacketReader
+        private val packetReader: PollPacketReader
         private val raResponder: RouterAdvertisementResponder
         private val tnm: TestNetworkManager
         val name get() = tapInterface.interfaceName
@@ -169,7 +169,11 @@
                 tnm.createTapInterface(hasCarrier, false /* bringUp */)
             }
             val mtu = tapInterface.mtu
-            packetReader = TapPacketReader(handler, tapInterface.fileDescriptor.fileDescriptor, mtu)
+            packetReader = PollPacketReader(
+                    handler,
+                    tapInterface.fileDescriptor.fileDescriptor,
+                    mtu
+            )
             raResponder = RouterAdvertisementResponder(packetReader)
             val iidString = "fe80::${Integer.toHexString(Random().nextInt(65536))}"
             val linklocal = InetAddresses.parseNumericAddress(iidString) as Inet6Address
@@ -336,7 +340,7 @@
         }
     }
 
-    private fun isEthernetSupported() : Boolean {
+    private fun isEthernetSupported(): Boolean {
         return context.getSystemService(EthernetManager::class.java) != null
     }
 
diff --git a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
index 2315940..11fc6df 100644
--- a/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkStatsManagerTest.java
@@ -115,6 +115,8 @@
 
     private static final int NETWORK_TAG = 0xf00d;
     private static final long THRESHOLD_BYTES = 2 * 1024 * 1024;  // 2 MB
+    private static final long SHORT_TOLERANCE = MINUTE / 2;
+    private static final long LONG_TOLERANCE = MINUTE * 120;
 
     private abstract class NetworkInterfaceToTest {
         private boolean mMetered;
@@ -364,16 +366,17 @@
         }
     }
 
-    private boolean shouldTestThisNetworkType(int networkTypeIndex, final long tolerance)
+    private boolean shouldTestThisNetworkType(int networkTypeIndex) {
+        return mPm.hasSystemFeature(mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature());
+    }
+
+    private void requestNetworkAndGenerateTraffic(int networkTypeIndex, final long tolerance)
             throws Exception {
-        boolean hasFeature = mPm.hasSystemFeature(
-                mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature());
-        if (!hasFeature) {
-            return false;
-        }
-        NetworkCallback callback = new NetworkCallback(tolerance, new URL(CHECK_CONNECTIVITY_URL));
+        final NetworkInterfaceToTest networkInterface = mNetworkInterfacesToTest[networkTypeIndex];
+        final NetworkCallback callback = new NetworkCallback(tolerance,
+                new URL(CHECK_CONNECTIVITY_URL));
         mCm.requestNetwork(new NetworkRequest.Builder()
-                .addTransportType(mNetworkInterfacesToTest[networkTypeIndex].getTransportType())
+                .addTransportType(networkInterface.getTransportType())
                 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                 .build(), callback);
         synchronized (this) {
@@ -388,20 +391,17 @@
             }
         }
         mCm.unregisterNetworkCallback(callback);
-        if (callback.success) {
-            mNetworkInterfacesToTest[networkTypeIndex].setMetered(callback.metered);
-            mNetworkInterfacesToTest[networkTypeIndex].setRoaming(callback.roaming);
-            mNetworkInterfacesToTest[networkTypeIndex].setIsDefault(callback.isDefault);
-            return true;
+        if (!callback.success) {
+            fail(networkInterface.getSystemFeature()
+                    + " is a reported system feature, however no corresponding "
+                    + "connected network interface was found or the attempt "
+                    + "to connect and read has timed out (timeout = " + (TIMEOUT_MILLIS * 2.4)
+                    + "ms)." + networkInterface.getErrorMessage());
         }
 
-        // This will always fail at this point as we know 'hasFeature' is true.
-        assertFalse(mNetworkInterfacesToTest[networkTypeIndex].getSystemFeature()
-                + " is a reported system feature, "
-                + "however no corresponding connected network interface was found or the attempt "
-                + "to connect and read has timed out (timeout = " + (TIMEOUT_MILLIS * 2) + "ms)."
-                + mNetworkInterfacesToTest[networkTypeIndex].getErrorMessage(), hasFeature);
-        return false;
+        networkInterface.setMetered(callback.metered);
+        networkInterface.setRoaming(callback.roaming);
+        networkInterface.setIsDefault(callback.isDefault);
     }
 
     private String getSubscriberId(int networkIndex) {
@@ -417,9 +417,10 @@
     @Test
     public void testDeviceSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats.Bucket bucket = null;
             try {
@@ -453,9 +454,10 @@
     @Test
     public void testUserSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats.Bucket bucket = null;
             try {
@@ -489,14 +491,15 @@
     @Test
     public void testAppSummary() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
+            if (!shouldTestThisNetworkType(i)) {
+                continue;
+            }
             // Use tolerance value that large enough to make sure stats of at
             // least one bucket is included. However, this is possible that
             // the test will see data of different app but with the same UID
             // that created before testing.
             // TODO: Consider query stats before testing and use the difference to verify.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
-                continue;
-            }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -565,10 +568,11 @@
     @Test
     public void testAppDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            // Relatively large tolerance to accommodate for history bucket size.
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -609,9 +613,10 @@
     public void testUidDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -663,9 +668,10 @@
     public void testTagDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -769,10 +775,11 @@
     @Test
     public void testUidTagStateDetails() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
-            // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE * 120)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            // Relatively large tolerance to accommodate for history bucket size.
+            requestNetworkAndGenerateTraffic(i, LONG_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
             NetworkStats result = null;
             try {
@@ -847,9 +854,10 @@
     public void testCallback() throws Exception {
         for (int i = 0; i < mNetworkInterfacesToTest.length; ++i) {
             // Relatively large tolerance to accommodate for history bucket size.
-            if (!shouldTestThisNetworkType(i, MINUTE / 2)) {
+            if (!shouldTestThisNetworkType(i)) {
                 continue;
             }
+            requestNetworkAndGenerateTraffic(i, SHORT_TOLERANCE);
             setAppOpsMode(AppOpsManager.OPSTR_GET_USAGE_STATS, "allow");
 
             TestUsageCallback usageCallback = new TestUsageCallback();
diff --git a/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
index f9acb66..aad072c 100644
--- a/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkValidationTest.kt
@@ -46,7 +46,7 @@
 import com.android.testutils.DhcpClientPacketFilter
 import com.android.testutils.DhcpOptionFilter
 import com.android.testutils.RecorderCallback.CallbackEntry
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestHttpServer
 import com.android.testutils.TestableNetworkCallback
 import com.android.testutils.runAsShell
@@ -93,7 +93,7 @@
     private val ethRequestCb = TestableNetworkCallback()
 
     private lateinit var iface: TestNetworkInterface
-    private lateinit var reader: TapPacketReader
+    private lateinit var reader: PollPacketReader
     private lateinit var capportUrl: Uri
 
     private var testSkipped = false
@@ -118,7 +118,7 @@
         iface = testInterfaceRule.createTapInterface()
 
         handlerThread.start()
-        reader = TapPacketReader(
+        reader = PollPacketReader(
                 handlerThread.threadHandler,
                 iface.fileDescriptor.fileDescriptor,
                 MAX_PACKET_LENGTH)
@@ -218,7 +218,7 @@
                     TEST_MTU, false /* rapidCommit */, capportUrl.toString())
 }
 
-private fun <T : DhcpPacket> TapPacketReader.assertDhcpPacketReceived(
+private fun <T : DhcpPacket> PollPacketReader.assertDhcpPacketReceived(
     packetType: Class<T>,
     timeoutMs: Long,
     type: Byte
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index c71d925..ad6fe63 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -100,7 +100,7 @@
 import com.android.testutils.NsdServiceInfoCallbackRecord.ServiceInfoCallbackEvent.UnregisterCallbackSucceeded
 import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestDnsPacket
 import com.android.testutils.TestableNetworkAgent
 import com.android.testutils.TestableNetworkAgent.CallbackEntry.OnNetworkCreated
@@ -1299,10 +1299,10 @@
 
         val si = makeTestServiceInfo(testNetwork1.network)
 
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1345,10 +1345,10 @@
                     parseNumericAddress("2001:db8::3"))
         }
 
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1391,10 +1391,10 @@
             hostname = customHostname
         }
 
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1438,10 +1438,10 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1518,10 +1518,10 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
+        val packetReader = PollPacketReader(
                 Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1587,10 +1587,10 @@
         val registrationRecord = NsdRegistrationRecord()
         val discoveryRecord = NsdDiscoveryRecord()
         val registeredService = registerService(registrationRecord, si)
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1630,10 +1630,10 @@
     fun testDiscoveryWithPtrOnlyResponse_ServiceIsFound() {
         // Register service on testNetwork1
         val discoveryRecord = NsdDiscoveryRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1688,10 +1688,10 @@
     fun testResolveWhenServerSendsNoAdditionalRecord() {
         // Resolve service on testNetwork1
         val resolveRecord = NsdResolveRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1776,8 +1776,8 @@
         var nsResponder: NSResponder? = null
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
-                testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+            val packetReader = PollPacketReader(Handler(handlerThread.looper),
+                    testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
             packetReader.startAsyncForTest()
 
             handlerThread.waitForIdle(TIMEOUT_MS)
@@ -1826,7 +1826,7 @@
         var nsResponder: NSResponder? = null
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
+            val packetReader = PollPacketReader(Handler(handlerThread.looper),
                     testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
             packetReader.startAsyncForTest()
 
@@ -1916,7 +1916,7 @@
         var nsResponder: NSResponder? = null
         tryTest {
             registerService(registrationRecord, si)
-            val packetReader = TapPacketReader(Handler(handlerThread.looper),
+            val packetReader = PollPacketReader(Handler(handlerThread.looper),
                     testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
             packetReader.startAsyncForTest()
 
@@ -1991,10 +1991,10 @@
 
         // Register service on testNetwork1
         val discoveryRecord = NsdDiscoveryRecord()
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -2355,10 +2355,10 @@
             it.port = TEST_PORT
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -2410,10 +2410,10 @@
                     parseNumericAddress("2001:db8::2"))
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
                 testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -2467,10 +2467,10 @@
             it.hostAddresses = listOf()
             it.publicKey = publicKey
         }
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-            testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor,
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
@@ -2582,10 +2582,10 @@
             "test_nsd_avoid_advertising_empty_txt_records",
             "1"
         )
-        val packetReader = TapPacketReader(
-            Handler(handlerThread.looper),
-            testNetwork1.iface.fileDescriptor.fileDescriptor,
-            1500 /* maxPacketSize */
+        val packetReader = PollPacketReader(
+                Handler(handlerThread.looper),
+                testNetwork1.iface.fileDescriptor.fileDescriptor,
+                1500 /* maxPacketSize */
         )
         packetReader.startAsyncForTest()
         handlerThread.waitForIdle(TIMEOUT_MS)
diff --git a/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt b/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
index 32d6899..20cfa1d 100644
--- a/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
+++ b/tests/cts/net/util/java/android/net/cts/util/EthernetTestInterface.kt
@@ -28,7 +28,7 @@
 import android.os.Handler
 import android.util.Log
 import com.android.net.module.util.ArrayTrackRecord
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.runAsShell
 import com.android.testutils.waitForIdle
 import java.net.NetworkInterface
@@ -85,7 +85,7 @@
             assertNotNull(nif)
             return nif.mtu
         }
-    val packetReader = TapPacketReader(handler, testIface.fileDescriptor.fileDescriptor, mtu)
+    val packetReader = PollPacketReader(handler, testIface.fileDescriptor.fileDescriptor, mtu)
     private val listener = EthernetStateListener(name)
     private val em = context.getSystemService(EthernetManager::class.java)!!
     @Volatile private var cleanedUp = false
diff --git a/tests/cts/tethering/Android.bp b/tests/cts/tethering/Android.bp
index 1165018..83818be 100644
--- a/tests/cts/tethering/Android.bp
+++ b/tests/cts/tethering/Android.bp
@@ -94,14 +94,8 @@
     // Tag this module as a cts test artifact
     test_suites: [
         "cts",
-        "mts-dnsresolver",
-        "mts-networking",
         "mts-tethering",
-        "mts-wifi",
-        "mcts-dnsresolver",
-        "mcts-networking",
         "mcts-tethering",
-        "mcts-wifi",
         "general-tests",
     ],
 
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 00f9d05..6892a42 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -72,8 +72,8 @@
     ],
 }
 
-java_defaults {
-    name: "FrameworksNetTestsDefaults",
+android_library {
+    name: "ConnectivityUnitTestsLib",
     min_sdk_version: "30",
     defaults: [
         "framework-connectivity-internal-test-defaults",
@@ -82,6 +82,7 @@
         "java/**/*.java",
         "java/**/*.kt",
     ],
+    exclude_srcs: [":non-connectivity-module-test"],
     static_libs: [
         "androidx.test.rules",
         "androidx.test.uiautomator_uiautomator",
@@ -110,14 +111,6 @@
         "ServiceConnectivityResources",
     ],
     exclude_kotlinc_generated_files: false,
-}
-
-android_library {
-    name: "FrameworksNetTestsLib",
-    defaults: [
-        "FrameworksNetTestsDefaults",
-    ],
-    exclude_srcs: [":non-connectivity-module-test"],
     visibility: ["//packages/modules/Connectivity/tests:__subpackages__"],
 }
 
@@ -137,7 +130,7 @@
 java_genrule {
     name: "frameworks-net-tests-lib-jarjar-gen",
     tool_files: [
-        ":FrameworksNetTestsLib{.jar}",
+        ":ConnectivityUnitTestsLib{.jar}",
         "jarjar-excludes.txt",
     ],
     tools: [
@@ -145,7 +138,7 @@
     ],
     out: ["frameworks-net-tests-lib-jarjar-rules.txt"],
     cmd: "$(location jarjar-rules-generator) " +
-        "$(location :FrameworksNetTestsLib{.jar}) " +
+        "$(location :ConnectivityUnitTestsLib{.jar}) " +
         "--prefix android.net.connectivity " +
         "--excludes $(location jarjar-excludes.txt) " +
         "--output $(out)",
@@ -156,14 +149,25 @@
     name: "FrameworksNetTests",
     enabled: enable_frameworks_net_tests,
     defaults: [
-        "FrameworksNetTestsDefaults",
+        "framework-connectivity-internal-test-defaults",
         "FrameworksNetTests-jni-defaults",
     ],
     jarjar_rules: ":frameworks-net-tests-jarjar-rules",
+    srcs: [":non-connectivity-module-test"],
     test_suites: ["device-tests"],
     static_libs: [
+        "frameworks-base-testutils",
         "services.core",
         "services.net",
+        "androidx.test.rules",
+        "framework-protos",
+        "mockito-target-minus-junit4",
+        "net-tests-utils",
+        "service-connectivity-pre-jarjar",
+        "service-connectivity-tiramisu-pre-jarjar",
+    ],
+    libs: [
+        "android.test.mock.stubs",
     ],
     jni_libs: [
         "libandroid_net_connectivity_com_android_net_module_util_jni",
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 5c3ad22..efae244 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
@@ -22,21 +22,27 @@
 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.MdnsInetAddressRecord
 import com.android.server.connectivity.mdns.MdnsPacket
 import com.android.server.connectivity.mdns.MdnsPacketReader
 import com.android.server.connectivity.mdns.MdnsPointerRecord
 import com.android.server.connectivity.mdns.MdnsRecord
+import com.android.server.connectivity.mdns.MdnsResponse
+import com.android.server.connectivity.mdns.MdnsServiceInfo
+import com.android.server.connectivity.mdns.MdnsServiceRecord
+import com.android.server.connectivity.mdns.MdnsTextRecord
 import com.android.server.connectivity.mdns.util.MdnsUtils.createQueryDatagramPackets
 import com.android.server.connectivity.mdns.util.MdnsUtils.truncateServiceName
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
-import java.net.DatagramPacket
-import kotlin.test.assertContentEquals
+import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
+import java.net.DatagramPacket
+import kotlin.test.assertContentEquals
 
 @RunWith(DevSdkIgnoreRunner::class)
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
@@ -157,4 +163,54 @@
         assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v6Packet, otherV6Packet)))
         assertFalse(MdnsUtils.checkAllPacketsWithSameAddress(listOf(v4Packet, v6Packet)))
     }
+
+    @Test
+    fun testBuildMdnsServiceInfoFromResponse() {
+        val serviceInstanceName = "MyTestService"
+        val serviceType = "_testservice._tcp.local"
+        val hostName = "Android_000102030405060708090A0B0C0D0E0F.local"
+        val port = 12345
+        val ttlTime = 120000L
+        val testElapsedRealtime = 123L
+        val serviceName = "$serviceInstanceName.$serviceType".split(".").toTypedArray()
+        val v4Address = "192.0.2.1"
+        val v6Address = "2001:db8::1"
+        val interfaceIndex = 99
+        val response = MdnsResponse(0 /* now */, serviceName, interfaceIndex, null /* network */)
+        // Set PTR record
+        response.addPointerRecord(MdnsPointerRecord(serviceType.split(".").toTypedArray(),
+                testElapsedRealtime, false /* cacheFlush */, ttlTime, serviceName))
+        // Set SRV record.
+        response.serviceRecord = MdnsServiceRecord(serviceName, testElapsedRealtime,
+                false /* cacheFlush */, ttlTime, 0 /* servicePriority */, 0 /* serviceWeight */,
+                port, hostName.split(".").toTypedArray())
+        // Set TXT record.
+        response.textRecord = MdnsTextRecord(serviceName,
+                testElapsedRealtime, true /* cacheFlush */, 0L /* ttlMillis */,
+                listOf(MdnsServiceInfo.TextEntry.fromString("somedifferent=entry")))
+        // Set InetAddress record.
+        response.addInet4AddressRecord(MdnsInetAddressRecord(hostName.split(".").toTypedArray(),
+                testElapsedRealtime, true /* cacheFlush */,
+                0L /* ttlMillis */, InetAddresses.parseNumericAddress(v4Address)))
+        response.addInet6AddressRecord(MdnsInetAddressRecord(hostName.split(".").toTypedArray(),
+                testElapsedRealtime, true /* cacheFlush */,
+                0L /* ttlMillis */, InetAddresses.parseNumericAddress(v6Address)))
+
+        // Convert a MdnsResponse to a MdnsServiceInfo
+        val serviceInfo = MdnsUtils.buildMdnsServiceInfoFromResponse(
+                response, serviceType.split(".").toTypedArray(), testElapsedRealtime)
+
+        assertEquals(serviceInstanceName, serviceInfo.serviceInstanceName)
+        assertArrayEquals(serviceType.split(".").toTypedArray(), serviceInfo.serviceType)
+        assertArrayEquals(hostName.split(".").toTypedArray(), serviceInfo.hostName)
+        assertEquals(port, serviceInfo.port)
+        assertEquals(1, serviceInfo.ipv4Addresses.size)
+        assertEquals(v4Address, serviceInfo.ipv4Addresses[0])
+        assertEquals(1, serviceInfo.ipv6Addresses.size)
+        assertEquals(v6Address, serviceInfo.ipv6Addresses[0])
+        assertEquals(interfaceIndex, serviceInfo.interfaceIndex)
+        assertEquals(null, serviceInfo.network)
+        assertEquals(mapOf("somedifferent" to "entry"),
+                serviceInfo.attributes)
+    }
 }
diff --git a/tests/unit/jni/Android.bp b/tests/unit/jni/Android.bp
index 57a157d..50971e7 100644
--- a/tests/unit/jni/Android.bp
+++ b/tests/unit/jni/Android.bp
@@ -42,6 +42,7 @@
     ],
     static_libs: [
         "libnet_utils_device_common_bpfjni",
+        "libnet_utils_device_common_timerfdjni",
         "libtcutils",
     ],
     shared_libs: [
diff --git a/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
index e95feaf..2659d24 100644
--- a/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
+++ b/thread/demoapp/java/com/android/threadnetwork/demoapp/ThreadNetworkSettingsFragment.java
@@ -47,6 +47,9 @@
 
 import java.time.Duration;
 import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Timer;
+import java.util.TimerTask;
 import java.util.concurrent.Executor;
 
 public final class ThreadNetworkSettingsFragment extends Fragment {
@@ -59,11 +62,16 @@
     private TextView mTextState;
     private TextView mTextNetworkInfo;
     private TextView mMigrateNetworkState;
+    private TextView mEphemeralKeyStateText;
     private Executor mMainExecutor;
 
     private int mDeviceRole;
     private long mPartitionId;
     private ActiveOperationalDataset mActiveDataset;
+    private int mEphemeralKeyState;
+    private String mEphemeralKey;
+    private Instant mEphemeralKeyExpiry;
+    private Timer mEphemeralKeyLifetimeTimer;
 
     private static final byte[] DEFAULT_ACTIVE_DATASET_TLVS =
             base16().lowerCase()
@@ -89,6 +97,19 @@
         }
     }
 
+    private static String ephemeralKeyStateToString(int ephemeralKeyState) {
+        switch (ephemeralKeyState) {
+            case ThreadNetworkController.EPHEMERAL_KEY_DISABLED:
+                return "Disabled";
+            case ThreadNetworkController.EPHEMERAL_KEY_ENABLED:
+                return "Enabled";
+            case ThreadNetworkController.EPHEMERAL_KEY_IN_USE:
+                return "Connected";
+            default:
+                return "Unknown";
+        }
+    }
+
     @Override
     public View onCreateView(
             LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -144,6 +165,15 @@
                             ThreadNetworkSettingsFragment.this.mPartitionId = mPartitionId;
                             updateState();
                         }
+
+                        @Override
+                        public void onEphemeralKeyStateChanged(
+                                int state, String ephemeralKey, Instant expiry) {
+                            ThreadNetworkSettingsFragment.this.mEphemeralKeyState = state;
+                            ThreadNetworkSettingsFragment.this.mEphemeralKey = ephemeralKey;
+                            ThreadNetworkSettingsFragment.this.mEphemeralKeyExpiry = expiry;
+                            updateState();
+                        }
                     });
             mThreadController.registerOperationalDatasetCallback(
                     mMainExecutor,
@@ -155,6 +185,7 @@
 
         mTextState = (TextView) view.findViewById(R.id.text_state);
         mTextNetworkInfo = (TextView) view.findViewById(R.id.text_network_info);
+        mEphemeralKeyStateText = (TextView) view.findViewById(R.id.text_ephemeral_key_state);
 
         if (mThreadController == null) {
             mTextState.setText("Thread not supported!");
@@ -168,6 +199,11 @@
         ((Button) view.findViewById(R.id.button_migrate_network))
                 .setOnClickListener(v -> doMigration());
 
+        ((Button) view.findViewById(R.id.button_activate_ephemeral_key_mode))
+                .setOnClickListener(v -> doActivateEphemeralKeyMode());
+        ((Button) view.findViewById(R.id.button_deactivate_ephemeral_key_mode))
+                .setOnClickListener(v -> doDeactivateEphemeralKeyMode());
+
         updateState();
     }
 
@@ -234,12 +270,46 @@
                 });
     }
 
+    private void doActivateEphemeralKeyMode() {
+        mThreadController.activateEphemeralKeyMode(
+                Duration.ofMinutes(2),
+                mMainExecutor,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onError(ThreadNetworkException error) {
+                        Log.e(TAG, "Failed to activate ephemeral key", error);
+                    }
+
+                    @Override
+                    public void onResult(Void v) {
+                        Log.i(TAG, "Successfully activated ephemeral key mode");
+                    }
+                });
+    }
+
+    private void doDeactivateEphemeralKeyMode() {
+        mThreadController.deactivateEphemeralKeyMode(
+                mMainExecutor,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onError(ThreadNetworkException error) {
+                        Log.e(TAG, "Failed to deactivate ephemeral key", error);
+                    }
+
+                    @Override
+                    public void onResult(Void v) {
+                        Log.i(TAG, "Successfully deactivated ephemeral key mode");
+                    }
+                });
+    }
+
     private void updateState() {
         Log.i(
                 TAG,
                 String.format(
-                        "Updating Thread states (mDeviceRole: %s)",
-                        deviceRoleToString(mDeviceRole)));
+                        "Updating Thread states (mDeviceRole: %s, mEphemeralKeyState: %s)",
+                        deviceRoleToString(mDeviceRole),
+                        ephemeralKeyStateToString(mEphemeralKeyState)));
 
         String state =
                 String.format(
@@ -254,6 +324,30 @@
                                 ? base16().encode(mActiveDataset.getExtendedPanId())
                                 : null);
         mTextState.setText(state);
+
+        updateEphemeralKeyStatus();
+    }
+
+    private void updateEphemeralKeyStatus() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(ephemeralKeyStateToString(mEphemeralKeyState));
+        if (mEphemeralKeyState != ThreadNetworkController.EPHEMERAL_KEY_DISABLED) {
+            sb.append("\nPasscode: ");
+            sb.append(mEphemeralKey);
+            sb.append("\nRemaining lifetime: ");
+            sb.append(Instant.now().until(mEphemeralKeyExpiry, ChronoUnit.SECONDS));
+            sb.append(" seconds");
+            mEphemeralKeyLifetimeTimer = new Timer();
+            mEphemeralKeyLifetimeTimer.schedule(
+                    new TimerTask() {
+                        @Override
+                        public void run() {
+                            mMainExecutor.execute(() -> updateEphemeralKeyStatus());
+                        }
+                    },
+                    1000L /* delay in millis */);
+        }
+        mEphemeralKeyStateText.setText(sb.toString());
     }
 
     private void updateNetworkInfo(LinkProperties linProperties) {
diff --git a/thread/demoapp/res/layout/thread_network_settings_fragment.xml b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
index cae46a3..84d984b 100644
--- a/thread/demoapp/res/layout/thread_network_settings_fragment.xml
+++ b/thread/demoapp/res/layout/thread_network_settings_fragment.xml
@@ -14,58 +14,86 @@
      limitations under the License.
 -->
 
-<LinearLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:padding="8dp"
-    android:orientation="vertical"
-    tools:context=".ThreadNetworkSettingsFragment" >
+<ScrollView
+  xmlns:android="http://schemas.android.com/apk/res/android"
+  android:layout_width="match_parent"
+  android:layout_height="match_parent">
+    <LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        xmlns:app="http://schemas.android.com/apk/res-auto"
+        xmlns:tools="http://schemas.android.com/tools"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:padding="8dp"
+        android:orientation="vertical"
+        tools:context=".ThreadNetworkSettingsFragment" >
 
-    <Button android:id="@+id/button_join_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Join Network" />
-    <Button android:id="@+id/button_leave_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Leave Network" />
+        <Button android:id="@+id/button_join_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Join Network" />
+        <Button android:id="@+id/button_leave_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Leave Network" />
 
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="16dp"
-        android:textStyle="bold"
-        android:text="State" />
-    <TextView
-        android:id="@+id/text_state"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp"
-        android:typeface="monospace" />
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="16dp"
+            android:textStyle="bold"
+            android:text="State" />
+        <TextView
+            android:id="@+id/text_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12dp"
+            android:typeface="monospace" />
 
-    <TextView
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_marginTop="10dp"
-        android:textSize="16dp"
-        android:textStyle="bold"
-        android:text="Network Info" />
-    <TextView
-        android:id="@+id/text_network_info"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp" />
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:textSize="16dp"
+            android:textStyle="bold"
+            android:text="Network Info" />
+        <TextView
+            android:id="@+id/text_network_info"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12dp" />
 
-    <Button android:id="@+id/button_migrate_network"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="Migrate Network" />
-    <TextView
-        android:id="@+id/text_migrate_network_state"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:textSize="12dp" />
-</LinearLayout>
+        <Button android:id="@+id/button_migrate_network"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Migrate Network" />
+        <TextView
+            android:id="@+id/text_migrate_network_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:textSize="12dp" />
+
+        <Button android:id="@+id/button_activate_ephemeral_key_mode"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Activate Ephemeral Key Mode" />
+        <Button android:id="@+id/button_deactivate_ephemeral_key_mode"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="Deactivate Ephemeral Key Mode" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="10dp"
+            android:textSize="16dp"
+            android:textStyle="bold"
+            android:text="Ephemeral Key State" />
+        <TextView
+            android:id="@+id/text_ephemeral_key_state"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="50dp"
+            android:textSize="12dp" />
+    </LinearLayout>
+</ScrollView>
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 57c365b..d074b01 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -24,5 +24,5 @@
     void onPartitionIdChanged(long partitionId);
     void onThreadEnableStateChanged(int enabledState);
     void onEphemeralKeyStateChanged(
-            int ephemeralKeyState, @nullable String ephemeralKey, long expiryMillis);
+            int ephemeralKeyState, @nullable String ephemeralKey, long lifetimeMillis);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadConfiguration.java b/thread/framework/java/android/net/thread/ThreadConfiguration.java
index 1c25535..e6fa1ef 100644
--- a/thread/framework/java/android/net/thread/ThreadConfiguration.java
+++ b/thread/framework/java/android/net/thread/ThreadConfiguration.java
@@ -126,18 +126,29 @@
      *
      * @hide
      */
+    @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+    @SystemApi
     public static final class Builder {
         private boolean mNat64Enabled = false;
         private boolean mDhcpv6PdEnabled = false;
 
-        /** Creates a new {@link Builder} object with all features disabled. */
+        /**
+         * Creates a new {@link Builder} object with all features disabled.
+         *
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         public Builder() {}
 
         /**
          * Creates a new {@link Builder} object from a {@link ThreadConfiguration} object.
          *
          * @param config the Border Router configurations to be copied
+         * @hide
          */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         public Builder(@NonNull ThreadConfiguration config) {
             Objects.requireNonNull(config);
 
@@ -150,7 +161,11 @@
          *
          * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
          * IPv4.
+         *
+         * @hide
          */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         @NonNull
         public Builder setNat64Enabled(boolean enabled) {
             this.mNat64Enabled = enabled;
@@ -162,6 +177,8 @@
          *
          * <p>Enabling this feature will allow Thread devices to connect to the internet/cloud over
          * IPv6.
+         *
+         * @hide
          */
         @NonNull
         public Builder setDhcpv6PdEnabled(boolean enabled) {
@@ -169,7 +186,13 @@
             return this;
         }
 
-        /** Creates a new {@link ThreadConfiguration} object. */
+        /**
+         * Creates a new {@link ThreadConfiguration} object.
+         *
+         * @hide
+         */
+        @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+        @SystemApi
         @NonNull
         public ThreadConfiguration build() {
             return new ThreadConfiguration(this);
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index 1222398..14d22d1 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -368,7 +368,7 @@
          *     0-9 of user-input friendly length (typically 9), or {@code null} if {@code
          *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED} or the caller doesn't have the
          *     permission {@link android.permission.THREAD_NETWORK_PRIVILEGED}
-         * @param expiry a timestamp of when the ephemeral key will expireor {@code null} if {@code
+         * @param expiry a timestamp of when the ephemeral key will expire or {@code null} if {@code
          *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED}
          */
         @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
@@ -420,17 +420,14 @@
 
         @Override
         public void onEphemeralKeyStateChanged(
-                @EphemeralKeyState int ephemeralKeyState, String ephemeralKey, long expiryMillis) {
-            if (!Flags.epskcEnabled()) {
-                throw new IllegalStateException(
-                        "This should not be called when Ephemeral key API is disabled");
-            }
-
+                @EphemeralKeyState int ephemeralKeyState,
+                String ephemeralKey,
+                long lifetimeMillis) {
             final long identity = Binder.clearCallingIdentity();
             final Instant expiry =
                     ephemeralKeyState == EPHEMERAL_KEY_DISABLED
                             ? null
-                            : Instant.ofEpochMilli(expiryMillis);
+                            : Instant.now().plusMillis(lifetimeMillis);
 
             try {
                 mExecutor.execute(
@@ -748,15 +745,19 @@
      * OutcomeReceiver#onResult} will be called, and the {@code configuration} will be applied and
      * persisted to the device; the configuration changes can be observed by {@link
      * #registerConfigurationCallback}. On failure, {@link OutcomeReceiver#onError} of {@code
-     * receiver} will be invoked with a specific error.
+     * receiver} will be invoked with a specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_UNSUPPORTED_FEATURE} the configuration enables a
+     *       feature which is not supported by the platform.
+     * </ul>
      *
      * @param configuration the configuration to set
      * @param executor the executor to execute {@code receiver}
      * @param receiver the receiver to receive result of this operation
-     * @hide
      */
-    // @FlaggedApi(ThreadNetworkFlags.FLAG_CONFIGURATION_ENABLED)
-    // @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
+    @FlaggedApi(Flags.FLAG_SET_NAT64_CONFIGURATION_ENABLED)
+    @RequiresPermission(permission.THREAD_NETWORK_PRIVILEGED)
     public void setConfiguration(
             @NonNull ThreadConfiguration configuration,
             @NonNull @CallbackExecutor Executor executor,
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 3d854d7..653b2fb 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -220,7 +220,6 @@
     private boolean mUserRestricted;
     private boolean mForceStopOtDaemonEnabled;
 
-    private OtDaemonConfiguration mOtDaemonConfig;
     private InfraLinkState mInfraLinkState;
 
     @VisibleForTesting
@@ -249,7 +248,6 @@
         // TODO: networkToLinkProperties should be shared with NsdPublisher, add a test/assert to
         // verify they are the same.
         mNetworkToLinkProperties = networkToLinkProperties;
-        mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
         mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
         mNsdPublisher = nsdPublisher;
@@ -346,6 +344,7 @@
         otDaemon.initialize(
                 mTunIfController.getTunFd(),
                 shouldEnableThread(),
+                newOtDaemonConfig(mPersistentSettings.getConfiguration()),
                 mNsdPublisher,
                 getMeshcopTxtAttributes(mResources.get()),
                 mOtDaemonCallbackProxy,
@@ -445,24 +444,26 @@
     }
 
     public void initialize() {
-        mHandler.post(
-                () -> {
-                    LOG.v(
-                            "Initializing Thread system service: Thread is "
-                                    + (shouldEnableThread() ? "enabled" : "disabled"));
-                    try {
-                        mTunIfController.createTunInterface();
-                    } catch (IOException e) {
-                        throw new IllegalStateException(
-                                "Failed to create Thread tunnel interface", e);
-                    }
-                    mConnectivityManager.registerNetworkProvider(mNetworkProvider);
-                    requestUpstreamNetwork();
-                    registerThreadNetworkCallback();
-                    mUserRestricted = isThreadUserRestricted();
-                    registerUserRestrictionsReceiver();
-                    maybeInitializeOtDaemon();
-                });
+        mHandler.post(() -> initializeInternal());
+    }
+
+    private void initializeInternal() {
+        checkOnHandlerThread();
+
+        LOG.v(
+                "Initializing Thread system service: Thread is "
+                        + (shouldEnableThread() ? "enabled" : "disabled"));
+        try {
+            mTunIfController.createTunInterface();
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to create Thread tunnel interface", e);
+        }
+        mConnectivityManager.registerNetworkProvider(mNetworkProvider);
+        requestUpstreamNetwork();
+        registerThreadNetworkCallback();
+        mUserRestricted = isThreadUserRestricted();
+        registerUserRestrictionsReceiver();
+        maybeInitializeOtDaemon();
     }
 
     /**
@@ -556,22 +557,21 @@
     public void setConfiguration(
             @NonNull ThreadConfiguration configuration, @NonNull IOperationReceiver receiver) {
         enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
-        mHandler.post(() -> setConfigurationInternal(configuration, receiver));
+        mHandler.post(
+                () ->
+                        setConfigurationInternal(
+                                configuration, new OperationReceiverWrapper(receiver)));
     }
 
     private void setConfigurationInternal(
             @NonNull ThreadConfiguration configuration,
-            @NonNull IOperationReceiver operationReceiver) {
+            @NonNull OperationReceiverWrapper receiver) {
         checkOnHandlerThread();
 
         LOG.i("Set Thread configuration: " + configuration);
 
         final boolean changed = mPersistentSettings.putConfiguration(configuration);
-        try {
-            operationReceiver.onSuccess();
-        } catch (RemoteException e) {
-            // do nothing if the client is dead
-        }
+        receiver.onSuccess();
         if (changed) {
             for (IConfigurationReceiver configReceiver : mConfigurationReceivers.keySet()) {
                 try {
@@ -581,7 +581,22 @@
                 }
             }
         }
-        // TODO: set the configuration at ot-daemon
+        try {
+            getOtDaemon()
+                    .setConfiguration(
+                            newOtDaemonConfig(configuration),
+                            new LoggingOtStatusReceiver("setConfiguration"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.setConfiguration failed. Config: " + configuration, e);
+        }
+    }
+
+    private static OtDaemonConfiguration newOtDaemonConfig(
+            @NonNull ThreadConfiguration threadConfig) {
+        return new OtDaemonConfiguration.Builder()
+                .setNat64Enabled(threadConfig.isNat64Enabled())
+                .setDhcpv6PdEnabled(threadConfig.isDhcpv6PdEnabled())
+                .build();
     }
 
     @Override
@@ -764,19 +779,17 @@
                             + ", localNetworkInfo: "
                             + localNetworkInfo
                             + "}");
-            if (localNetworkInfo.getUpstreamNetwork() == null) {
+            mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
+            if (mUpstreamNetwork == null) {
                 setInfraLinkState(newInfraLinkStateBuilder().build());
                 return;
             }
-            if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
-                mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
-                if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
-                    setInfraLinkState(
-                            newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
-                                    .build());
-                }
-                mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
+            if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
+                setInfraLinkState(
+                        newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
+                                .build());
             }
+            mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
         }
     }
 
@@ -1308,20 +1321,15 @@
     }
 
     private void setInfraLinkState(InfraLinkState newInfraLinkState) {
-        if (mInfraLinkState.equals(newInfraLinkState)) {
-            return;
+        if (!Objects.equals(mInfraLinkState, newInfraLinkState)) {
+            LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
         }
-        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
-
         setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
         setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
         mInfraLinkState = newInfraLinkState;
     }
 
     private void setInfraLinkInterfaceName(String newInfraLinkInterfaceName) {
-        if (Objects.equals(mInfraLinkState.interfaceName, newInfraLinkInterfaceName)) {
-            return;
-        }
         ParcelFileDescriptor infraIcmp6Socket = null;
         if (newInfraLinkInterfaceName != null) {
             try {
@@ -1342,9 +1350,6 @@
     }
 
     private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
-        if (Objects.equals(mInfraLinkState.nat64Prefix, newNat64Prefix)) {
-            return;
-        }
         try {
             getOtDaemon()
                     .setInfraLinkNat64Prefix(
@@ -1477,11 +1482,6 @@
         return builder.build();
     }
 
-    private static OtDaemonConfiguration.Builder newOtDaemonConfigBuilder(
-            OtDaemonConfiguration config) {
-        return new OtDaemonConfiguration.Builder();
-    }
-
     private static InfraLinkState.Builder newInfraLinkStateBuilder() {
         return new InfraLinkState.Builder().setInterfaceName("");
     }
@@ -1787,7 +1787,7 @@
                             .onEphemeralKeyStateChanged(
                                     newState.ephemeralKeyState,
                                     passcode,
-                                    newState.ephemeralKeyExpiryMillis);
+                                    newState.ephemeralKeyLifetimeMillis);
                 } catch (RemoteException ignored) {
                     // do nothing if the client is dead
                 }
@@ -1800,7 +1800,7 @@
             if (oldState.ephemeralKeyState != newState.ephemeralKeyState) return true;
             if (oldState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) return false;
             return (!Objects.equals(oldState.ephemeralKeyPasscode, newState.ephemeralKeyPasscode)
-                    || oldState.ephemeralKeyExpiryMillis != newState.ephemeralKeyExpiryMillis);
+                    || oldState.ephemeralKeyLifetimeMillis != newState.ephemeralKeyLifetimeMillis);
         }
 
         private void onActiveOperationalDatasetChanged(
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 1eddebf..5d869df 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -19,10 +19,12 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOutputReceiver;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
 import android.os.Binder;
 import android.os.Process;
@@ -56,6 +58,7 @@
     private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
     private static final Duration OT_CTL_COMMAND_TIMEOUT = Duration.ofSeconds(5);
+    private static final Duration CONFIG_TIMEOUT = Duration.ofSeconds(1);
     private static final String PERMISSION_THREAD_NETWORK_TESTING =
             "android.permission.THREAD_NETWORK_TESTING";
 
@@ -118,6 +121,8 @@
         pw.println("    Sets country code to <two-letter code> or left for normal value");
         pw.println("  ot-ctl <subcommand>");
         pw.println("    Runs ot-ctl command");
+        pw.println("  config [name] [value]");
+        pw.println("    Gets the config or sets the value for a config entry");
     }
 
     @Override
@@ -144,6 +149,8 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
+            case "config":
+                return handleConfigCommand();
             case "ot-ctl":
                 return handleOtCtlCommand();
             default:
@@ -261,6 +268,68 @@
         return 0;
     }
 
+    private int handleConfigCommand() {
+        ensureTestingPermission();
+
+        // Get config
+        if (peekNextArg() == null) {
+            try {
+                final ThreadConfiguration config = getConfig();
+                getOutputWriter().println("Thread configuration = " + config);
+            } catch (AssertionError e) {
+                getErrorWriter().println("Failed: " + e.getMessage());
+                return -1;
+            }
+            return 0;
+        }
+
+        // Set config
+        final String name = getNextArg();
+        final String value = getNextArg();
+        try {
+            setConfig(name, value);
+        } catch (AssertionError | IllegalArgumentException e) {
+            getErrorWriter().println(e.getMessage());
+            return -1;
+        }
+        return 0;
+    }
+
+    private ThreadConfiguration getConfig() throws AssertionError {
+        final CompletableFuture<ThreadConfiguration> future = new CompletableFuture<>();
+        mControllerService.registerConfigurationCallback(
+                new IConfigurationReceiver.Stub() {
+                    @Override
+                    public void onConfigurationChanged(ThreadConfiguration config) {
+                        future.complete(config);
+                    }
+                });
+        try {
+            return future.get(CONFIG_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
+        } catch (InterruptedException | ExecutionException | TimeoutException e) {
+            throw new AssertionError("Failed to get config within timeout", e);
+        }
+    }
+
+    private void setConfig(String name, String value)
+            throws IllegalArgumentException, AssertionError {
+        if (name == null || value == null) {
+            throw new IllegalArgumentException(
+                    "Invalid config name = " + name + ", value=" + value);
+        }
+        final ThreadConfiguration oldConfig = getConfig();
+        final ThreadConfiguration.Builder newConfigBuilder =
+                new ThreadConfiguration.Builder(oldConfig);
+        switch (name) {
+            case "nat64" -> newConfigBuilder.setNat64Enabled(argEnabledOrDisabled(value));
+            case "pd" -> newConfigBuilder.setDhcpv6PdEnabled(argEnabledOrDisabled(value));
+            default -> throw new IllegalArgumentException("Invalid config name: " + name);
+        }
+        CompletableFuture<Void> future = new CompletableFuture();
+        mControllerService.setConfiguration(newConfigBuilder.build(), newOperationReceiver(future));
+        waitForFuture(future, CONFIG_TIMEOUT, mErrorWriter);
+    }
+
     private static final class OutputReceiver extends IOutputReceiver.Stub {
         private final CompletableFuture<Void> future;
         private final PrintWriter outputWriter;
@@ -359,6 +428,10 @@
         }
     }
 
+    private static boolean argEnabledOrDisabled(String arg) {
+        return argTrueOrFalse(arg, "enabled", "disabled");
+    }
+
     private boolean getNextArgRequiredTrueOrFalse(String trueString, String falseString) {
         String nextArg = getNextArgRequired();
         return argTrueOrFalse(nextArg, trueString, falseString);
diff --git a/thread/service/java/com/android/server/thread/TunInterfaceController.java b/thread/service/java/com/android/server/thread/TunInterfaceController.java
index 85a0371..3bff9c6 100644
--- a/thread/service/java/com/android/server/thread/TunInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/TunInterfaceController.java
@@ -93,7 +93,7 @@
 
     /** Returns link properties of the Thread TUN interface. */
     public LinkProperties getLinkProperties() {
-        return mLinkProperties;
+        return new LinkProperties(mLinkProperties);
     }
 
     /**
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 1792bfb..92227b4 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -182,6 +182,7 @@
     @After
     public void tearDown() throws Exception {
         dropAllPermissions();
+        setEnabledAndWait(mController, true);
         leaveAndWait(mController);
         tearDownTestNetwork();
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
@@ -1042,7 +1043,9 @@
                     listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
 
             assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
-            assertThat(epskc2.getThird()).isEqualTo(epskc1.getThird());
+            // allow time precision loss of a second since the value is passed via IPC
+            assertThat(epskc2.getThird()).isGreaterThan(epskc1.getThird().minusSeconds(1));
+            assertThat(epskc2.getThird()).isLessThan(epskc1.getThird().plusSeconds(1));
         } finally {
             listener2.unregisterStateCallback();
         }
@@ -1148,15 +1151,9 @@
         CompletableFuture<Void> setFuture2 = new CompletableFuture<>();
         ConfigurationListener listener = new ConfigurationListener(mController);
         ThreadConfiguration config1 =
-                new ThreadConfiguration.Builder()
-                        .setNat64Enabled(true)
-                        .setDhcpv6PdEnabled(true)
-                        .build();
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
         ThreadConfiguration config2 =
-                new ThreadConfiguration.Builder()
-                        .setNat64Enabled(false)
-                        .setDhcpv6PdEnabled(true)
-                        .build();
+                new ThreadConfiguration.Builder().setNat64Enabled(false).build();
 
         try {
             runAsShell(
@@ -1268,7 +1265,10 @@
 
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
-        assertThat(txtMap.get("sb")).isNotNull();
+        // Border Agent State Bitmap is 32 bits
+        assertThat(txtMap.get("sb").length).isEqualTo(4);
+        // The 12th bit (4th bit of the second byte) for ePSKc support should be set to 1.
+        assertThat(txtMap.get("sb")[2] & 8).isEqualTo(8);
     }
 
     @Test
@@ -1293,7 +1293,10 @@
         Map<String, byte[]> txtMap = resolvedService.getAttributes();
         assertThat(txtMap.get("rv")).isNotNull();
         assertThat(txtMap.get("tv")).isNotNull();
-        assertThat(txtMap.get("sb")).isNotNull();
+        // Border Agent State Bitmap is 32 bits
+        assertThat(txtMap.get("sb").length).isEqualTo(4);
+        // The 12th bit (4th bit of the second byte) for ePSKc support should be set to 1.
+        assertThat(txtMap.get("sb")[2] & 8).isEqualTo(8);
         assertThat(txtMap.get("id").length).isEqualTo(16);
     }
 
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 4a8462d8..f8e92f0 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -29,6 +29,7 @@
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
 import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
 import static android.net.thread.utils.IntegrationTestUtils.sendUdpMessage;
+import static android.net.thread.utils.IntegrationTestUtils.stopOtDaemon;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 import static android.system.OsConstants.ICMP_ECHO;
 
@@ -46,7 +47,6 @@
 import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
-import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
 import android.net.LinkProperties;
@@ -68,7 +68,7 @@
 import androidx.test.filters.LargeTest;
 import androidx.test.runner.AndroidJUnit4;
 
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 import com.android.testutils.TestNetworkTracker;
 
 import org.junit.After;
@@ -118,7 +118,7 @@
     private Handler mHandler;
     private TestNetworkTracker mInfraNetworkTracker;
     private List<FullThreadDevice> mFtds;
-    private TapPacketReader mInfraNetworkReader;
+    private PollPacketReader mInfraNetworkReader;
     private InfraNetworkDevice mInfraDevice;
 
     @Before
@@ -274,6 +274,28 @@
     }
 
     @Test
+    public void unicastRouting_otDaemonRestarts_borderRoutingWorks() throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
+
+        stopOtDaemon();
+        ftd.waitForStateAnyOf(List.of("leader", "router", "child"), Duration.ofSeconds(40));
+
+        startInfraDeviceAndWaitForOnLinkAddr();
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
+        assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
     @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
@@ -628,9 +650,8 @@
     public void nat64_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded() throws Exception {
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
-        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
         mOtCtl.setNat64Cidr(NAT64_CIDR);
-        mOtCtl.setNat64Enabled(true);
+        mController.setNat64EnabledAndWait(true);
         waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
         ftd.ping(IPV4_SERVER_ADDR);
@@ -663,8 +684,7 @@
         mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
         FullThreadDevice ftd = mFtds.get(0);
         joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
-        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
-        mOtCtl.setNat64Enabled(true);
+        mController.setNat64EnabledAndWait(true);
         mOtCtl.addPrefixInNetworkData(DHCP6_PD_PREFIX, "paros", "med");
         waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 87219d3..32e3b95 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,6 +19,7 @@
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_CONFIG;
 import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
@@ -79,6 +80,7 @@
     public void tearDown() throws Exception {
         mFtd.destroy();
         ensureThreadEnabled();
+        mController.setConfigurationAndWait(DEFAULT_CONFIG);
     }
 
     private static void ensureThreadEnabled() {
@@ -179,6 +181,27 @@
         assertThat(result).endsWith("Done\r\n");
     }
 
+    @Test
+    public void config_getConfig_expectedValueIsPrinted() throws Exception {
+        ThreadConfiguration config =
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
+        mController.setConfigurationAndWait(config);
+
+        final String result = runThreadCommand("config");
+
+        assertThat(result).contains("Nat64Enabled=true");
+    }
+
+    @Test
+    public void config_setConfig_expectedValueIsSet() throws Exception {
+        ThreadConfiguration config = new ThreadConfiguration.Builder().build();
+        mController.setConfigurationAndWait(config);
+
+        runThreadCommand("config nat64 enabled");
+
+        assertThat(mController.getConfiguration().isNat64Enabled()).isTrue();
+    }
+
     private static String runThreadCommand(String cmd) {
         return runShellCommandOrThrow("cmd thread_network " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
index 72a278c..cb0c8ee 100644
--- a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
@@ -28,7 +28,7 @@
 import com.android.net.module.util.Ipv6Utils;
 import com.android.net.module.util.structs.LlaOption;
 import com.android.net.module.util.structs.PrefixInformationOption;
-import com.android.testutils.TapPacketReader;
+import com.android.testutils.PollPacketReader;
 
 import java.io.IOException;
 import java.net.Inet6Address;
@@ -49,18 +49,18 @@
     // The MAC address of this device.
     public final MacAddress macAddr;
     // The packet reader of the TUN interface of the test network.
-    public final TapPacketReader packetReader;
+    public final PollPacketReader packetReader;
     // The IPv6 address generated by SLAAC for the device.
     public Inet6Address ipv6Addr;
 
     /**
      * Constructs an InfraNetworkDevice with the given {@link MAC address} and {@link
-     * TapPacketReader}.
+     * PollPacketReader}.
      *
      * @param macAddr the MAC address of the device
      * @param packetReader the packet reader of the TUN interface of the test network.
      */
-    public InfraNetworkDevice(MacAddress macAddr, TapPacketReader packetReader) {
+    public InfraNetworkDevice(MacAddress macAddr, PollPacketReader packetReader) {
         this.macAddr = macAddr;
         this.packetReader = packetReader;
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index 3df74b0..d903636 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -32,6 +32,7 @@
 import android.net.nsd.NsdManager
 import android.net.nsd.NsdServiceInfo
 import android.net.thread.ActiveOperationalDataset
+import android.net.thread.ThreadConfiguration
 import android.net.thread.ThreadNetworkController
 import android.os.Build
 import android.os.Handler
@@ -47,7 +48,7 @@
 import com.android.net.module.util.structs.Ipv6Header
 import com.android.net.module.util.structs.PrefixInformationOption
 import com.android.net.module.util.structs.RaHeader
-import com.android.testutils.TapPacketReader
+import com.android.testutils.PollPacketReader
 import com.android.testutils.TestNetworkTracker
 import com.android.testutils.initTestNetwork
 import com.android.testutils.runAsShell
@@ -108,6 +109,9 @@
     val DEFAULT_DATASET: ActiveOperationalDataset =
         ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS)
 
+    @JvmField
+    val DEFAULT_CONFIG = ThreadConfiguration.Builder().build()
+
     /**
      * Waits for the given [Supplier] to be true until given timeout.
      *
@@ -136,18 +140,18 @@
     }
 
     /**
-     * Creates a [TapPacketReader] given the [TestNetworkInterface] and [Handler].
+     * Creates a [PollPacketReader] given the [TestNetworkInterface] and [Handler].
      *
      * @param testNetworkInterface the TUN interface of the test network
      * @param handler the handler to process the packets
-     * @return the [TapPacketReader]
+     * @return the [PollPacketReader]
      */
     @JvmStatic
     fun newPacketReader(
         testNetworkInterface: TestNetworkInterface, handler: Handler
-    ): TapPacketReader {
+    ): PollPacketReader {
         val fd = testNetworkInterface.fileDescriptor.fileDescriptor
-        val reader = TapPacketReader(handler, fd, testNetworkInterface.mtu)
+        val reader = PollPacketReader(handler, fd, testNetworkInterface.mtu)
         handler.post { reader.start() }
         handler.waitForIdle(timeoutMs = 5000)
         return reader
@@ -191,7 +195,7 @@
     }
 
     /**
-     * Polls for a packet from a given [TapPacketReader] that satisfies the `filter`.
+     * Polls for a packet from a given [PollPacketReader] that satisfies the `filter`.
      *
      * @param packetReader a TUN packet reader
      * @param filter the filter to be applied on the packet
@@ -199,7 +203,7 @@
      * than 3000ms to read the next packet, the method will return null
      */
     @JvmStatic
-    fun pollForPacket(packetReader: TapPacketReader, filter: Predicate<ByteArray>): ByteArray? {
+    fun pollForPacket(packetReader: PollPacketReader, filter: Predicate<ByteArray>): ByteArray? {
         var packet: ByteArray?
         while ((packetReader.poll(3000 /* timeoutMs */, filter).also { packet = it }) != null) {
             return packet
@@ -570,10 +574,10 @@
     @JvmStatic
     @JvmOverloads
     fun startInfraDeviceAndWaitForOnLinkAddr(
-        tapPacketReader: TapPacketReader,
-        macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6")
+            pollPacketReader: PollPacketReader,
+            macAddress: MacAddress = MacAddress.fromString("1:2:3:4:5:6")
     ): InfraNetworkDevice {
-        val infraDevice = InfraNetworkDevice(macAddress, tapPacketReader)
+        val infraDevice = InfraNetworkDevice(macAddress, pollPacketReader)
         infraDevice.runSlaac(Duration.ofSeconds(60))
         requireNotNull(infraDevice.ipv6Addr)
         return infraDevice
@@ -601,4 +605,12 @@
     fun tearDownInfraNetwork(testNetworkTracker: TestNetworkTracker) {
         runAsShell(MANAGE_TEST_NETWORKS) { testNetworkTracker.teardown() }
     }
+
+    /**
+     * Stop the ot-daemon by shell command.
+     */
+    @JvmStatic
+    fun stopOtDaemon() {
+        runShellCommandOrThrow("stop ot-daemon")
+    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
index 7e84233..4354702 100644
--- a/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
+++ b/thread/tests/integration/src/android/net/thread/utils/ThreadNetworkControllerWrapper.java
@@ -29,6 +29,7 @@
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.ThreadNetworkException;
@@ -40,6 +41,7 @@
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
 
 /** A helper class which provides synchronous API wrappers for {@link ThreadNetworkController}. */
 public final class ThreadNetworkControllerWrapper {
@@ -47,6 +49,7 @@
     public static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
     private static final Duration SET_ENABLED_TIMEOUT = Duration.ofSeconds(2);
+    private static final Duration CONFIG_TIMEOUT = Duration.ofSeconds(1);
 
     private final ThreadNetworkController mController;
 
@@ -191,6 +194,36 @@
         future.get(CALLBACK_TIMEOUT.toSeconds(), SECONDS);
     }
 
+    public ThreadConfiguration getConfiguration() throws Exception {
+        CompletableFuture<ThreadConfiguration> future = new CompletableFuture<>();
+        Consumer<ThreadConfiguration> callback = future::complete;
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.registerConfigurationCallback(directExecutor(), callback));
+        future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS);
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.unregisterConfigurationCallback(callback));
+        return future.getNow(null);
+    }
+
+    public void setConfigurationAndWait(ThreadConfiguration config) throws Exception {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.setConfiguration(
+                                config, directExecutor(), newOutcomeReceiver(future)));
+        future.get(CONFIG_TIMEOUT.toSeconds(), SECONDS);
+    }
+
+    public void setNat64EnabledAndWait(boolean enabled) throws Exception {
+        final ThreadConfiguration config = getConfiguration();
+        final ThreadConfiguration newConfig =
+                new ThreadConfiguration.Builder(config).setNat64Enabled(enabled).build();
+        setConfigurationAndWait(newConfig);
+    }
+
     private static <V> OutcomeReceiver<V, ThreadNetworkException> newOutcomeReceiver(
             CompletableFuture<V> future) {
         return new OutcomeReceiver<V, ThreadNetworkException>() {
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index 62801bf..e3c83f1 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -147,6 +147,14 @@
         return (IOperationReceiver) invocation.getArguments()[0];
     }
 
+    private static IOperationReceiver getSetConfigurationReceiver(InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[1];
+    }
+
+    private static IConfigurationReceiver getConfigurationReceiver(InvocationOnMock invocation) {
+        return (IConfigurationReceiver) invocation.getArguments()[0];
+    }
+
     @Test
     public void registerStateCallback_callbackIsInvokedWithCallingAppIdentity() throws Exception {
         setBinderUid(SYSTEM_UID);
@@ -537,4 +545,68 @@
         assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
         assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
     }
+
+    @Test
+    public void setConfiguration_callbackIsInvokedWithCallingAppIdentity() throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getSetConfigurationReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .setConfiguration(any(ThreadConfiguration.class), any(IOperationReceiver.class));
+        mController.setConfiguration(
+                new ThreadConfiguration.Builder().build(),
+                Runnable::run,
+                v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getSetConfigurationReceiver(invoke).onError(ERROR_INTERNAL_ERROR, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .setConfiguration(any(ThreadConfiguration.class), any(IOperationReceiver.class));
+        mController.setConfiguration(
+                new ThreadConfiguration.Builder().build(),
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
+    public void registerConfigurationCallback_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger callbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getConfigurationReceiver(invoke)
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder().build());
+                            return null;
+                        })
+                .when(mMockService)
+                .registerConfigurationCallback(any(IConfigurationReceiver.class));
+
+        mController.registerConfigurationCallback(
+                Runnable::run, v -> callbackUid.set(Binder.getCallingUid()));
+
+        assertThat(callbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(callbackUid.get()).isEqualTo(Process.myUid());
+    }
 }
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 b97e2b7..7ac404f 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -741,10 +741,7 @@
                         .setDhcpv6PdEnabled(false)
                         .build();
         ThreadConfiguration config2 =
-                new ThreadConfiguration.Builder()
-                        .setNat64Enabled(true)
-                        .setDhcpv6PdEnabled(true)
-                        .build();
+                new ThreadConfiguration.Builder().setNat64Enabled(true).build();
         ThreadConfiguration config3 =
                 new ThreadConfiguration.Builder(config2).build(); // Same as config2
 
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index af5c9aa..c0e99d7 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -38,8 +38,11 @@
 
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IConfigurationReceiver;
+import android.net.thread.IOperationReceiver;
 import android.net.thread.IOutputReceiver;
 import android.net.thread.PendingOperationalDataset;
+import android.net.thread.ThreadConfiguration;
 import android.os.Binder;
 import android.os.Process;
 
@@ -320,4 +323,108 @@
         inOrder.verify(mOutputWriter).print("Done");
         inOrder.verify(mOutputWriter).print("\r\n");
     }
+
+    @Test
+    public void config_getConfig_testingPermissionIsChecked() {
+        runShellCommand("config");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void config_getConfig_serviceTimeOut_failsWithTimeoutError() {
+        runShellCommand("config");
+
+        verify(mControllerService, times(1)).registerConfigurationCallback(any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_getConfig_expectedValueIsPrinted() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder()
+                                                    .setNat64Enabled(true)
+                                                    .build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+
+        runShellCommand("config");
+
+        verify(mErrorWriter, never()).println();
+        verify(mOutputWriter, times(1)).println(contains("Nat64Enabled=true"));
+    }
+
+    @Test
+    public void config_setConfig_testingPermissionIsChecked() {
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void config_setConfig_serviceTimeOut_failedWithTimeoutError() {
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mControllerService, times(1)).registerConfigurationCallback(any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_invalidArgument_failsWithInvalidArgumentError() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder().build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+
+        runShellCommand("config", "invalidName", "invalidValue");
+
+        verify(mErrorWriter, atLeastOnce()).println(contains("Invalid config"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void config_setConfig_expectedValueIsSet() {
+        doAnswer(
+                        inv -> {
+                            ((IConfigurationReceiver) inv.getArgument(0))
+                                    .onConfigurationChanged(
+                                            new ThreadConfiguration.Builder()
+                                                    .setNat64Enabled(false)
+                                                    .build());
+                            return null;
+                        })
+                .when(mControllerService)
+                .registerConfigurationCallback(any());
+        doAnswer(
+                        inv -> {
+                            ((IOperationReceiver) inv.getArgument(0)).onSuccess();
+                            return null;
+                        })
+                .when(mControllerService)
+                .setConfiguration(any(), any());
+
+        runShellCommand("config", "nat64", "enabled");
+
+        verify(mControllerService, times(1))
+                .setConfiguration(
+                        eq(new ThreadConfiguration.Builder().setNat64Enabled(true).build()), any());
+        verify(mErrorWriter, never()).println();
+        verify(mOutputWriter, never()).println();
+    }
 }