Merge "Pass upstream prefixes to bpf tethering offload" into main
diff --git a/TEST_MAPPING b/TEST_MAPPING
index fafd3bb..46308af 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -5,6 +5,9 @@
       "options": [
         {
           "exclude-annotation": "com.android.testutils.NetworkStackModuleTest"
+        },
+        {
+          "exclude-annotation": "com.android.testutils.SkipPresubmit"
         }
       ]
     },
@@ -260,7 +263,12 @@
       "name": "netd_updatable_unit_test[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
     },
     {
-      "name": "ConnectivityCoverageTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
+      "name": "ConnectivityCoverageTests[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]",
+      "options": [
+        {
+          "exclude-annotation": "com.android.testutils.SkipPresubmit"
+        }
+      ]
     },
     {
       "name": "traffic_controller_unit_test[CaptivePortalLoginGoogle.apk+NetworkStackGoogle.apk+com.google.android.resolv.apex+com.google.android.tethering.apex]"
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 4478b1e..e69b872 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -95,6 +95,7 @@
     ],
     static_libs: [
         "NetworkStackApiCurrentShims",
+        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: { strict_updatability_linting: true },
@@ -109,6 +110,7 @@
     ],
     static_libs: [
         "NetworkStackApiStableShims",
+        "net-utils-device-common-struct",
     ],
     apex_available: ["com.android.tethering"],
     lint: { strict_updatability_linting: true },
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 9757daa..cd8eac8 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -96,6 +96,8 @@
     },
     binaries: [
         "clatd",
+        "ethtool",
+        "netbpfload",
         "ot-daemon",
     ],
     canned_fs_config: "canned_fs_config",
diff --git a/Tethering/apex/canned_fs_config b/Tethering/apex/canned_fs_config
index 5a03347..1f5fcfa 100644
--- a/Tethering/apex/canned_fs_config
+++ b/Tethering/apex/canned_fs_config
@@ -1,2 +1,3 @@
 /bin/for-system 0 1000 0750
 /bin/for-system/clatd 1029 1029 06755
+/bin/netbpfload 0 0 0750
diff --git a/Tethering/src/android/net/ip/IpServer.java b/Tethering/src/android/net/ip/IpServer.java
index 8a93e4d..c78893a 100644
--- a/Tethering/src/android/net/ip/IpServer.java
+++ b/Tethering/src/android/net/ip/IpServer.java
@@ -33,6 +33,7 @@
 
 import static com.android.net.module.util.Inet4AddressUtils.intToInet4AddressHTH;
 import static com.android.net.module.util.NetworkStackConstants.RFC7421_PREFIX_LENGTH;
+import static com.android.networkstack.tethering.TetheringConfiguration.USE_SYNC_SM;
 import static com.android.networkstack.tethering.UpstreamNetworkState.isVcnInterface;
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 import static com.android.networkstack.tethering.util.TetheringMessageBase.BASE_IPSERVER;
@@ -44,6 +45,7 @@
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetheredClient;
 import android.net.TetheringManager;
 import android.net.TetheringRequestParcel;
@@ -55,7 +57,6 @@
 import android.net.dhcp.IDhcpServer;
 import android.net.ip.RouterAdvertisementDaemon.RaParams;
 import android.os.Handler;
-import android.os.Looper;
 import android.os.Message;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -68,10 +69,10 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.MessageUtils;
 import com.android.internal.util.State;
-import com.android.internal.util.StateMachine;
 import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.InterfaceController;
 import com.android.net.module.util.ip.IpNeighborMonitor;
@@ -84,6 +85,8 @@
 import com.android.networkstack.tethering.metrics.TetheringMetrics;
 import com.android.networkstack.tethering.util.InterfaceSet;
 import com.android.networkstack.tethering.util.PrefixUtils;
+import com.android.networkstack.tethering.util.StateMachineShim;
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo;
 
 import java.net.Inet4Address;
 import java.net.Inet6Address;
@@ -104,7 +107,7 @@
  *
  * @hide
  */
-public class IpServer extends StateMachine {
+public class IpServer extends StateMachineShim {
     public static final int STATE_UNAVAILABLE = 0;
     public static final int STATE_AVAILABLE   = 1;
     public static final int STATE_TETHERED    = 2;
@@ -247,6 +250,11 @@
     private final INetd mNetd;
     @NonNull
     private final BpfCoordinator mBpfCoordinator;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final Callback mCallback;
     private final InterfaceController mInterfaceCtrl;
     private final PrivateAddressCoordinator mPrivateAddressCoordinator;
@@ -306,18 +314,22 @@
     private LinkAddress mIpv4Address;
 
     private final TetheringMetrics mTetheringMetrics;
+    private final Handler mHandler;
 
     // TODO: Add a dependency object to pass the data members or variables from the tethering
     // object. It helps to reduce the arguments of the constructor.
     public IpServer(
-            String ifaceName, Looper looper, int interfaceType, SharedLog log,
-            INetd netd, @NonNull BpfCoordinator coordinator, Callback callback,
+            String ifaceName, Handler handler, int interfaceType, SharedLog log,
+            INetd netd, @NonNull BpfCoordinator bpfCoordinator,
+            @Nullable LateSdk<RoutingCoordinatorManager> routingCoordinator, Callback callback,
             TetheringConfiguration config, PrivateAddressCoordinator addressCoordinator,
             TetheringMetrics tetheringMetrics, Dependencies deps) {
-        super(ifaceName, looper);
+        super(ifaceName, USE_SYNC_SM ? null : handler.getLooper());
+        mHandler = handler;
         mLog = log.forSubComponent(ifaceName);
         mNetd = netd;
-        mBpfCoordinator = coordinator;
+        mBpfCoordinator = bpfCoordinator;
+        mRoutingCoordinator = routingCoordinator;
         mCallback = callback;
         mInterfaceCtrl = new InterfaceController(ifaceName, mNetd, mLog);
         mIfaceName = ifaceName;
@@ -346,13 +358,22 @@
         mTetheredState = new TetheredState();
         mUnavailableState = new UnavailableState();
         mWaitingForRestartState = new WaitingForRestartState();
-        addState(mInitialState);
-        addState(mLocalHotspotState);
-        addState(mTetheredState);
-        addState(mWaitingForRestartState, mTetheredState);
-        addState(mUnavailableState);
+        final ArrayList allStates = new ArrayList<StateInfo>();
+        allStates.add(new StateInfo(mInitialState, null));
+        allStates.add(new StateInfo(mLocalHotspotState, null));
+        allStates.add(new StateInfo(mTetheredState, null));
+        allStates.add(new StateInfo(mWaitingForRestartState, mTetheredState));
+        allStates.add(new StateInfo(mUnavailableState, null));
+        addAllStates(allStates);
+    }
 
-        setInitialState(mInitialState);
+    private Handler getHandler() {
+        return mHandler;
+    }
+
+    /** Start IpServer state machine. */
+    public void start() {
+        start(mInitialState);
     }
 
     /** Interface name which IpServer served.*/
@@ -809,21 +830,62 @@
         for (RouteInfo route : toBeRemoved) mLinkProperties.removeRoute(route);
     }
 
-    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+    private void addInterfaceToNetwork(final int netId, @NonNull final String ifaceName) {
         try {
-            // It's safe to call networkAddInterface() even if
-            // the interface is already in the local_network.
-            mNetd.networkAddInterface(INetd.LOCAL_NET_ID, mIfaceName);
-            try {
-                // Add routes from local network. Note that adding routes that
-                // already exist does not cause an error (EEXIST is silently ignored).
-                NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
-            } catch (IllegalStateException e) {
-                mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
-                return;
+            if (null != mRoutingCoordinator.value) {
+                // TODO : remove this call in favor of using the LocalNetworkConfiguration
+                // correctly, which will let ConnectivityService do it automatically.
+                mRoutingCoordinator.value.addInterfaceToNetwork(netId, ifaceName);
+            } else {
+                mNetd.networkAddInterface(netId, ifaceName);
             }
         } catch (ServiceSpecificException | RemoteException e) {
             mLog.e("Failed to add " + mIfaceName + " to local table: ", e);
+        }
+    }
+
+    private void addInterfaceForward(@NonNull final String fromIface,
+            @NonNull final String toIface) throws ServiceSpecificException, RemoteException {
+        if (null != mRoutingCoordinator.value) {
+            mRoutingCoordinator.value.addInterfaceForward(fromIface, toIface);
+        } else {
+            mNetd.tetherAddForward(fromIface, toIface);
+            mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+        }
+    }
+
+    private void removeInterfaceForward(@NonNull final String fromIface,
+            @NonNull final String toIface) {
+        if (null != mRoutingCoordinator.value) {
+            try {
+                mRoutingCoordinator.value.removeInterfaceForward(fromIface, toIface);
+            } catch (ServiceSpecificException e) {
+                mLog.e("Exception in removeInterfaceForward", e);
+            }
+        } else {
+            try {
+                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                mLog.e("Exception in ipfwdRemoveInterfaceForward", e);
+            }
+            try {
+                mNetd.tetherRemoveForward(fromIface, toIface);
+            } catch (RemoteException | ServiceSpecificException e) {
+                mLog.e("Exception in disableNat", e);
+            }
+        }
+    }
+
+    private void addRoutesToLocalNetwork(@NonNull final List<RouteInfo> toBeAdded) {
+        // It's safe to call addInterfaceToNetwork() even if
+        // the interface is already in the local_network.
+        addInterfaceToNetwork(INetd.LOCAL_NET_ID, mIfaceName);
+        try {
+            // Add routes from local network. Note that adding routes that
+            // already exist does not cause an error (EEXIST is silently ignored).
+            NetdUtils.addRoutesToLocalNetwork(mNetd, mIfaceName, toBeAdded);
+        } catch (IllegalStateException e) {
+            mLog.e("Failed to add IPv4/v6 routes to local table: " + e);
             return;
         }
 
@@ -1323,16 +1385,7 @@
             // to remove their rules, which generates errors.
             // Just do the best we can.
             mBpfCoordinator.maybeDetachProgram(mIfaceName, upstreamIface);
-            try {
-                mNetd.ipfwdRemoveInterfaceForward(mIfaceName, upstreamIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in ipfwdRemoveInterfaceForward: " + e.toString());
-            }
-            try {
-                mNetd.tetherRemoveForward(mIfaceName, upstreamIface);
-            } catch (RemoteException | ServiceSpecificException e) {
-                mLog.e("Exception in disableNat: " + e.toString());
-            }
+            removeInterfaceForward(mIfaceName, upstreamIface);
         }
 
         @Override
@@ -1388,10 +1441,9 @@
 
                         mBpfCoordinator.maybeAttachProgram(mIfaceName, ifname);
                         try {
-                            mNetd.tetherAddForward(mIfaceName, ifname);
-                            mNetd.ipfwdAddInterfaceForward(mIfaceName, ifname);
+                            addInterfaceForward(mIfaceName, ifname);
                         } catch (RemoteException | ServiceSpecificException e) {
-                            mLog.e("Exception enabling NAT: " + e.toString());
+                            mLog.e("Exception enabling iface forward", e);
                             cleanupUpstream();
                             mLastError = TETHER_ERROR_ENABLE_FORWARDING_ERROR;
                             transitionTo(mInitialState);
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index b371178..b7607ef 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -90,6 +90,7 @@
 import android.net.LinkProperties;
 import android.net.Network;
 import android.net.NetworkInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheringCallbackStartedParcel;
@@ -136,6 +137,7 @@
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.NetdUtils;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
 import com.android.networkstack.apishim.common.BluetoothPanShim.TetheredInterfaceCallbackShim;
@@ -250,6 +252,10 @@
     private final Handler mHandler;
     private final INetd mNetd;
     private final NetdCallback mNetdCallback;
+    // Contains null if the connectivity module is unsupported, as the routing coordinator is not
+    // available. Must use LateSdk because MessageUtils enumerates fields in this class, so it
+    // must be able to find all classes at runtime.
+    @NonNull private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinator;
     private final UserRestrictionActionListener mTetheringRestriction;
     private final ActiveDataSubIdListener mActiveDataSubIdListener;
     private final ConnectedClientsTracker mConnectedClientsTracker;
@@ -296,6 +302,7 @@
         mDeps = deps;
         mContext = mDeps.getContext();
         mNetd = mDeps.getINetd(mContext);
+        mRoutingCoordinator = mDeps.getRoutingCoordinator(mContext);
         mLooper = mDeps.getTetheringLooper();
         mNotificationUpdater = mDeps.getNotificationUpdater(mContext, mLooper);
         mTetheringMetrics = mDeps.getTetheringMetrics();
@@ -2834,9 +2841,10 @@
 
         mLog.i("adding IpServer for: " + iface);
         final TetherState tetherState = new TetherState(
-                new IpServer(iface, mLooper, interfaceType, mLog, mNetd, mBpfCoordinator,
-                             makeControlCallback(), mConfig, mPrivateAddressCoordinator,
-                             mTetheringMetrics, mDeps.getIpServerDependencies()), isNcm);
+                new IpServer(iface, mHandler, interfaceType, mLog, mNetd, mBpfCoordinator,
+                        mRoutingCoordinator, makeControlCallback(), mConfig,
+                        mPrivateAddressCoordinator, mTetheringMetrics,
+                        mDeps.getIpServerDependencies()), 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 747cc20..502fee8 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java
@@ -136,6 +136,9 @@
      */
     public static final int DEFAULT_TETHER_OFFLOAD_POLL_INTERVAL_MS = 5000;
 
+    /** A flag for using synchronous or asynchronous state machine. */
+    public static final boolean USE_SYNC_SM = false;
+
     public final String[] tetherableUsbRegexs;
     public final String[] tetherableWifiRegexs;
     public final String[] tetherableWigigRegexs;
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 741a5c5..c6468a0 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -16,11 +16,14 @@
 
 package com.android.networkstack.tethering;
 
+import android.annotation.Nullable;
 import android.app.usage.NetworkStatsManager;
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothPan;
 import android.content.Context;
 import android.net.INetd;
+import android.net.RoutingCoordinatorManager;
+import android.net.connectivity.TiramisuConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
 import android.os.Build;
 import android.os.Handler;
@@ -33,6 +36,8 @@
 import androidx.annotation.RequiresApi;
 
 import com.android.internal.util.StateMachine;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.networkstack.apishim.BluetoothPanShimImpl;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -122,6 +127,16 @@
     }
 
     /**
+     * Get the routing coordinator, or null if below S.
+     */
+    @Nullable
+    public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(Context context) {
+        if (!SdkLevel.isAtLeastS()) return new LateSdk<>(null);
+        return new LateSdk<>(
+                TiramisuConnectivityInternalApiUtil.getRoutingCoordinatorManager(context));
+    }
+
+    /**
      * Get a reference to the TetheringNotificationUpdater to be used by tethering.
      */
     public TetheringNotificationUpdater getNotificationUpdater(@NonNull final Context ctx,
@@ -135,7 +150,7 @@
     public abstract Looper getTetheringLooper();
 
     /**
-     *  Get Context of TetheringSerice.
+     *  Get Context of TetheringService.
      */
     public abstract Context getContext();
 
diff --git a/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java b/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java
new file mode 100644
index 0000000..fc432f7
--- /dev/null
+++ b/Tethering/src/com/android/networkstack/tethering/util/StateMachineShim.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.networkstack.tethering.util;
+
+import android.annotation.Nullable;
+import android.os.Looper;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.State;
+import com.android.internal.util.StateMachine;
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo;
+
+import java.util.List;
+
+/** A wrapper to decide whether use synchronous state machine for tethering. */
+public class StateMachineShim {
+    // Exactly one of mAsyncSM or mSyncSM is non-null.
+    private final StateMachine mAsyncSM;
+    private final SyncStateMachine mSyncSM;
+
+    /**
+     * The Looper parameter is only needed for AsyncSM, so if looper is null, the shim will be
+     * created for SyncSM.
+     */
+    public StateMachineShim(final String name, @Nullable final Looper looper) {
+        this(name, looper, new Dependencies());
+    }
+
+    @VisibleForTesting
+    public StateMachineShim(final String name, @Nullable final Looper looper,
+            final Dependencies deps) {
+        if (looper == null) {
+            mAsyncSM = null;
+            mSyncSM = deps.makeSyncStateMachine(name, Thread.currentThread());
+        } else {
+            mAsyncSM = deps.makeAsyncStateMachine(name, looper);
+            mSyncSM = null;
+        }
+    }
+
+    /** A dependencies class which used for testing injection. */
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Create SyncSM instance, for injection. */
+        public SyncStateMachine makeSyncStateMachine(final String name, final Thread thread) {
+            return new SyncStateMachine(name, thread);
+        }
+
+        /** Create AsyncSM instance, for injection. */
+        public AsyncStateMachine makeAsyncStateMachine(final String name, final Looper looper) {
+            return new AsyncStateMachine(name, looper);
+        }
+    }
+
+    /** Start the state machine */
+    public void start(final State initialState) {
+        if (mSyncSM != null) {
+            mSyncSM.start(initialState);
+        } else {
+            mAsyncSM.setInitialState(initialState);
+            mAsyncSM.start();
+        }
+    }
+
+    /** Add states to state machine. */
+    public void addAllStates(final List<StateInfo> stateInfos) {
+        if (mSyncSM != null) {
+            mSyncSM.addAllStates(stateInfos);
+        } else {
+            for (final StateInfo info : stateInfos) {
+                mAsyncSM.addState(info.state, info.parent);
+            }
+        }
+    }
+
+    /**
+     * Transition to given state.
+     *
+     * SyncSM doesn't allow this be called during state transition (#enter() or #exit() methods),
+     * or multiple times while processing a single message.
+     */
+    public void transitionTo(final State state) {
+        if (mSyncSM != null) {
+            mSyncSM.transitionTo(state);
+        } else {
+            mAsyncSM.transitionTo(state);
+        }
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what) {
+        sendMessage(what, 0, 0, null);
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what, Object obj) {
+        sendMessage(what, 0, 0, obj);
+    }
+
+    /** Send message to state machine. */
+    public void sendMessage(int what, int arg1) {
+        sendMessage(what, arg1, 0, null);
+    }
+
+    /**
+     * Send message to state machine.
+     *
+     * If using asynchronous state machine, putting the message into looper's message queue.
+     * Tethering runs on single looper thread that ipServers and mainSM all share with same message
+     * queue. The enqueued message will be processed by asynchronous state machine when all the
+     * messages before such enqueued message are processed.
+     * If using synchronous state machine, the message is processed right away without putting into
+     * looper's message queue.
+     */
+    public void sendMessage(int what, int arg1, int arg2, Object obj) {
+        if (mSyncSM != null) {
+            mSyncSM.processMessage(what, arg1, arg2, obj);
+        } else {
+            mAsyncSM.sendMessage(what, arg1, arg2, obj);
+        }
+    }
+
+    /**
+     * Send message after delayMillis millisecond.
+     *
+     * This can only be used with async state machine, so this will throw if using sync state
+     * machine.
+     */
+    public void sendMessageDelayedToAsyncSM(final int what, final long delayMillis) {
+        if (mSyncSM != null) {
+            throw new IllegalStateException("sendMessageDelayed can only be used with async SM");
+        }
+
+        mAsyncSM.sendMessageDelayed(what, delayMillis);
+    }
+
+    /**
+     * Send self message.
+     * This can only be used with sync state machine, so this will throw if using async state
+     * machine.
+     */
+    public void sendSelfMessageToSyncSM(final int what, final Object obj) {
+        if (mSyncSM == null) {
+            throw new IllegalStateException("sendSelfMessage can only be used with sync SM");
+        }
+
+        mSyncSM.sendSelfMessage(what, 0, 0, obj);
+    }
+
+    /**
+     * An alias StateMahchine class with public construtor.
+     *
+     * Since StateMachine.java only provides protected construtor, adding a child class so that this
+     * shim could create StateMachine instance.
+     */
+    @VisibleForTesting
+    public static class AsyncStateMachine extends StateMachine {
+        public AsyncStateMachine(final String name, final Looper looper) {
+            super(name, looper);
+        }
+    }
+}
diff --git a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
index 328e3fb..dac5b63 100644
--- a/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
+++ b/Tethering/tests/privileged/src/android/net/ip/RouterAdvertisementDaemonTest.java
@@ -16,8 +16,6 @@
 
 package android.net.ip;
 
-import static android.net.RouteInfo.RTN_UNICAST;
-
 import static com.android.net.module.util.NetworkStackConstants.ETHER_HEADER_LEN;
 import static com.android.net.module.util.NetworkStackConstants.ETHER_TYPE_IPV6;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_MTU;
@@ -42,12 +40,13 @@
 import android.net.INetd;
 import android.net.IpPrefix;
 import android.net.MacAddress;
-import android.net.RouteInfo;
 import android.net.ip.RouterAdvertisementDaemon.RaParams;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.IBinder;
 import android.os.Looper;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
 
 import androidx.test.InstrumentationRegistry;
 import androidx.test.filters.SmallTest;
@@ -55,7 +54,6 @@
 
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.Ipv6Utils;
-import com.android.net.module.util.NetdUtils;
 import com.android.net.module.util.Struct;
 import com.android.net.module.util.structs.EthernetHeader;
 import com.android.net.module.util.structs.Icmpv6Header;
@@ -80,7 +78,6 @@
 import java.net.InetAddress;
 import java.nio.ByteBuffer;
 import java.util.HashSet;
-import java.util.List;
 
 @RunWith(AndroidJUnit4.class)
 @SmallTest
@@ -332,10 +329,12 @@
         // Add a default route "fe80::/64 -> ::" to local network, otherwise, device will fail to
         // send the unicast RA out due to the ENETUNREACH error(No route to the peer's link-local
         // address is present).
-        final String iface = mTetheredParams.name;
-        final RouteInfo linkLocalRoute =
-                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST);
-        NetdUtils.addRoutesToLocalNetwork(sNetd, iface, List.of(linkLocalRoute));
+        try {
+            sNetd.networkAddRoute(INetd.LOCAL_NET_ID, mTetheredParams.name,
+                    "fe80::/64", INetd.NEXTHOP_NONE);
+        } catch (RemoteException | ServiceSpecificException e) {
+            throw new IllegalStateException(e);
+        }
 
         final ByteBuffer rs = createRsPacket("fe80::1122:3344:5566:7788");
         mTetheredPacketReader.sendResponse(rs);
diff --git a/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java b/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
index 81d4fbe..60f2d17 100644
--- a/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
+++ b/Tethering/tests/privileged/src/com/android/networkstack/tethering/ConntrackSocketTest.java
@@ -44,6 +44,7 @@
 import com.android.net.module.util.netlink.NetlinkUtils;
 import com.android.net.module.util.netlink.StructNlMsgHdr;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -84,6 +85,14 @@
         mOffloadHw = new OffloadHardwareInterface(mHandler, mLog, mDeps);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+    }
+
     void findConnectionOrThrow(FileDescriptor fd, InetSocketAddress local, InetSocketAddress remote)
             throws Exception {
         Log.d(TAG, "Looking for socket " + local + " -> " + remote);
diff --git a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
index 0440f5b..bc970e4 100644
--- a/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
+++ b/Tethering/tests/unit/src/android/net/ip/IpServerTest.java
@@ -63,6 +63,7 @@
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
@@ -81,6 +82,7 @@
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherOffloadRuleParcel;
 import android.net.TetherStatsParcel;
 import android.net.dhcp.DhcpServerCallbacks;
@@ -102,9 +104,11 @@
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.BpfMap;
 import com.android.net.module.util.InterfaceParams;
 import com.android.net.module.util.NetworkStackConstants;
+import com.android.net.module.util.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.Struct.S32;
 import com.android.net.module.util.bpf.Tether4Key;
@@ -211,6 +215,8 @@
     @Mock private IpNeighborMonitor mIpNeighborMonitor;
     @Mock private IpServer.Dependencies mDependencies;
     @Mock private PrivateAddressCoordinator mAddressCoordinator;
+    private final LateSdk<RoutingCoordinatorManager> mRoutingCoordinatorManager =
+            new LateSdk<>(SdkLevel.isAtLeastS() ? mock(RoutingCoordinatorManager.class) : null);
     @Mock private NetworkStatsManager mStatsManager;
     @Mock private TetheringConfiguration mTetherConfig;
     @Mock private ConntrackMonitor mConntrackMonitor;
@@ -227,6 +233,7 @@
     @Captor private ArgumentCaptor<DhcpServingParamsParcel> mDhcpParamsCaptor;
 
     private final TestLooper mLooper = new TestLooper();
+    private final Handler mHandler = new Handler(mLooper.getLooper());
     private final ArgumentCaptor<LinkProperties> mLinkPropertiesCaptor =
             ArgumentCaptor.forClass(LinkProperties.class);
     private IpServer mIpServer;
@@ -266,8 +273,9 @@
         // Recreate mBpfCoordinator again here because mTetherConfig has changed
         mBpfCoordinator = spy(new BpfCoordinator(mBpfDeps));
         mIpServer = new IpServer(
-                IFACE_NAME, mLooper.getLooper(), interfaceType, mSharedLog, mNetd, mBpfCoordinator,
-                mCallback, mTetherConfig, mAddressCoordinator, mTetheringMetrics, mDependencies);
+                IFACE_NAME, mHandler, interfaceType, mSharedLog, mNetd, mBpfCoordinator,
+                mRoutingCoordinatorManager, mCallback, mTetherConfig, mAddressCoordinator,
+                mTetheringMetrics, mDependencies);
         mIpServer.start();
         mNeighborEventConsumer = neighborCaptor.getValue();
 
@@ -335,10 +343,27 @@
         when(mTetherConfig.isBpfOffloadEnabled()).thenReturn(DEFAULT_USING_BPF_OFFLOAD);
         when(mTetherConfig.useLegacyDhcpServer()).thenReturn(false /* default value */);
 
+        // Simulate the behavior of RoutingCoordinator
+        if (null != mRoutingCoordinatorManager.value) {
+            doAnswer(it -> {
+                final String fromIface = (String) it.getArguments()[0];
+                final String toIface = (String) it.getArguments()[1];
+                mNetd.tetherAddForward(fromIface, toIface);
+                mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+                return null;
+            }).when(mRoutingCoordinatorManager.value).addInterfaceForward(any(), any());
+            doAnswer(it -> {
+                final String fromIface = (String) it.getArguments()[0];
+                final String toIface = (String) it.getArguments()[1];
+                mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+                mNetd.tetherRemoveForward(fromIface, toIface);
+                return null;
+            }).when(mRoutingCoordinatorManager.value).removeInterfaceForward(any(), any());
+        }
         mBpfDeps = new BpfCoordinator.Dependencies() {
                     @NonNull
                     public Handler getHandler() {
-                        return new Handler(mLooper.getLooper());
+                        return mHandler;
                     }
 
                     @NonNull
@@ -416,9 +441,9 @@
     public void startsOutAvailable() {
         when(mDependencies.getIpNeighborMonitor(any(), any(), any()))
                 .thenReturn(mIpNeighborMonitor);
-        mIpServer = new IpServer(IFACE_NAME, mLooper.getLooper(), TETHERING_BLUETOOTH, mSharedLog,
-                mNetd, mBpfCoordinator, mCallback, mTetherConfig, mAddressCoordinator,
-                mTetheringMetrics, mDependencies);
+        mIpServer = new IpServer(IFACE_NAME, mHandler, TETHERING_BLUETOOTH, mSharedLog,
+                mNetd, mBpfCoordinator, mRoutingCoordinatorManager, mCallback, mTetherConfig,
+                mAddressCoordinator, mTetheringMetrics, mDependencies);
         mIpServer.start();
         mLooper.dispatchAll();
         verify(mCallback).updateInterfaceState(
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 91b092a..6ebd6ae 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -126,16 +126,17 @@
 
         final LinkAddress newAddress = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        final IpPrefix testDupRequest = asIpPrefix(newAddress);
-        assertNotEquals(hotspotPrefix, testDupRequest);
-        assertNotEquals(bluetoothPrefix, testDupRequest);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
+        final IpPrefix newHotspotPrefix = asIpPrefix(newAddress);
+        assertNotEquals(hotspotPrefix, newHotspotPrefix);
+        assertNotEquals(bluetoothPrefix, newHotspotPrefix);
 
         final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
         final IpPrefix usbPrefix = asIpPrefix(usbAddress);
         assertNotEquals(usbPrefix, bluetoothPrefix);
-        assertNotEquals(usbPrefix, hotspotPrefix);
+        assertNotEquals(usbPrefix, newHotspotPrefix);
+
+        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
         mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
     }
 
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 770507e..ba39f22 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -142,6 +142,7 @@
 import android.net.NetworkCapabilities;
 import android.net.NetworkRequest;
 import android.net.RouteInfo;
+import android.net.RoutingCoordinatorManager;
 import android.net.TetherStatesParcel;
 import android.net.TetheredClient;
 import android.net.TetheredClient.AddressInfo;
@@ -191,6 +192,7 @@
 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.SdkUtil.LateSdk;
 import com.android.net.module.util.SharedLog;
 import com.android.net.module.util.ip.IpNeighborMonitor;
 import com.android.networkstack.apishim.common.BluetoothPanShim;
@@ -482,6 +484,12 @@
             return mEntitleMgr;
         }
 
+        @Nullable
+        @Override
+        public LateSdk<RoutingCoordinatorManager> getRoutingCoordinator(final Context context) {
+            return new LateSdk<>(null);
+        }
+
         @Override
         public TetheringConfiguration generateTetheringConfiguration(Context ctx, SharedLog log,
                 int subId) {
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt
new file mode 100644
index 0000000..2c4df76
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/StateMachineShimTest.kt
@@ -0,0 +1,128 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.networkstack.tethering.util
+
+import android.os.Looper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.util.State
+import com.android.networkstack.tethering.util.StateMachineShim.AsyncStateMachine
+import com.android.networkstack.tethering.util.StateMachineShim.Dependencies
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo
+import kotlin.test.assertFailsWith
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class StateMachineShimTest {
+    private val mSyncSM = mock(SyncStateMachine::class.java)
+    private val mAsyncSM = mock(AsyncStateMachine::class.java)
+    private val mState1 = mock(State::class.java)
+    private val mState2 = mock(State::class.java)
+
+    inner class MyDependencies() : Dependencies() {
+
+        override fun makeSyncStateMachine(name: String, thread: Thread) = mSyncSM
+
+        override fun makeAsyncStateMachine(name: String, looper: Looper) = mAsyncSM
+    }
+
+    @Test
+    fun testUsingSyncStateMachine() {
+        val inOrder = inOrder(mSyncSM, mAsyncSM)
+        val shimUsingSyncSM = StateMachineShim("ShimTest", null, MyDependencies())
+        shimUsingSyncSM.start(mState1)
+        inOrder.verify(mSyncSM).start(mState1)
+
+        val allStates = ArrayList<StateInfo>()
+        allStates.add(StateInfo(mState1, null))
+        allStates.add(StateInfo(mState2, mState1))
+        shimUsingSyncSM.addAllStates(allStates)
+        inOrder.verify(mSyncSM).addAllStates(allStates)
+
+        shimUsingSyncSM.transitionTo(mState1)
+        inOrder.verify(mSyncSM).transitionTo(mState1)
+
+        val what = 10
+        shimUsingSyncSM.sendMessage(what)
+        inOrder.verify(mSyncSM).processMessage(what, 0, 0, null)
+        val obj = Object()
+        shimUsingSyncSM.sendMessage(what, obj)
+        inOrder.verify(mSyncSM).processMessage(what, 0, 0, obj)
+        val arg1 = 11
+        shimUsingSyncSM.sendMessage(what, arg1)
+        inOrder.verify(mSyncSM).processMessage(what, arg1, 0, null)
+        val arg2 = 12
+        shimUsingSyncSM.sendMessage(what, arg1, arg2, obj)
+        inOrder.verify(mSyncSM).processMessage(what, arg1, arg2, obj)
+
+        assertFailsWith(IllegalStateException::class) {
+            shimUsingSyncSM.sendMessageDelayedToAsyncSM(what, 1000 /* delayMillis */)
+        }
+
+        shimUsingSyncSM.sendSelfMessageToSyncSM(what, obj)
+        inOrder.verify(mSyncSM).sendSelfMessage(what, 0, 0, obj)
+
+        verifyNoMoreInteractions(mSyncSM, mAsyncSM)
+    }
+
+    @Test
+    fun testUsingAsyncStateMachine() {
+        val inOrder = inOrder(mSyncSM, mAsyncSM)
+        val shimUsingAsyncSM = StateMachineShim("ShimTest", mock(Looper::class.java),
+                MyDependencies())
+        shimUsingAsyncSM.start(mState1)
+        inOrder.verify(mAsyncSM).setInitialState(mState1)
+        inOrder.verify(mAsyncSM).start()
+
+        val allStates = ArrayList<StateInfo>()
+        allStates.add(StateInfo(mState1, null))
+        allStates.add(StateInfo(mState2, mState1))
+        shimUsingAsyncSM.addAllStates(allStates)
+        inOrder.verify(mAsyncSM).addState(mState1, null)
+        inOrder.verify(mAsyncSM).addState(mState2, mState1)
+
+        shimUsingAsyncSM.transitionTo(mState1)
+        inOrder.verify(mAsyncSM).transitionTo(mState1)
+
+        val what = 10
+        shimUsingAsyncSM.sendMessage(what)
+        inOrder.verify(mAsyncSM).sendMessage(what, 0, 0, null)
+        val obj = Object()
+        shimUsingAsyncSM.sendMessage(what, obj)
+        inOrder.verify(mAsyncSM).sendMessage(what, 0, 0, obj)
+        val arg1 = 11
+        shimUsingAsyncSM.sendMessage(what, arg1)
+        inOrder.verify(mAsyncSM).sendMessage(what, arg1, 0, null)
+        val arg2 = 12
+        shimUsingAsyncSM.sendMessage(what, arg1, arg2, obj)
+        inOrder.verify(mAsyncSM).sendMessage(what, arg1, arg2, obj)
+
+        shimUsingAsyncSM.sendMessageDelayedToAsyncSM(what, 1000 /* delayMillis */)
+        inOrder.verify(mAsyncSM).sendMessageDelayed(what, 1000)
+
+        assertFailsWith(IllegalStateException::class) {
+            shimUsingAsyncSM.sendSelfMessageToSyncSM(what, obj)
+        }
+
+        verifyNoMoreInteractions(mSyncSM, mAsyncSM)
+    }
+}
diff --git a/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt
new file mode 100644
index 0000000..3a57fdd
--- /dev/null
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/util/SyncStateMachineTest.kt
@@ -0,0 +1,294 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.networkstack.tethering.util
+
+import android.os.Message
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.internal.util.State
+import com.android.networkstack.tethering.util.SyncStateMachine.StateInfo
+import java.util.ArrayDeque
+import java.util.ArrayList
+import kotlin.test.assertFailsWith
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.spy
+import org.mockito.Mockito.verifyNoMoreInteractions
+
+private const val MSG_INVALID = -1
+private const val MSG_1 = 1
+private const val MSG_2 = 2
+private const val MSG_3 = 3
+private const val MSG_4 = 4
+private const val MSG_5 = 5
+private const val MSG_6 = 6
+private const val MSG_7 = 7
+private const val ARG_1 = 100
+private const val ARG_2 = 200
+
+@RunWith(AndroidJUnit4::class)
+@SmallTest
+class SynStateMachineTest {
+    private val mState1 = spy(object : TestState(MSG_1) {})
+    private val mState2 = spy(object : TestState(MSG_2) {})
+    private val mState3 = spy(object : TestState(MSG_3) {})
+    private val mState4 = spy(object : TestState(MSG_4) {})
+    private val mState5 = spy(object : TestState(MSG_5) {})
+    private val mState6 = spy(object : TestState(MSG_6) {})
+    private val mState7 = spy(object : TestState(MSG_7) {})
+    private val mInOrder = inOrder(mState1, mState2, mState3, mState4, mState5, mState6, mState7)
+    // Lazy initialize to make sure running in test thread.
+    private val mSM by lazy {
+        SyncStateMachine("TestSyncStateMachine", Thread.currentThread(), true /* debug */)
+    }
+    private val mAllStates = ArrayList<StateInfo>()
+
+    private val mMsgProcessedResults = ArrayDeque<Pair<State, Int>>()
+
+    open inner class TestState(val expected: Int) : State() {
+        // Control destination state in obj field for testing.
+        override fun processMessage(msg: Message): Boolean {
+            mMsgProcessedResults.add(this to msg.what)
+            assertEquals(ARG_1, msg.arg1)
+            assertEquals(ARG_2, msg.arg2)
+
+            if (msg.what == expected) {
+                msg.obj?.let { mSM.transitionTo(it as State) }
+                return true
+            }
+
+            return false
+        }
+    }
+
+    private fun verifyNoMoreInteractions() {
+        verifyNoMoreInteractions(mState1, mState2, mState3, mState4, mState5, mState6)
+    }
+
+    private fun processMessage(what: Int, toState: State?) {
+        mSM.processMessage(what, ARG_1, ARG_2, toState)
+    }
+
+    private fun verifyMessageProcessedBy(what: Int, vararg processedStates: State) {
+        for (state in processedStates) {
+            // InOrder.verify can't check the Message content here because SyncSM will recycle the
+            // message after it's been processed. SyncSM reuses the same Message instance for all
+            // messages it processes. So, if using InOrder.verify to verify the content of a message
+            // after SyncSM has processed it, the content would be wrong.
+            mInOrder.verify(state).processMessage(any())
+            val (processedState, msgWhat) = mMsgProcessedResults.remove()
+            assertEquals(state, processedState)
+            assertEquals(what, msgWhat)
+        }
+        assertTrue(mMsgProcessedResults.isEmpty())
+    }
+
+    @Test
+    fun testInitialState() {
+        // mState1 -> initial
+        //    |
+        // mState2
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState2, mState1))
+        mSM.addAllStates(mAllStates)
+
+        mSM.start(mState1)
+        mInOrder.verify(mState1).enter()
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testStartFromLeafState() {
+        // mState1 -> initial
+        //    |
+        // mState2
+        //    |
+        // mState3
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState2, mState1))
+        mAllStates.add(StateInfo(mState3, mState2))
+        mSM.addAllStates(mAllStates)
+
+        mSM.start(mState3)
+        mInOrder.verify(mState1).enter()
+        mInOrder.verify(mState2).enter()
+        mInOrder.verify(mState3).enter()
+        verifyNoMoreInteractions()
+    }
+
+    private fun verifyStart() {
+        mSM.addAllStates(mAllStates)
+        mSM.start(mState1)
+        mInOrder.verify(mState1).enter()
+        verifyNoMoreInteractions()
+    }
+
+    fun addState(state: State, parent: State? = null) {
+        mAllStates.add(StateInfo(state, parent))
+    }
+
+    @Test
+    fun testAddState() {
+        // Add duplicated states.
+        mAllStates.add(StateInfo(mState1, null))
+        mAllStates.add(StateInfo(mState1, null))
+        assertFailsWith(IllegalStateException::class) {
+            mSM.addAllStates(mAllStates)
+        }
+    }
+
+    @Test
+    fun testProcessMessage() {
+        // mState1
+        //    |
+        // mState2
+        addState(mState1)
+        addState(mState2, mState1)
+        verifyStart()
+
+        processMessage(MSG_1, null)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testTwoStates() {
+        // mState1 <-initial, mState2
+        addState(mState1)
+        addState(mState2)
+        verifyStart()
+
+        // Test transition to mState2
+        processMessage(MSG_1, mState2)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState1).exit()
+        mInOrder.verify(mState2).enter()
+        verifyNoMoreInteractions()
+
+        // If set destState to mState2 (current state), no state transition.
+        processMessage(MSG_2, mState2)
+        verifyMessageProcessedBy(MSG_2, mState2)
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testTwoStateTrees() {
+        //    mState1 -> initial  mState4
+        //    /     \             /     \
+        // mState2 mState3     mState5 mState6
+        addState(mState1)
+        addState(mState2, mState1)
+        addState(mState3, mState1)
+        addState(mState4)
+        addState(mState5, mState4)
+        addState(mState6, mState4)
+        verifyStart()
+
+        //    mState1 -> current     mState4
+        //    /     \                /     \
+        // mState2 mState3 -> dest mState5 mState6
+        processMessage(MSG_1, mState3)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState3).enter()
+        verifyNoMoreInteractions()
+
+        //           mState1                     mState4
+        //           /     \                     /     \
+        // dest <- mState2 mState3 -> current mState5 mState6
+        processMessage(MSG_1, mState2)
+        verifyMessageProcessedBy(MSG_1, mState3, mState1)
+        mInOrder.verify(mState3).exit()
+        mInOrder.verify(mState2).enter()
+        verifyNoMoreInteractions()
+
+        //               mState1          mState4
+        //               /     \          /     \
+        // current <- mState2 mState3 mState5 mState6 -> dest
+        processMessage(MSG_2, mState6)
+        verifyMessageProcessedBy(MSG_2, mState2)
+        mInOrder.verify(mState2).exit()
+        mInOrder.verify(mState1).exit()
+        mInOrder.verify(mState4).enter()
+        mInOrder.verify(mState6).enter()
+        verifyNoMoreInteractions()
+    }
+
+    @Test
+    fun testMultiDepthTransition() {
+        //      mState1 -> current
+        //    |          \
+        //  mState2         mState6
+        //    |   \           |
+        //  mState3 mState5  mState7
+        //    |
+        //  mState4
+        addState(mState1)
+        addState(mState2, mState1)
+        addState(mState6, mState1)
+        addState(mState3, mState2)
+        addState(mState5, mState2)
+        addState(mState7, mState6)
+        addState(mState4, mState3)
+        verifyStart()
+
+        //      mState1 -> current
+        //    |          \
+        //  mState2         mState6
+        //    |   \           |
+        //  mState3 mState5  mState7
+        //    |
+        //  mState4 -> dest
+        processMessage(MSG_1, mState4)
+        verifyMessageProcessedBy(MSG_1, mState1)
+        mInOrder.verify(mState2).enter()
+        mInOrder.verify(mState3).enter()
+        mInOrder.verify(mState4).enter()
+        verifyNoMoreInteractions()
+
+        //            mState1
+        //        /            \
+        //  mState2             mState6
+        //    |   \                 \
+        //  mState3 mState5 -> dest  mState7
+        //    |
+        //  mState4 -> current
+        processMessage(MSG_1, mState5)
+        verifyMessageProcessedBy(MSG_1, mState4, mState3, mState2, mState1)
+        mInOrder.verify(mState4).exit()
+        mInOrder.verify(mState3).exit()
+        mInOrder.verify(mState5).enter()
+        verifyNoMoreInteractions()
+
+        //            mState1
+        //        /              \
+        //  mState2               mState6
+        //    |   \                    \
+        //  mState3 mState5 -> current  mState7 -> dest
+        //    |
+        //  mState4
+        processMessage(MSG_2, mState7)
+        verifyMessageProcessedBy(MSG_2, mState5, mState2)
+        mInOrder.verify(mState5).exit()
+        mInOrder.verify(mState2).exit()
+        mInOrder.verify(mState6).enter()
+        mInOrder.verify(mState7).enter()
+        verifyNoMoreInteractions()
+    }
+}
diff --git a/bpf_progs/netd.h b/bpf_progs/netd.h
index dd27bf9..4958040 100644
--- a/bpf_progs/netd.h
+++ b/bpf_progs/netd.h
@@ -190,7 +190,7 @@
     OEM_DENY_2_MATCH = (1 << 10),
     OEM_DENY_3_MATCH = (1 << 11),
 };
-// LINT.ThenChange(packages/modules/Connectivity/service/src/com/android/server/BpfNetMaps.java)
+// LINT.ThenChange(../framework/src/android/net/BpfNetMapsConstants.java)
 
 enum BpfPermissionMatch {
     BPF_PERMISSION_INTERNET = 1 << 2,
diff --git a/common/Android.bp b/common/Android.bp
index c982431..1d73a46 100644
--- a/common/Android.bp
+++ b/common/Android.bp
@@ -25,6 +25,7 @@
 // as the above target may not exist
 // depending on the branch
 
+// The library requires the final artifact to contain net-utils-device-common-struct.
 java_library {
     name: "connectivity-net-module-utils-bpf",
     srcs: [
@@ -40,8 +41,9 @@
     libs: [
         "androidx.annotation_annotation",
         "framework-connectivity.stubs.module_lib",
-    ],
-    static_libs: [
+        // For libraries which are statically linked in framework-connectivity, do not
+        // statically link here because callers of this library might already have a static
+        // version linked.
         "net-utils-device-common-struct",
     ],
     apex_available: [
diff --git a/common/TrunkStable.bp b/common/TrunkStable.bp
index 772f79e..56938fc 100644
--- a/common/TrunkStable.bp
+++ b/common/TrunkStable.bp
@@ -19,3 +19,8 @@
     package: "com.android.net.flags",
     srcs: ["flags.aconfig"],
 }
+
+java_aconfig_library {
+    name: "connectivity_flags_aconfig_lib",
+    aconfig_declarations: "com.android.net.flags-aconfig",
+}
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 4926503..7235202 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -6,3 +6,24 @@
   description: "NetworkActivityTracker tracks multiple networks including non default networks"
   bug: "267870186"
 }
+
+flag {
+  name: "forbidden_capability"
+  namespace: "android_core_networking"
+  description: "This flag controls the forbidden capability API"
+  bug: "302997505"
+}
+
+flag {
+  name: "nsd_expired_services_removal"
+  namespace: "android_core_networking"
+  description: "Remove expired services from MdnsServiceCache"
+  bug: "304649384"
+}
+
+flag {
+  name: "set_data_saver_via_cm"
+  namespace: "android_core_networking"
+  description: "Set data saver through ConnectivityManager API"
+  bug: "297836825"
+}
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 53ad834..23510e1 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -305,6 +305,7 @@
     ctor public NetworkStats(long, int);
     method @NonNull public android.net.NetworkStats add(@NonNull android.net.NetworkStats);
     method @NonNull public android.net.NetworkStats addEntry(@NonNull android.net.NetworkStats.Entry);
+    method public android.net.NetworkStats clone();
     method public int describeContents();
     method @NonNull public java.util.Iterator<android.net.NetworkStats.Entry> iterator();
     method @NonNull public android.net.NetworkStats subtract(@NonNull android.net.NetworkStats);
@@ -416,12 +417,87 @@
 
 package android.net.thread {
 
-  public class ThreadNetworkController {
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ActiveOperationalDataset implements android.os.Parcelable {
+    method @NonNull public static android.net.thread.ActiveOperationalDataset createRandomDataset();
+    method public int describeContents();
+    method @NonNull public static android.net.thread.ActiveOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getActiveTimestamp();
+    method @IntRange(from=0, to=65535) public int getChannel();
+    method @NonNull @Size(min=1) public android.util.SparseArray<byte[]> getChannelMask();
+    method @IntRange(from=0, to=255) public int getChannelPage();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) public byte[] getExtendedPanId();
+    method @NonNull public android.net.IpPrefix getMeshLocalPrefix();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) public byte[] getNetworkKey();
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) public String getNetworkName();
+    method @IntRange(from=0, to=65534) public int getPanId();
+    method @NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) public byte[] getPskc();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.SecurityPolicy getSecurityPolicy();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field public static final int CHANNEL_MAX_24_GHZ = 26; // 0x1a
+    field public static final int CHANNEL_MIN_24_GHZ = 11; // 0xb
+    field public static final int CHANNEL_PAGE_24_GHZ = 0; // 0x0
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.ActiveOperationalDataset> CREATOR;
+    field public static final int LENGTH_EXTENDED_PAN_ID = 8; // 0x8
+    field public static final int LENGTH_MAX_DATASET_TLVS = 254; // 0xfe
+    field public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16; // 0x10
+    field public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64; // 0x40
+    field public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1; // 0x1
+    field public static final int LENGTH_NETWORK_KEY = 16; // 0x10
+    field public static final int LENGTH_PSKC = 16; // 0x10
+  }
+
+  public static final class ActiveOperationalDataset.Builder {
+    ctor public ActiveOperationalDataset.Builder(@NonNull android.net.thread.ActiveOperationalDataset);
+    ctor public ActiveOperationalDataset.Builder();
+    method @NonNull public android.net.thread.ActiveOperationalDataset build();
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setActiveTimestamp(@NonNull android.net.thread.OperationalDatasetTimestamp);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannel(@IntRange(from=0, to=255) int, @IntRange(from=0, to=65535) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setChannelMask(@NonNull @Size(min=1) android.util.SparseArray<byte[]>);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setExtendedPanId(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_EXTENDED_PAN_ID) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setMeshLocalPrefix(@NonNull android.net.IpPrefix);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkKey(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_NETWORK_KEY) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setNetworkName(@NonNull @Size(min=android.net.thread.ActiveOperationalDataset.LENGTH_MIN_NETWORK_NAME_BYTES, max=android.net.thread.ActiveOperationalDataset.LENGTH_MAX_NETWORK_NAME_BYTES) String);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPanId(@IntRange(from=0, to=65534) int);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setPskc(@NonNull @Size(android.net.thread.ActiveOperationalDataset.LENGTH_PSKC) byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset.Builder setSecurityPolicy(@NonNull android.net.thread.ActiveOperationalDataset.SecurityPolicy);
+  }
+
+  public static final class ActiveOperationalDataset.SecurityPolicy {
+    ctor public ActiveOperationalDataset.SecurityPolicy(@IntRange(from=1, to=65535) int, @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[]);
+    method @NonNull @Size(min=android.net.thread.ActiveOperationalDataset.SecurityPolicy.LENGTH_MIN_SECURITY_POLICY_FLAGS) public byte[] getFlags();
+    method @IntRange(from=1, to=65535) public int getRotationTimeHours();
+    field public static final int DEFAULT_ROTATION_TIME_HOURS = 672; // 0x2a0
+    field public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1; // 0x1
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class OperationalDatasetTimestamp {
+    ctor public OperationalDatasetTimestamp(@IntRange(from=0, to=281474976710655L) long, @IntRange(from=0, to=32767) int, boolean);
+    method @NonNull public static android.net.thread.OperationalDatasetTimestamp fromInstant(@NonNull java.time.Instant);
+    method @IntRange(from=0, to=281474976710655L) public long getSeconds();
+    method @IntRange(from=0, to=32767) public int getTicks();
+    method public boolean isAuthoritativeSource();
+    method @NonNull public java.time.Instant toInstant();
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class PendingOperationalDataset implements android.os.Parcelable {
+    ctor public PendingOperationalDataset(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull android.net.thread.OperationalDatasetTimestamp, @NonNull java.time.Duration);
+    method public int describeContents();
+    method @NonNull public static android.net.thread.PendingOperationalDataset fromThreadTlvs(@NonNull byte[]);
+    method @NonNull public android.net.thread.ActiveOperationalDataset getActiveOperationalDataset();
+    method @NonNull public java.time.Duration getDelayTimer();
+    method @NonNull public android.net.thread.OperationalDatasetTimestamp getPendingTimestamp();
+    method @NonNull public byte[] toThreadTlvs();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
+    field @NonNull public static final android.os.Parcelable.Creator<android.net.thread.PendingOperationalDataset> CREATOR;
+  }
+
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController {
     method public int getThreadVersion();
     field public static final int THREAD_VERSION_1_3 = 4; // 0x4
   }
 
-  public class ThreadNetworkManager {
+  @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkManager {
     method @NonNull public java.util.List<android.net.thread.ThreadNetworkController> getAllThreadNetworkControllers();
   }
 
diff --git a/framework-t/src/android/net/EthernetNetworkSpecifier.java b/framework-t/src/android/net/EthernetNetworkSpecifier.java
index e4d6e24..90c0361 100644
--- a/framework-t/src/android/net/EthernetNetworkSpecifier.java
+++ b/framework-t/src/android/net/EthernetNetworkSpecifier.java
@@ -26,8 +26,6 @@
 
 /**
  * A {@link NetworkSpecifier} used to identify ethernet interfaces.
- *
- * @see EthernetManager
  */
 public final class EthernetNetworkSpecifier extends NetworkSpecifier implements Parcelable {
 
diff --git a/framework/Android.bp b/framework/Android.bp
index 182c558..103083f 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -101,7 +101,7 @@
         "framework-connectivity-javastream-protos",
     ],
     impl_only_static_libs: [
-        "net-utils-device-common-struct",
+        "net-utils-device-common-bpf",
     ],
     libs: [
         "androidx.annotation_annotation",
@@ -130,7 +130,7 @@
         // to generate the SDK stubs.
         // Even if the library is included in "impl_only_static_libs" of defaults. This is still
         // needed because java_library which doesn't understand "impl_only_static_libs".
-        "net-utils-device-common-struct",
+        "net-utils-device-common-bpf",
     ],
     libs: [
         // This cannot be in the defaults clause above because if it were, it would be used
@@ -292,17 +292,20 @@
 // Library providing limited APIs within the connectivity module, so that R+ components like
 // Tethering have a controlled way to depend on newer components like framework-connectivity that
 // are not loaded on R.
+// Note that this target needs to have access to hidden classes, and as such needs to list
+// the full libraries instead of the .impl lib (which only expose API classes).
 java_library {
     name: "connectivity-internal-api-util",
     sdk_version: "module_current",
     libs: [
         "androidx.annotation_annotation",
-        "framework-connectivity.impl",
+        "framework-connectivity-pre-jarjar",
     ],
     jarjar_rules: ":framework-connectivity-jarjar-rules",
     srcs: [
-        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.TIRAMISU),
-        // so that API checks are enforced for R+ users of this library
+        // Files listed here MUST all be annotated with @RequiresApi(Build.VERSION_CODES.S)
+        // or above as appropriate so that API checks are enforced for R+ users of this library
+        "src/android/net/RoutingCoordinatorManager.java",
         "src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java",
     ],
     visibility: [
diff --git a/framework/aidl-export/android/net/LocalNetworkConfig.aidl b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
new file mode 100644
index 0000000..e2829a5
--- /dev/null
+++ b/framework/aidl-export/android/net/LocalNetworkConfig.aidl
@@ -0,0 +1,20 @@
+/**
+ *
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+@JavaOnlyStableParcelable parcelable LocalNetworkConfig;
diff --git a/framework/api/module-lib-current.txt b/framework/api/module-lib-current.txt
index 193bd92..782e20a 100644
--- a/framework/api/module-lib-current.txt
+++ b/framework/api/module-lib-current.txt
@@ -24,6 +24,7 @@
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptPartialConnectivity(@NonNull android.net.Network, boolean, boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAcceptUnvalidated(@NonNull android.net.Network, boolean, boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_SETUP_WIZARD, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setAvoidUnvalidated(@NonNull android.net.Network);
+    method @FlaggedApi("com.android.net.flags.set_data_saver_via_cm") @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setDataSaverEnabled(boolean);
     method @RequiresPermission(anyOf={android.Manifest.permission.NETWORK_SETTINGS, android.Manifest.permission.NETWORK_STACK, android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK}) public void setFirewallChainEnabled(int, boolean);
     method @RequiresPermission(android.Manifest.permission.NETWORK_STACK) public void setGlobalProxy(@Nullable android.net.ProxyInfo);
     method @RequiresPermission(anyOf={android.net.NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, android.Manifest.permission.NETWORK_STACK, android.Manifest.permission.NETWORK_SETTINGS}) public void setLegacyLockdownVpnEnabled(boolean);
diff --git a/framework/api/system-current.txt b/framework/api/system-current.txt
index 4a2ed8a..e812024 100644
--- a/framework/api/system-current.txt
+++ b/framework/api/system-current.txt
@@ -94,6 +94,7 @@
   }
 
   public final class DscpPolicy implements android.os.Parcelable {
+    method public int describeContents();
     method @Nullable public java.net.InetAddress getDestinationAddress();
     method @Nullable public android.util.Range<java.lang.Integer> getDestinationPortRange();
     method public int getDscpValue();
@@ -101,6 +102,7 @@
     method public int getProtocol();
     method @Nullable public java.net.InetAddress getSourceAddress();
     method public int getSourcePort();
+    method public void writeToParcel(@NonNull android.os.Parcel, int);
     field @NonNull public static final android.os.Parcelable.Creator<android.net.DscpPolicy> CREATOR;
     field public static final int PROTOCOL_ANY = -1; // 0xffffffff
     field public static final int SOURCE_PORT_ANY = -1; // 0xffffffff
diff --git a/framework/jarjar-excludes.txt b/framework/jarjar-excludes.txt
index 1ac5e8e..bc3c8d1 100644
--- a/framework/jarjar-excludes.txt
+++ b/framework/jarjar-excludes.txt
@@ -14,6 +14,15 @@
 # TODO: move files to android.net.connectivity.visiblefortesting
 android\.net\.IConnectivityDiagnosticsCallback(\$.+)?
 
+# Classes used by tethering as a hidden API are compiled as a lib in target
+# connectivity-internal-api-util. Because it's used by tethering, it can't
+# be jarjared. Classes in android.net.connectivity are exempt from being
+# listed here because they are already in the target package and as such
+# are already not jarjared.
+# Because Tethering can be installed on R without Connectivity, any use
+# of these classes must be protected by a check for >= S SDK.
+# It's unlikely anybody else declares a hidden class with this name ?
+android\.net\.RoutingCoordinatorManager(\$.+)?
 
 # KeepaliveUtils is used by ConnectivityManager CTS
 # TODO: move into service-connectivity so framework-connectivity stops using
diff --git a/framework/src/android/net/BpfNetMapsConstants.java b/framework/src/android/net/BpfNetMapsConstants.java
index 2191682..e0527f5 100644
--- a/framework/src/android/net/BpfNetMapsConstants.java
+++ b/framework/src/android/net/BpfNetMapsConstants.java
@@ -60,7 +60,7 @@
     public static final long OEM_DENY_1_MATCH = (1 << 9);
     public static final long OEM_DENY_2_MATCH = (1 << 10);
     public static final long OEM_DENY_3_MATCH = (1 << 11);
-    // LINT.ThenChange(packages/modules/Connectivity/bpf_progs/netd.h)
+    // LINT.ThenChange(../../../../bpf_progs/netd.h)
 
     public static final List<Pair<Long, String>> MATCH_LIST = Arrays.asList(
             Pair.create(HAPPY_BOX_MATCH, "HAPPY_BOX_MATCH"),
diff --git a/framework/src/android/net/BpfNetMapsReader.java b/framework/src/android/net/BpfNetMapsReader.java
new file mode 100644
index 0000000..49e874a
--- /dev/null
+++ b/framework/src/android/net/BpfNetMapsReader.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import static android.net.BpfNetMapsConstants.CONFIGURATION_MAP_PATH;
+import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
+import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY;
+import static android.net.BpfNetMapsUtils.getMatchByFirewallChain;
+import static android.net.BpfNetMapsUtils.isFirewallAllowList;
+import static android.net.BpfNetMapsUtils.throwIfPreT;
+import static android.net.ConnectivityManager.FIREWALL_RULE_ALLOW;
+import static android.net.ConnectivityManager.FIREWALL_RULE_DENY;
+
+import android.annotation.NonNull;
+import android.annotation.RequiresApi;
+import android.os.Build;
+import android.os.ServiceSpecificException;
+import android.system.ErrnoException;
+import android.system.Os;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
+import com.android.net.module.util.BpfMap;
+import com.android.net.module.util.IBpfMap;
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.S32;
+import com.android.net.module.util.Struct.U32;
+
+/**
+ * A helper class to *read* java BpfMaps.
+ * @hide
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)  // BPF maps were only mainlined in T
+public class BpfNetMapsReader {
+    // Locally store the handle of bpf maps. The FileDescriptors are statically cached inside the
+    // BpfMap implementation.
+
+    // Bpf map to store various networking configurations, the format of the value is different
+    // for different keys. See BpfNetMapsConstants#*_CONFIGURATION_KEY for keys.
+    private final IBpfMap<S32, U32> mConfigurationMap;
+    // Bpf map to store per uid traffic control configurations.
+    // See {@link UidOwnerValue} for more detail.
+    private final IBpfMap<S32, UidOwnerValue> mUidOwnerMap;
+    private final Dependencies mDeps;
+
+    public BpfNetMapsReader() {
+        this(new Dependencies());
+    }
+
+    @VisibleForTesting
+    public BpfNetMapsReader(@NonNull Dependencies deps) {
+        if (!SdkLevel.isAtLeastT()) {
+            throw new UnsupportedOperationException(
+                    BpfNetMapsReader.class.getSimpleName() + " is not supported below Android T");
+        }
+        mDeps = deps;
+        mConfigurationMap = mDeps.getConfigurationMap();
+        mUidOwnerMap = mDeps.getUidOwnerMap();
+    }
+
+    /**
+     * Dependencies of BpfNetMapReader, for injection in tests.
+     */
+    @VisibleForTesting
+    public static class Dependencies {
+        /** Get the configuration map. */
+        public IBpfMap<S32, U32> getConfigurationMap() {
+            try {
+                return new BpfMap<>(CONFIGURATION_MAP_PATH, BpfMap.BPF_F_RDONLY,
+                        S32.class, U32.class);
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Cannot open configuration map", e);
+            }
+        }
+
+        /** Get the uid owner map. */
+        public IBpfMap<S32, UidOwnerValue> getUidOwnerMap() {
+            try {
+                return new BpfMap<>(UID_OWNER_MAP_PATH, BpfMap.BPF_F_RDONLY,
+                        S32.class, UidOwnerValue.class);
+            } catch (ErrnoException e) {
+                throw new IllegalStateException("Cannot open uid owner map", e);
+            }
+        }
+    }
+
+    /**
+     * Get the specified firewall chain's status.
+     *
+     * @param chain target chain
+     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public boolean isChainEnabled(final int chain) {
+        return isChainEnabled(mConfigurationMap, chain);
+    }
+
+    /**
+     * Get firewall rule of specified firewall chain on specified uid.
+     *
+     * @param chain target chain
+     * @param uid        target uid
+     * @return either {@link ConnectivityManager#FIREWALL_RULE_ALLOW} or
+     *         {@link ConnectivityManager#FIREWALL_RULE_DENY}.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public int getUidRule(final int chain, final int uid) {
+        return getUidRule(mUidOwnerMap, chain, uid);
+    }
+
+    /**
+     * Get the specified firewall chain's status.
+     *
+     * @param configurationMap target configurationMap
+     * @param chain target chain
+     * @return {@code true} if chain is enabled, {@code false} if chain is not enabled.
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *                                  cause of the failure.
+     */
+    public static boolean isChainEnabled(
+            final IBpfMap<Struct.S32, Struct.U32> configurationMap, final int chain) {
+        throwIfPreT("isChainEnabled is not available on pre-T devices");
+
+        final long match = getMatchByFirewallChain(chain);
+        try {
+            final Struct.U32 config = configurationMap.getValue(UID_RULES_CONFIGURATION_KEY);
+            return (config.val & match) != 0;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno,
+                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
+        }
+    }
+
+    /**
+     * Get firewall rule of specified firewall chain on specified uid.
+     *
+     * @param uidOwnerMap target uidOwnerMap.
+     * @param chain target chain.
+     * @param uid target uid.
+     * @return either FIREWALL_RULE_ALLOW or FIREWALL_RULE_DENY
+     * @throws UnsupportedOperationException if called on pre-T devices.
+     * @throws ServiceSpecificException      in case of failure, with an error code indicating the
+     *                                       cause of the failure.
+     */
+    public static int getUidRule(final IBpfMap<Struct.S32, UidOwnerValue> uidOwnerMap,
+            final int chain, final int uid) {
+        throwIfPreT("getUidRule is not available on pre-T devices");
+
+        final long match = getMatchByFirewallChain(chain);
+        final boolean isAllowList = isFirewallAllowList(chain);
+        try {
+            final UidOwnerValue uidMatch = uidOwnerMap.getValue(new Struct.S32(uid));
+            final boolean isMatchEnabled = uidMatch != null && (uidMatch.rule & match) != 0;
+            return isMatchEnabled == isAllowList ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
+        } catch (ErrnoException e) {
+            throw new ServiceSpecificException(e.errno,
+                    "Unable to get uid rule status: " + Os.strerror(e.errno));
+        }
+    }
+}
diff --git a/framework/src/android/net/BpfNetMapsUtils.java b/framework/src/android/net/BpfNetMapsUtils.java
index d464e3d..28d5891 100644
--- a/framework/src/android/net/BpfNetMapsUtils.java
+++ b/framework/src/android/net/BpfNetMapsUtils.java
@@ -39,6 +39,8 @@
 import android.os.ServiceSpecificException;
 import android.util.Pair;
 
+import com.android.modules.utils.build.SdkLevel;
+
 import java.util.StringJoiner;
 
 /**
@@ -124,4 +126,15 @@
         }
         return sj.toString();
     }
+
+    public static final boolean PRE_T = !SdkLevel.isAtLeastT();
+
+    /**
+     * Throw UnsupportedOperationException if SdkLevel is before T.
+     */
+    public static void throwIfPreT(final String msg) {
+        if (PRE_T) {
+            throw new UnsupportedOperationException(msg);
+        }
+    }
 }
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 2315521..32058a4 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -26,9 +26,11 @@
 import static android.net.QosCallback.QosCallbackRegistrationException;
 
 import android.annotation.CallbackExecutor;
+import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresApi;
 import android.annotation.RequiresPermission;
 import android.annotation.SdkConstant;
 import android.annotation.SdkConstant.SdkConstantType;
@@ -115,6 +117,14 @@
     private static final String TAG = "ConnectivityManager";
     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
+    // TODO : remove this class when udc-mainline-prod is abandoned and android.net.flags.Flags is
+    // available here
+    /** @hide */
+    public static class Flags {
+        static final String SET_DATA_SAVER_VIA_CM =
+                "com.android.net.flags.set_data_saver_via_cm";
+    }
+
     /**
      * A change in network connectivity has occurred. A default connection has either
      * been established or lost. The NetworkInfo for the affected network is
@@ -3811,11 +3821,28 @@
     @RequiresPermission(anyOf = {
             NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
             android.Manifest.permission.NETWORK_FACTORY})
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo ni, LinkProperties lp,
-            NetworkCapabilities nc, @NonNull NetworkScore score, NetworkAgentConfig config,
-            int providerId) {
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId) {
+        return registerNetworkAgent(na, ni, lp, nc, null /* localNetworkConfig */, score, config,
+                providerId);
+    }
+
+    /**
+     * @hide
+     * Register a NetworkAgent with ConnectivityService.
+     * @return Network corresponding to NetworkAgent.
+     */
+    @RequiresPermission(anyOf = {
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+            android.Manifest.permission.NETWORK_FACTORY})
+    public Network registerNetworkAgent(@NonNull INetworkAgent na, @NonNull NetworkInfo ni,
+            @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId) {
         try {
-            return mService.registerNetworkAgent(na, ni, lp, nc, score, config, providerId);
+            return mService.registerNetworkAgent(na, ni, lp, nc, score, localNetworkConfig, config,
+                    providerId);
         } catch (RemoteException e) {
             throw e.rethrowFromSystemServer();
         }
@@ -5941,6 +5968,28 @@
     }
 
     /**
+     * Sets data saver switch.
+     *
+     * @param enable True if enable.
+     * @throws IllegalStateException if failed.
+     * @hide
+     */
+    @FlaggedApi(Flags.SET_DATA_SAVER_VIA_CM)
+    @SystemApi(client = MODULE_LIBRARIES)
+    @RequiresPermission(anyOf = {
+            android.Manifest.permission.NETWORK_SETTINGS,
+            android.Manifest.permission.NETWORK_STACK,
+            NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK
+    })
+    public void setDataSaverEnabled(final boolean enable) {
+        try {
+            mService.setDataSaverEnabled(enable);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
      * Adds the specified UID to the list of UIds that are allowed to use data on metered networks
      * even when background data is restricted. The deny list takes precedence over the allow list.
      *
@@ -6157,4 +6206,24 @@
             throw e.rethrowFromSystemServer();
         }
     }
+
+    private static final Object sRoutingCoordinatorManagerLock = new Object();
+    @GuardedBy("sRoutingCoordinatorManagerLock")
+    private static RoutingCoordinatorManager sRoutingCoordinatorManager = null;
+    /** @hide */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public RoutingCoordinatorManager getRoutingCoordinatorManager() {
+        try {
+            synchronized (sRoutingCoordinatorManagerLock) {
+                if (null == sRoutingCoordinatorManager) {
+                    sRoutingCoordinatorManager = new RoutingCoordinatorManager(mContext,
+                            IRoutingCoordinator.Stub.asInterface(
+                                    mService.getRoutingCoordinatorService()));
+                }
+                return sRoutingCoordinatorManager;
+            }
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
 }
diff --git a/framework/src/android/net/IConnectivityManager.aidl b/framework/src/android/net/IConnectivityManager.aidl
index ebe8bca..d3a02b9 100644
--- a/framework/src/android/net/IConnectivityManager.aidl
+++ b/framework/src/android/net/IConnectivityManager.aidl
@@ -27,6 +27,7 @@
 import android.net.IQosCallback;
 import android.net.ISocketKeepaliveCallback;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkAgentConfig;
 import android.net.NetworkCapabilities;
@@ -146,7 +147,8 @@
     void declareNetworkRequestUnfulfillable(in NetworkRequest request);
 
     Network registerNetworkAgent(in INetworkAgent na, in NetworkInfo ni, in LinkProperties lp,
-            in NetworkCapabilities nc, in NetworkScore score, in NetworkAgentConfig config,
+            in NetworkCapabilities nc, in NetworkScore score,
+            in LocalNetworkConfig localNetworkConfig, in NetworkAgentConfig config,
             in int factorySerialNumber);
 
     NetworkRequest requestNetwork(int uid, in NetworkCapabilities networkCapabilities, int reqType,
@@ -238,6 +240,8 @@
 
     void setTestAllowBadWifiUntil(long timeMs);
 
+    void setDataSaverEnabled(boolean enable);
+
     void updateMeteredNetworkAllowList(int uid, boolean add);
 
     void updateMeteredNetworkDenyList(int uid, boolean add);
@@ -257,4 +261,6 @@
     void setVpnNetworkPreference(String session, in UidRange[] ranges);
 
     void setTestLowTcpPollingTimerForKeepalive(long timeMs);
+
+    IBinder getRoutingCoordinatorService();
 }
diff --git a/framework/src/android/net/INetworkAgentRegistry.aidl b/framework/src/android/net/INetworkAgentRegistry.aidl
index b375b7b..61b27b5 100644
--- a/framework/src/android/net/INetworkAgentRegistry.aidl
+++ b/framework/src/android/net/INetworkAgentRegistry.aidl
@@ -17,6 +17,7 @@
 
 import android.net.DscpPolicy;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.Network;
 import android.net.NetworkCapabilities;
 import android.net.NetworkInfo;
@@ -34,6 +35,7 @@
     void sendLinkProperties(in LinkProperties lp);
     // TODO: consider replacing this by "markConnected()" and removing
     void sendNetworkInfo(in NetworkInfo info);
+    void sendLocalNetworkConfig(in LocalNetworkConfig config);
     void sendScore(in NetworkScore score);
     void sendExplicitlySelected(boolean explicitlySelected, boolean acceptPartial);
     void sendSocketKeepaliveEvent(int slot, int reason);
diff --git a/framework/src/android/net/IRoutingCoordinator.aidl b/framework/src/android/net/IRoutingCoordinator.aidl
new file mode 100644
index 0000000..cf02ec4
--- /dev/null
+++ b/framework/src/android/net/IRoutingCoordinator.aidl
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.net.RouteInfo;
+
+/** @hide */
+interface IRoutingCoordinator {
+   /**
+    * Add a route for specific network
+    *
+    * @param netId the network to add the route to
+    * @param route the route to add
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void addRoute(int netId, in RouteInfo route);
+
+   /**
+    * Remove a route for specific network
+    *
+    * @param netId the network to remove the route from
+    * @param route the route to remove
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void removeRoute(int netId, in RouteInfo route);
+
+    /**
+    * Update a route for specific network
+    *
+    * @param netId the network to update the route for
+    * @param route parcelable with route information
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void updateRoute(int netId, in RouteInfo route);
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    void addInterfaceToNetwork(int netId, in String iface);
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+     void removeInterfaceFromNetwork(int netId, in String iface);
+
+   /**
+    * Add forwarding ip rule
+    *
+    * @param fromIface interface name to add forwarding ip rule
+    * @param toIface interface name to add forwarding ip rule
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void addInterfaceForward(in String fromIface, in String toIface);
+
+   /**
+    * Remove forwarding ip rule
+    *
+    * @param fromIface interface name to remove forwarding ip rule
+    * @param toIface interface name to remove forwarding ip rule
+    * @throws ServiceSpecificException in case of failure, with an error code indicating the
+    *         cause of the failure.
+    */
+    void removeInterfaceForward(in String fromIface, in String toIface);
+}
diff --git a/framework/src/android/net/LocalNetworkConfig.java b/framework/src/android/net/LocalNetworkConfig.java
new file mode 100644
index 0000000..fca7fd1
--- /dev/null
+++ b/framework/src/android/net/LocalNetworkConfig.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * A class to communicate configuration info about a local network through {@link NetworkAgent}.
+ * @hide
+ */
+// TODO : @SystemApi
+public final class LocalNetworkConfig implements Parcelable {
+    @Nullable
+    private final NetworkRequest mUpstreamSelector;
+
+    @NonNull
+    private final MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+    @NonNull
+    private final MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+    private LocalNetworkConfig(@Nullable final NetworkRequest upstreamSelector,
+            @Nullable final MulticastRoutingConfig upstreamConfig,
+            @Nullable final MulticastRoutingConfig downstreamConfig) {
+        mUpstreamSelector = upstreamSelector;
+        if (null != upstreamConfig) {
+            mUpstreamMulticastRoutingConfig = upstreamConfig;
+        } else {
+            mUpstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+        if (null != downstreamConfig) {
+            mDownstreamMulticastRoutingConfig = downstreamConfig;
+        } else {
+            mDownstreamMulticastRoutingConfig = MulticastRoutingConfig.CONFIG_FORWARD_NONE;
+        }
+    }
+
+    /**
+     * Get the request choosing which network traffic from this network is forwarded to and from.
+     *
+     * This may be null if the local network doesn't forward the traffic anywhere.
+     */
+    @Nullable
+    public NetworkRequest getUpstreamSelector() {
+        return mUpstreamSelector;
+    }
+
+    public @NonNull MulticastRoutingConfig getUpstreamMulticastRoutingConfig() {
+        return mUpstreamMulticastRoutingConfig;
+    }
+
+    public @NonNull MulticastRoutingConfig getDownstreamMulticastRoutingConfig() {
+        return mDownstreamMulticastRoutingConfig;
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull final Parcel dest, final int flags) {
+        dest.writeParcelable(mUpstreamSelector, flags);
+        dest.writeParcelable(mUpstreamMulticastRoutingConfig, flags);
+        dest.writeParcelable(mDownstreamMulticastRoutingConfig, flags);
+    }
+
+    public static final @NonNull Creator<LocalNetworkConfig> CREATOR = new Creator<>() {
+        public LocalNetworkConfig createFromParcel(Parcel in) {
+            final NetworkRequest upstreamSelector = in.readParcelable(null);
+            final MulticastRoutingConfig upstreamConfig = in.readParcelable(null);
+            final MulticastRoutingConfig downstreamConfig = in.readParcelable(null);
+            return new LocalNetworkConfig(
+                    upstreamSelector, upstreamConfig, downstreamConfig);
+        }
+
+        @Override
+        public LocalNetworkConfig[] newArray(final int size) {
+            return new LocalNetworkConfig[size];
+        }
+    };
+
+
+    public static final class Builder {
+        @Nullable
+        NetworkRequest mUpstreamSelector;
+
+        @Nullable
+        MulticastRoutingConfig mUpstreamMulticastRoutingConfig;
+
+        @Nullable
+        MulticastRoutingConfig mDownstreamMulticastRoutingConfig;
+
+        /**
+         * Create a Builder
+         */
+        public Builder() {
+        }
+
+        /**
+         * Set to choose where this local network should forward its traffic to.
+         *
+         * The system will automatically choose the best network matching the request as an
+         * upstream, and set up forwarding between this local network and the chosen upstream.
+         * If no network matches the request, there is no upstream and the traffic is not forwarded.
+         * The caller can know when this changes by listening to link properties changes of
+         * this network with the {@link android.net.LinkProperties#getForwardedNetwork()} getter.
+         *
+         * Set this to null if the local network shouldn't be forwarded. Default is null.
+         */
+        @NonNull
+        public Builder setUpstreamSelector(@Nullable NetworkRequest upstreamSelector) {
+            mUpstreamSelector = upstreamSelector;
+            return this;
+        }
+
+        /**
+         * Set the upstream multicast routing config.
+         *
+         * If null, don't route multicast packets upstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setUpstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mUpstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Set the downstream multicast routing config.
+         *
+         * If null, don't route multicast packets downstream. This is equivalent to a
+         * MulticastRoutingConfig in mode FORWARD_NONE. The default is null.
+         */
+        @NonNull
+        public Builder setDownstreamMulticastRoutingConfig(@Nullable MulticastRoutingConfig cfg) {
+            mDownstreamMulticastRoutingConfig = cfg;
+            return this;
+        }
+
+        /**
+         * Build the LocalNetworkConfig object.
+         */
+        @NonNull
+        public LocalNetworkConfig build() {
+            return new LocalNetworkConfig(mUpstreamSelector,
+                    mUpstreamMulticastRoutingConfig,
+                    mDownstreamMulticastRoutingConfig);
+        }
+    }
+}
diff --git a/framework/src/android/net/MulticastRoutingConfig.java b/framework/src/android/net/MulticastRoutingConfig.java
new file mode 100644
index 0000000..ebd9fc5
--- /dev/null
+++ b/framework/src/android/net/MulticastRoutingConfig.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.ArraySet;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * A class representing a configuration for multicast routing.
+ *
+ * Internal usage to Connectivity
+ * @hide
+ */
+// TODO : @SystemApi
+public class MulticastRoutingConfig implements Parcelable {
+    private static final String TAG = MulticastRoutingConfig.class.getSimpleName();
+
+    /** Do not forward any multicast packets. */
+    public static final int FORWARD_NONE = 0;
+    /**
+     * Forward only multicast packets with destination in the list of listening addresses.
+     * Ignore the min scope.
+     */
+    public static final int FORWARD_SELECTED = 1;
+    /**
+     * Forward all multicast packets with scope greater or equal than the min scope.
+     * Ignore the list of listening addresses.
+     */
+    public static final int FORWARD_WITH_MIN_SCOPE = 2;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "FORWARD_" }, value = {
+            FORWARD_NONE,
+            FORWARD_SELECTED,
+            FORWARD_WITH_MIN_SCOPE
+    })
+    public @interface MulticastForwardingMode {}
+
+    /**
+     * Not a multicast scope, for configurations that do not use the min scope.
+     */
+    public static final int MULTICAST_SCOPE_NONE = -1;
+
+    public static final MulticastRoutingConfig CONFIG_FORWARD_NONE =
+            new MulticastRoutingConfig(FORWARD_NONE, MULTICAST_SCOPE_NONE, null);
+
+    @MulticastForwardingMode
+    private final int mForwardingMode;
+
+    private final int mMinScope;
+
+    @NonNull
+    private final Set<Inet6Address> mListeningAddresses;
+
+    private MulticastRoutingConfig(@MulticastForwardingMode final int mode, final int scope,
+            @Nullable final Set<Inet6Address> addresses) {
+        mForwardingMode = mode;
+        mMinScope = scope;
+        if (null != addresses) {
+            mListeningAddresses = Collections.unmodifiableSet(new ArraySet<>(addresses));
+        } else {
+            mListeningAddresses = Collections.emptySet();
+        }
+    }
+
+    /**
+     * Returns the forwarding mode.
+     */
+    @MulticastForwardingMode
+    public int getForwardingMode() {
+        return mForwardingMode;
+    }
+
+    /**
+     * Returns the minimal group address scope that is allowed for forwarding.
+     * If the forwarding mode is not FORWARD_WITH_MIN_SCOPE, will be MULTICAST_SCOPE_NONE.
+     */
+    public int getMinScope() {
+        return mMinScope;
+    }
+
+    /**
+     * Returns the list of group addresses listened by the outgoing interface.
+     * The list will be empty if the forwarding mode is not FORWARD_SELECTED.
+     */
+    @NonNull
+    public Set<Inet6Address> getMulticastListeningAddresses() {
+        return mListeningAddresses;
+    }
+
+    private MulticastRoutingConfig(Parcel in) {
+        mForwardingMode = in.readInt();
+        mMinScope = in.readInt();
+        final int count = in.readInt();
+        final ArraySet<Inet6Address> listeningAddresses = new ArraySet<>(count);
+        final byte[] buffer = new byte[16]; // Size of an Inet6Address
+        for (int i = 0; i < count; ++i) {
+            in.readByteArray(buffer);
+            try {
+                listeningAddresses.add((Inet6Address) Inet6Address.getByAddress(buffer));
+            } catch (UnknownHostException e) {
+                Log.wtf(TAG, "Can't read inet6address : " + Arrays.toString(buffer));
+            }
+        }
+        mListeningAddresses = Collections.unmodifiableSet(listeningAddresses);
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeInt(mForwardingMode);
+        dest.writeInt(mMinScope);
+        dest.writeInt(mListeningAddresses.size());
+        for (final Inet6Address addr : mListeningAddresses) {
+            dest.writeByteArray(addr.getAddress());
+        }
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    public static final Creator<MulticastRoutingConfig> CREATOR = new Creator<>() {
+        @Override
+        public MulticastRoutingConfig createFromParcel(Parcel in) {
+            return new MulticastRoutingConfig(in);
+        }
+
+        @Override
+        public MulticastRoutingConfig[] newArray(int size) {
+            return new MulticastRoutingConfig[size];
+        }
+    };
+
+    public static class Builder {
+        @MulticastForwardingMode
+        private final int mForwardingMode;
+        private int mMinScope;
+        private final ArraySet<Inet6Address> mListeningAddresses;
+
+        private Builder(@MulticastForwardingMode final int mode, int scope) {
+            mForwardingMode = mode;
+            mMinScope = scope;
+            mListeningAddresses = new ArraySet<>();
+        }
+
+        /**
+         * Create a builder that forwards nothing.
+         * No properties can be set on such a builder.
+         */
+        public static Builder newBuilderForwardingNone() {
+            return new Builder(FORWARD_NONE, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Create a builder that forwards packets above a certain scope
+         *
+         * The scope can be changed on this builder, but not the listening addresses.
+         * @param scope the initial scope
+         */
+        public static Builder newBuilderWithMinScope(final int scope) {
+            return new Builder(FORWARD_WITH_MIN_SCOPE, scope);
+        }
+
+        /**
+         * Create a builder that forwards a specified list of listening addresses.
+         *
+         * Addresses can be added and removed from this builder, but the scope can't be set.
+         */
+        public static Builder newBuilderWithListeningAddresses() {
+            return new Builder(FORWARD_SELECTED, MULTICAST_SCOPE_NONE);
+        }
+
+        /**
+         * Sets the minimum scope for this multicast routing config.
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_WITH_MIN_SCOPE mode.
+         * @return this builder
+         */
+        public Builder setMinimumScope(final int scope) {
+            if (FORWARD_WITH_MIN_SCOPE != mForwardingMode) {
+                throw new IllegalArgumentException("Can't set the scope on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mMinScope = scope;
+            return this;
+        }
+
+        /**
+         * Add an address to the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was already added, this is a no-op.
+         * @return this builder
+         */
+        public Builder addListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't add an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            // TODO : should we check that this is a multicast address ?
+            mListeningAddresses.add(address);
+            return this;
+        }
+
+        /**
+         * Remove an address from the set of listening addresses.
+         *
+         * This is only meaningful (indeed, allowed) for configs in FORWARD_SELECTED mode.
+         * If this address was not added, or was already removed, this is a no-op.
+         * @return this builder
+         */
+        public Builder removeListeningAddress(@NonNull final Inet6Address address) {
+            if (FORWARD_SELECTED != mForwardingMode) {
+                throw new IllegalArgumentException("Can't remove an address on a builder in mode "
+                        + modeToString(mForwardingMode));
+            }
+            mListeningAddresses.remove(address);
+            return this;
+        }
+
+        /**
+         * Build the config.
+         */
+        public MulticastRoutingConfig build() {
+            return new MulticastRoutingConfig(mForwardingMode, mMinScope, mListeningAddresses);
+        }
+    }
+
+    private static String modeToString(@MulticastForwardingMode final int mode) {
+        switch (mode) {
+            case FORWARD_NONE: return "FORWARD_NONE";
+            case FORWARD_SELECTED: return "FORWARD_SELECTED";
+            case FORWARD_WITH_MIN_SCOPE: return "FORWARD_WITH_MIN_SCOPE";
+            default: return "unknown multicast routing mode " + mode;
+        }
+    }
+}
diff --git a/framework/src/android/net/NetworkAgent.java b/framework/src/android/net/NetworkAgent.java
index 177f7e3..4e9087c 100644
--- a/framework/src/android/net/NetworkAgent.java
+++ b/framework/src/android/net/NetworkAgent.java
@@ -151,7 +151,7 @@
 
     /**
      * Sent by the NetworkAgent to ConnectivityService to pass the current
-     * NetworkCapabilties.
+     * NetworkCapabilities.
      * obj = NetworkCapabilities
      * @hide
      */
@@ -443,6 +443,14 @@
     public static final int EVENT_UNREGISTER_AFTER_REPLACEMENT = BASE + 29;
 
     /**
+     * Sent by the NetworkAgent to ConnectivityService to pass the new value of the local
+     * network agent config.
+     * obj = {@code Pair<NetworkAgentInfo, LocalNetworkConfig>}
+     * @hide
+     */
+    public static final int EVENT_LOCAL_NETWORK_CONFIG_CHANGED = BASE + 30;
+
+    /**
      * DSCP policy was successfully added.
      */
     public static final int DSCP_POLICY_STATUS_SUCCESS = 0;
@@ -517,20 +525,47 @@
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
             @NonNull NetworkScore score, @NonNull NetworkAgentConfig config,
             @Nullable NetworkProvider provider) {
-        this(looper, context, logTag, nc, lp, score, config,
+        this(context, looper, logTag, nc, lp, null /* localNetworkConfig */, score, config,
+                provider);
+    }
+
+    /**
+     * Create a new network agent.
+     * @param context a {@link Context} to get system services from.
+     * @param looper the {@link Looper} on which to invoke the callbacks.
+     * @param logTag the tag for logs
+     * @param nc the initial {@link NetworkCapabilities} of this network. Update with
+     *           sendNetworkCapabilities.
+     * @param lp the initial {@link LinkProperties} of this network. Update with sendLinkProperties.
+     * @param localNetworkConfig the initial {@link LocalNetworkConfig} of this
+     *                                  network. Update with sendLocalNetworkConfig. Must be
+     *                                  non-null iff the nc have NET_CAPABILITY_LOCAL_NETWORK.
+     * @param score the initial score of this network. Update with sendNetworkScore.
+     * @param config an immutable {@link NetworkAgentConfig} for this agent.
+     * @param provider the {@link NetworkProvider} managing this agent.
+     * @hide
+     */
+    // TODO : expose
+    public NetworkAgent(@NonNull Context context, @NonNull Looper looper, @NonNull String logTag,
+            @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, @Nullable NetworkProvider provider) {
+        this(looper, context, logTag, nc, lp, localNetworkConfig, score, config,
                 provider == null ? NetworkProvider.ID_NONE : provider.getProviderId(),
                 getLegacyNetworkInfo(config));
     }
 
     private static class InitialConfiguration {
-        public final Context context;
-        public final NetworkCapabilities capabilities;
-        public final LinkProperties properties;
-        public final NetworkScore score;
-        public final NetworkAgentConfig config;
-        public final NetworkInfo info;
+        @NonNull public final Context context;
+        @NonNull public final NetworkCapabilities capabilities;
+        @NonNull public final LinkProperties properties;
+        @NonNull public final NetworkScore score;
+        @NonNull public final NetworkAgentConfig config;
+        @NonNull public final NetworkInfo info;
+        @Nullable public final LocalNetworkConfig localNetworkConfig;
         InitialConfiguration(@NonNull Context context, @NonNull NetworkCapabilities capabilities,
-                @NonNull LinkProperties properties, @NonNull NetworkScore score,
+                @NonNull LinkProperties properties,
+                @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
                 @NonNull NetworkAgentConfig config, @NonNull NetworkInfo info) {
             this.context = context;
             this.capabilities = capabilities;
@@ -538,14 +573,15 @@
             this.score = score;
             this.config = config;
             this.info = info;
+            this.localNetworkConfig = localNetworkConfig;
         }
     }
     private volatile InitialConfiguration mInitialConfiguration;
 
     private NetworkAgent(@NonNull Looper looper, @NonNull Context context, @NonNull String logTag,
             @NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
-            @NonNull NetworkScore score, @NonNull NetworkAgentConfig config, int providerId,
-            @NonNull NetworkInfo ni) {
+            @Nullable LocalNetworkConfig localNetworkConfig, @NonNull NetworkScore score,
+            @NonNull NetworkAgentConfig config, int providerId, @NonNull NetworkInfo ni) {
         mHandler = new NetworkAgentHandler(looper);
         LOG_TAG = logTag;
         mNetworkInfo = new NetworkInfo(ni);
@@ -556,7 +592,7 @@
 
         mInitialConfiguration = new InitialConfiguration(context,
                 new NetworkCapabilities(nc, NetworkCapabilities.REDACT_NONE),
-                new LinkProperties(lp), score, config, ni);
+                new LinkProperties(lp), localNetworkConfig, score, config, ni);
     }
 
     private class NetworkAgentHandler extends Handler {
@@ -723,7 +759,8 @@
             mNetwork = cm.registerNetworkAgent(new NetworkAgentBinder(mHandler),
                     new NetworkInfo(mInitialConfiguration.info),
                     mInitialConfiguration.properties, mInitialConfiguration.capabilities,
-                    mInitialConfiguration.score, mInitialConfiguration.config, providerId);
+                    mInitialConfiguration.localNetworkConfig, mInitialConfiguration.score,
+                    mInitialConfiguration.config, providerId);
             mInitialConfiguration = null; // All this memory can now be GC'd
         }
         return mNetwork;
@@ -1099,6 +1136,18 @@
     }
 
     /**
+     * Must be called by the agent when the network's {@link LocalNetworkConfig} changes.
+     * @param config the new LocalNetworkConfig
+     * @hide
+     */
+    public void sendLocalNetworkConfig(@NonNull LocalNetworkConfig config) {
+        Objects.requireNonNull(config);
+        // If the agent doesn't have NET_CAPABILITY_LOCAL_NETWORK, this will be ignored by
+        // ConnectivityService with a Log.wtf.
+        queueOrSendMessage(reg -> reg.sendLocalNetworkConfig(config));
+    }
+
+    /**
      * Must be called by the agent to update the score of this network.
      *
      * @param score the new score.
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index abda1fa..f959114 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -29,6 +29,9 @@
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
 import android.net.ConnectivityManager.NetworkCallback;
+// Can't be imported because aconfig tooling doesn't exist on udc-mainline-prod yet
+// See inner class Flags which mimics this for the time being
+// import android.net.flags.Flags;
 import android.os.Build;
 import android.os.Parcel;
 import android.os.Parcelable;
@@ -121,6 +124,14 @@
 public final class NetworkCapabilities implements Parcelable {
     private static final String TAG = "NetworkCapabilities";
 
+    // TODO : remove this class when udc-mainline-prod is abandoned and android.net.flags.Flags is
+    // available here
+    /** @hide */
+    public static class Flags {
+        static final String FLAG_FORBIDDEN_CAPABILITY =
+                "com.android.net.flags.forbidden_capability";
+    }
+
     /**
      * Mechanism to support redaction of fields in NetworkCapabilities that are guarded by specific
      * app permissions.
@@ -442,6 +453,7 @@
             NET_CAPABILITY_MMTEL,
             NET_CAPABILITY_PRIORITIZE_LATENCY,
             NET_CAPABILITY_PRIORITIZE_BANDWIDTH,
+            NET_CAPABILITY_LOCAL_NETWORK,
     })
     public @interface NetCapability { }
 
@@ -703,7 +715,21 @@
      */
     public static final int NET_CAPABILITY_PRIORITIZE_BANDWIDTH = 35;
 
-    private static final int MAX_NET_CAPABILITY = NET_CAPABILITY_PRIORITIZE_BANDWIDTH;
+    /**
+     * This is a local network, e.g. a tethering downstream or a P2P direct network.
+     *
+     * <p>
+     * Note that local networks are not sent to callbacks by default. To receive callbacks about
+     * them, the {@link NetworkRequest} instance must be prepared to see them, either by
+     * adding the capability with {@link NetworkRequest.Builder#addCapability}, by removing
+     * this forbidden capability with {@link NetworkRequest.Builder#removeForbiddenCapability},
+     * or by clearing all capabilites with {@link NetworkRequest.Builder#clearCapabilities()}.
+     * </p>
+     * @hide
+     */
+    public static final int NET_CAPABILITY_LOCAL_NETWORK = 36;
+
+    private static final int MAX_NET_CAPABILITY = NET_CAPABILITY_LOCAL_NETWORK;
 
     // Set all bits up to the MAX_NET_CAPABILITY-th bit
     private static final long ALL_VALID_CAPABILITIES = (2L << MAX_NET_CAPABILITY) - 1;
@@ -793,6 +819,10 @@
      * Adds the given capability to this {@code NetworkCapability} instance.
      * Note that when searching for a network to satisfy a request, all capabilities
      * requested must be satisfied.
+     * <p>
+     * If the capability was previously added to the list of forbidden capabilities (either
+     * by default or added using {@link #addForbiddenCapability(int)}), then it will be removed
+     * from the list of forbidden capabilities as well.
      *
      * @param capability the capability to be added.
      * @return This NetworkCapabilities instance, to facilitate chaining.
@@ -801,8 +831,7 @@
     public @NonNull NetworkCapabilities addCapability(@NetCapability int capability) {
         // If the given capability was previously added to the list of forbidden capabilities
         // then the capability will also be removed from the list of forbidden capabilities.
-        // TODO: Consider adding forbidden capabilities to the public API and mention this
-        // in the documentation.
+        // TODO: Add forbidden capabilities to the public API
         checkValidCapability(capability);
         mNetworkCapabilities |= 1L << capability;
         // remove from forbidden capability list
@@ -845,7 +874,7 @@
     }
 
     /**
-     * Removes (if found) the given forbidden capability from this {@code NetworkCapability}
+     * Removes (if found) the given forbidden capability from this {@link NetworkCapabilities}
      * instance that were added via addForbiddenCapability(int) or setCapabilities(int[], int[]).
      *
      * @param capability the capability to be removed.
@@ -859,6 +888,16 @@
     }
 
     /**
+     * Removes all forbidden capabilities from this {@link NetworkCapabilities} instance.
+     * @return This NetworkCapabilities instance, to facilitate chaining.
+     * @hide
+     */
+    public @NonNull NetworkCapabilities removeAllForbiddenCapabilities() {
+        mForbiddenNetworkCapabilities = 0;
+        return this;
+    }
+
+    /**
      * Sets (or clears) the given capability on this {@link NetworkCapabilities}
      * instance.
      * @hide
@@ -901,6 +940,8 @@
      * @return an array of forbidden capability values for this instance.
      * @hide
      */
+    @NonNull
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
     public @NetCapability int[] getForbiddenCapabilities() {
         return BitUtils.unpackBits(mForbiddenNetworkCapabilities);
     }
@@ -1000,7 +1041,7 @@
     /**
      * Tests for the presence of a capability on this instance.
      *
-     * @param capability the capabilities to be tested for.
+     * @param capability the capability to be tested for.
      * @return {@code true} if set on this instance.
      */
     public boolean hasCapability(@NetCapability int capability) {
@@ -1008,19 +1049,27 @@
                 && ((mNetworkCapabilities & (1L << capability)) != 0);
     }
 
-    /** @hide */
+    /**
+     * Tests for the presence of a forbidden capability on this instance.
+     *
+     * @param capability the capability to be tested for.
+     * @return {@code true} if this capability is set forbidden on this instance.
+     * @hide
+     */
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
     public boolean hasForbiddenCapability(@NetCapability int capability) {
         return isValidCapability(capability)
                 && ((mForbiddenNetworkCapabilities & (1L << capability)) != 0);
     }
 
     /**
-     * Check if this NetworkCapabilities has system managed capabilities or not.
+     * Check if this NetworkCapabilities has connectivity-managed capabilities or not.
      * @hide
      */
     public boolean hasConnectivityManagedCapability() {
-        return ((mNetworkCapabilities & CONNECTIVITY_MANAGED_CAPABILITIES) != 0);
+        return (mNetworkCapabilities & CONNECTIVITY_MANAGED_CAPABILITIES) != 0
+                || mForbiddenNetworkCapabilities != 0;
     }
 
     /**
@@ -2500,6 +2549,7 @@
             case NET_CAPABILITY_MMTEL:                return "MMTEL";
             case NET_CAPABILITY_PRIORITIZE_LATENCY:          return "PRIORITIZE_LATENCY";
             case NET_CAPABILITY_PRIORITIZE_BANDWIDTH:        return "PRIORITIZE_BANDWIDTH";
+            case NET_CAPABILITY_LOCAL_NETWORK:        return "LOCAL_NETWORK";
             default:                                  return Integer.toString(capability);
         }
     }
@@ -2889,6 +2939,44 @@
         }
 
         /**
+         * Adds the given capability to the list of forbidden capabilities.
+         *
+         * A network with a capability will not match a {@link NetworkCapabilities} or
+         * {@link NetworkRequest} which has said capability set as forbidden. For example, if
+         * a request has NET_CAPABILITY_INTERNET in the list of forbidden capabilities, networks
+         * with NET_CAPABILITY_INTERNET will not match the request.
+         *
+         * If the capability was previously added to the list of required capabilities (for
+         * example, it was there by default or added using {@link #addCapability(int)} method), then
+         * it will be removed from the list of required capabilities as well.
+         *
+         * @param capability the capability
+         * @return this builder
+         * @hide
+         */
+        @NonNull
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
+        public Builder addForbiddenCapability(@NetCapability final int capability) {
+            mCaps.addForbiddenCapability(capability);
+            return this;
+        }
+
+        /**
+         * Removes the given capability from the list of forbidden capabilities.
+         *
+         * @see #addForbiddenCapability(int)
+         * @param capability the capability
+         * @return this builder
+         * @hide
+         */
+        @NonNull
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
+        public Builder removeForbiddenCapability(@NetCapability final int capability) {
+            mCaps.removeForbiddenCapability(capability);
+            return this;
+        }
+
+        /**
          * Adds the given enterprise capability identifier.
          * Note that when searching for a network to satisfy a request, all capabilities identifier
          * requested must be satisfied. Enterprise capability identifier is applicable only
@@ -3235,4 +3323,4 @@
             return new NetworkCapabilities(mCaps);
         }
     }
-}
\ No newline at end of file
+}
diff --git a/framework/src/android/net/NetworkRequest.java b/framework/src/android/net/NetworkRequest.java
index 6c351d0..9824faa 100644
--- a/framework/src/android/net/NetworkRequest.java
+++ b/framework/src/android/net/NetworkRequest.java
@@ -20,6 +20,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOREGROUND;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -39,6 +40,8 @@
 import android.annotation.SuppressLint;
 import android.annotation.SystemApi;
 import android.compat.annotation.UnsupportedAppUsage;
+// TODO : replace with android.net.flags.Flags when aconfig is supported on udc-mainline-prod
+// import android.net.NetworkCapabilities.Flags;
 import android.net.NetworkCapabilities.NetCapability;
 import android.net.NetworkCapabilities.Transport;
 import android.os.Build;
@@ -281,6 +284,15 @@
                 NET_CAPABILITY_TRUSTED,
                 NET_CAPABILITY_VALIDATED);
 
+        /**
+         * Capabilities that are forbidden by default.
+         * Forbidden capabilities only make sense in NetworkRequest, not for network agents.
+         * Therefore these capabilities are only in NetworkRequest.
+         */
+        private static final int[] DEFAULT_FORBIDDEN_CAPABILITIES = new int[] {
+            NET_CAPABILITY_LOCAL_NETWORK
+        };
+
         private final NetworkCapabilities mNetworkCapabilities;
 
         // A boolean that represents whether the NOT_VCN_MANAGED capability should be deduced when
@@ -296,6 +308,16 @@
             // it for apps that do not have the NETWORK_SETTINGS permission.
             mNetworkCapabilities = new NetworkCapabilities();
             mNetworkCapabilities.setSingleUid(Process.myUid());
+            // Default forbidden capabilities are foremost meant to help with backward
+            // compatibility. When adding new types of network identified by a capability that
+            // might confuse older apps, a default forbidden capability will have apps not see
+            // these networks unless they explicitly ask for it.
+            // If the app called clearCapabilities() it will see everything, but then it
+            // can be argued that it's fair to send them too, since it asked for everything
+            // explicitly.
+            for (final int forbiddenCap : DEFAULT_FORBIDDEN_CAPABILITIES) {
+                mNetworkCapabilities.addForbiddenCapability(forbiddenCap);
+            }
         }
 
         /**
@@ -408,6 +430,7 @@
         @NonNull
         @SuppressLint("MissingGetterMatchingBuilder")
         @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
         public Builder addForbiddenCapability(@NetworkCapabilities.NetCapability int capability) {
             mNetworkCapabilities.addForbiddenCapability(capability);
             return this;
@@ -424,6 +447,7 @@
         @NonNull
         @SuppressLint("BuilderSetStyle")
         @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+        // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public
         public Builder removeForbiddenCapability(
                 @NetworkCapabilities.NetCapability int capability) {
             mNetworkCapabilities.removeForbiddenCapability(capability);
@@ -433,6 +457,7 @@
         /**
          * Completely clears all the {@code NetworkCapabilities} from this builder instance,
          * removing even the capabilities that are set by default when the object is constructed.
+         * Also removes any set forbidden capabilities.
          *
          * @return The builder to facilitate chaining.
          */
@@ -721,6 +746,7 @@
      * @hide
      */
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public instead of @SystemApi
     public boolean hasForbiddenCapability(@NetCapability int capability) {
         return networkCapabilities.hasForbiddenCapability(capability);
     }
@@ -843,6 +869,7 @@
      */
     @NonNull
     @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES)
+    // TODO : @FlaggedApi(Flags.FLAG_FORBIDDEN_CAPABILITY) and public instead of @SystemApi
     public @NetCapability int[] getForbiddenCapabilities() {
         // No need to make a defensive copy here as NC#getForbiddenCapabilities() already returns
         // a new array.
diff --git a/framework/src/android/net/NetworkScore.java b/framework/src/android/net/NetworkScore.java
index 815e2b0..00382f6 100644
--- a/framework/src/android/net/NetworkScore.java
+++ b/framework/src/android/net/NetworkScore.java
@@ -44,7 +44,9 @@
     @Retention(RetentionPolicy.SOURCE)
     @IntDef(value = {
             KEEP_CONNECTED_NONE,
-            KEEP_CONNECTED_FOR_HANDOVER
+            KEEP_CONNECTED_FOR_HANDOVER,
+            KEEP_CONNECTED_FOR_TEST,
+            KEEP_CONNECTED_DOWNSTREAM_NETWORK
     })
     public @interface KeepConnectedReason { }
 
@@ -57,6 +59,18 @@
      * is being considered for handover.
      */
     public static final int KEEP_CONNECTED_FOR_HANDOVER = 1;
+    /**
+     * Keep this network connected even if there is no outstanding request for it, because it
+     * is used in a test and it's not necessarily easy to file the right request for it.
+     * @hide
+     */
+    public static final int KEEP_CONNECTED_FOR_TEST = 2;
+    /**
+     * Keep this network connected even if there is no outstanding request for it, because
+     * it is a downstream network.
+     * @hide
+     */
+    public static final int KEEP_CONNECTED_DOWNSTREAM_NETWORK = 3;
 
     // Agent-managed policies
     // This network should lose to a wifi that has ever been validated
diff --git a/framework/src/android/net/RouteInfo.java b/framework/src/android/net/RouteInfo.java
index df5f151..e8ebf81 100644
--- a/framework/src/android/net/RouteInfo.java
+++ b/framework/src/android/net/RouteInfo.java
@@ -584,7 +584,7 @@
             }
             RouteKey p = (RouteKey) o;
             // No need to do anything special for scoped addresses. Inet6Address#equals does not
-            // consider the scope ID, but the netd route IPCs (e.g., INetd#networkAddRouteParcel)
+            // consider the scope ID, but the route IPCs (e.g., RoutingCoordinatorManager#addRoute)
             // and the kernel ignore scoped addresses both in the prefix and in the nexthop and only
             // look at RTA_OIF.
             return Objects.equals(p.mDestination, mDestination)
diff --git a/framework/src/android/net/RoutingCoordinatorManager.java b/framework/src/android/net/RoutingCoordinatorManager.java
new file mode 100644
index 0000000..a9e7eef
--- /dev/null
+++ b/framework/src/android/net/RoutingCoordinatorManager.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+/**
+ * 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.
+ * @hide
+ */
+@RequiresApi(Build.VERSION_CODES.S)
+public class RoutingCoordinatorManager {
+    @NonNull final Context mContext;
+    @NonNull final IRoutingCoordinator mService;
+
+    public RoutingCoordinatorManager(@NonNull final Context context,
+            @NonNull final IRoutingCoordinator service) {
+        mContext = context;
+        mService = service;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.addRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.removeRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void updateRoute(final int netId, final RouteInfo route) {
+        try {
+            mService.updateRoute(netId, route);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void addInterfaceToNetwork(final int netId, final String iface) {
+        try {
+            mService.addInterfaceToNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    public void removeInterfaceFromNetwork(final int netId, final String iface) {
+        try {
+            mService.removeInterfaceFromNetwork(netId, iface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Add forwarding ip rule
+     *
+     * @param fromIface interface name to add forwarding ip rule
+     * @param toIface interface name to add forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addInterfaceForward(final String fromIface, final String toIface) {
+        try {
+            mService.addInterfaceForward(fromIface, toIface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Remove forwarding ip rule
+     *
+     * @param fromIface interface name to remove forwarding ip rule
+     * @param toIface interface name to remove forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeInterfaceForward(final String fromIface, final String toIface) {
+        try {
+            mService.removeInterfaceForward(fromIface, toIface);
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+}
diff --git a/service/src/com/android/server/UidOwnerValue.java b/framework/src/android/net/UidOwnerValue.java
similarity index 86%
rename from service/src/com/android/server/UidOwnerValue.java
rename to framework/src/android/net/UidOwnerValue.java
index d6c0e0d..e8ae604 100644
--- a/service/src/com/android/server/UidOwnerValue.java
+++ b/framework/src/android/net/UidOwnerValue.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2022 The Android Open Source Project
+ * Copyright (C) 2023 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.
@@ -14,11 +14,15 @@
  * limitations under the License.
  */
 
-package com.android.server;
+package android.net;
 
 import com.android.net.module.util.Struct;
 
-/** Value type for per uid traffic control configuration map  */
+/**
+ * Value type for per uid traffic control configuration map.
+ *
+ * @hide
+ */
 public class UidOwnerValue extends Struct {
     // Allowed interface index. Only applicable if IIF_MATCH is set in the rule bitmask below.
     @Field(order = 0, type = Type.S32)
diff --git a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
index d65858f..c2d75d2 100644
--- a/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
+++ b/framework/src/android/net/connectivity/TiramisuConnectivityInternalApiUtil.java
@@ -18,6 +18,7 @@
 
 import android.content.Context;
 import android.net.ConnectivityManager;
+import android.net.RoutingCoordinatorManager;
 import android.os.Build;
 import android.os.IBinder;
 
@@ -34,15 +35,28 @@
  * linter).
  * @hide
  */
-@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+// TODO : rename this so that it doesn't reference "Tiramisu" since it can be used in S.
+@RequiresApi(Build.VERSION_CODES.S)
 public class TiramisuConnectivityInternalApiUtil {
 
     /**
      * Get a service binder token for
      * {@link com.android.server.connectivity.wear.CompanionDeviceManagerProxyService}.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public static IBinder getCompanionDeviceManagerProxyService(Context ctx) {
         final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
         return cm.getCompanionDeviceManagerProxyService();
     }
+
+    /**
+     * Obtain a routing coordinator manager from a context, possibly cross-module.
+     * @param ctx the context
+     * @return an instance of the coordinator manager
+     */
+    @RequiresApi(Build.VERSION_CODES.S)
+    public static RoutingCoordinatorManager getRoutingCoordinatorManager(Context ctx) {
+        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
+        return cm.getRoutingCoordinatorManager();
+    }
 }
diff --git a/netbpfload/Android.bp b/netbpfload/Android.bp
index d4e7ba8..1f92374 100644
--- a/netbpfload/Android.bp
+++ b/netbpfload/Android.bp
@@ -36,4 +36,16 @@
         "loader.cpp",
         "NetBpfLoad.cpp",
     ],
+    apex_available: [
+        "com.android.tethering",
+        "//apex_available:platform",
+    ],
+    // really should be Android 14/U (34), but we cannot include binaries built
+    // against newer sdk in the apex, which still targets 30(R):
+    // module "netbpfload" variant "android_x86_apex30": should support
+    // min_sdk_version(30) for "com.android.tethering": newer SDK(34).
+    min_sdk_version: "30",
+
+    init_rc: ["netbpfload.rc"],
+    required: ["bpfloader"],
 }
diff --git a/netbpfload/NetBpfLoad.cpp b/netbpfload/NetBpfLoad.cpp
index b44a0bc..6152287 100644
--- a/netbpfload/NetBpfLoad.cpp
+++ b/netbpfload/NetBpfLoad.cpp
@@ -38,6 +38,7 @@
 #include <sys/stat.h>
 #include <sys/types.h>
 
+#include <android/api-level.h>
 #include <android-base/logging.h>
 #include <android-base/macros.h>
 #include <android-base/properties.h>
@@ -168,10 +169,13 @@
     return 0;
 }
 
-int main(int argc, char** argv) {
+int main(int argc, char** argv, char * const envp[]) {
     (void)argc;
     android::base::InitLogging(argv, &android::base::KernelLogger);
 
+    const int device_api_level = android_get_device_api_level();
+    const bool isAtLeastU = (device_api_level >= __ANDROID_API_U__);
+
     if (!android::bpf::isAtLeastKernelVersion(4, 19, 0)) {
         ALOGE("Android U QPR2 requires kernel 4.19.");
         return 1;
@@ -208,24 +212,27 @@
         return 1;
     }
 
-    // Linux 5.16-rc1 changed the default to 2 (disabled but changeable), but we need 0 (enabled)
-    // (this writeFile is known to fail on at least 4.19, but always defaults to 0 on pre-5.13,
-    // on 5.13+ it depends on CONFIG_BPF_UNPRIV_DEFAULT_OFF)
-    if (writeProcSysFile("/proc/sys/kernel/unprivileged_bpf_disabled", "0\n") &&
-        android::bpf::isAtLeastKernelVersion(5, 13, 0)) return 1;
+    if (isAtLeastU) {
+        // Linux 5.16-rc1 changed the default to 2 (disabled but changeable),
+        // but we need 0 (enabled)
+        // (this writeFile is known to fail on at least 4.19, but always defaults to 0 on
+        // pre-5.13, on 5.13+ it depends on CONFIG_BPF_UNPRIV_DEFAULT_OFF)
+        if (writeProcSysFile("/proc/sys/kernel/unprivileged_bpf_disabled", "0\n") &&
+            android::bpf::isAtLeastKernelVersion(5, 13, 0)) return 1;
 
-    // Enable the eBPF JIT -- but do note that on 64-bit kernels it is likely
-    // already force enabled by the kernel config option BPF_JIT_ALWAYS_ON.
-    // (Note: this (open) will fail with ENOENT 'No such file or directory' if
-    //  kernel does not have CONFIG_BPF_JIT=y)
-    // BPF_JIT is required by R VINTF (which means 4.14/4.19/5.4 kernels),
-    // but 4.14/4.19 were released with P & Q, and only 5.4 is new in R+.
-    if (writeProcSysFile("/proc/sys/net/core/bpf_jit_enable", "1\n")) return 1;
+        // Enable the eBPF JIT -- but do note that on 64-bit kernels it is likely
+        // already force enabled by the kernel config option BPF_JIT_ALWAYS_ON.
+        // (Note: this (open) will fail with ENOENT 'No such file or directory' if
+        //  kernel does not have CONFIG_BPF_JIT=y)
+        // BPF_JIT is required by R VINTF (which means 4.14/4.19/5.4 kernels),
+        // but 4.14/4.19 were released with P & Q, and only 5.4 is new in R+.
+        if (writeProcSysFile("/proc/sys/net/core/bpf_jit_enable", "1\n")) return 1;
 
-    // Enable JIT kallsyms export for privileged users only
-    // (Note: this (open) will fail with ENOENT 'No such file or directory' if
-    //  kernel does not have CONFIG_HAVE_EBPF_JIT=y)
-    if (writeProcSysFile("/proc/sys/net/core/bpf_jit_kallsyms", "1\n")) return 1;
+        // Enable JIT kallsyms export for privileged users only
+        // (Note: this (open) will fail with ENOENT 'No such file or directory' if
+        //  kernel does not have CONFIG_HAVE_EBPF_JIT=y)
+        if (writeProcSysFile("/proc/sys/net/core/bpf_jit_kallsyms", "1\n")) return 1;
+    }
 
     // Create all the pin subdirectories
     // (this must be done first to allow selinux_context and pin_subdir functionality,
@@ -257,10 +264,12 @@
         return 1;
     }
 
-    if (android::base::SetProperty("bpf.progs_loaded", "1") == false) {
-        ALOGE("Failed to set bpf.progs_loaded property");
-        return 1;
+    ALOGI("done, transferring control to platform bpfloader.");
+
+    const char * args[] = { "/system/bin/bpfloader", NULL, };
+    if (execve(args[0], (char**)args, envp)) {
+        ALOGE("FATAL: execve('/system/bin/bpfloader'): %d[%s]", errno, strerror(errno));
     }
 
-    return 0;
+    return 1;
 }
diff --git a/netbpfload/initrc-doc/README.txt b/netbpfload/initrc-doc/README.txt
new file mode 100644
index 0000000..42e1fc2
--- /dev/null
+++ b/netbpfload/initrc-doc/README.txt
@@ -0,0 +1,62 @@
+This directory contains comment stripped versions of
+  //system/bpf/bpfloader/bpfloader.rc
+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
+
+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
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/sc-v2-dev:bpfloader/bpfloader.rc;   ) | egrep -v '^ *#' > bpfloader-sdk31-12-S.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/tm-qpr-dev:bpfloader/bpfloader.rc;  ) | egrep -v '^ *#' > bpfloader-sdk33-13-T.rc
+  (cd /android1/system/bpf && git cat-file -p remotes/goog/udc-qpr-dev:bpfloader/bpfloader.rc; ) | egrep -v '^ *#' > bpfloader-sdk34-14-U.rc
+
+ie. there were no changes between R/S/T and R/S/T QPR3, and no change between U and U QPR1.
+
+Note: Sv2 sdk/api level is actually 32, it just didn't change anything wrt. bpf, so doesn't matter.
+
+
+Key takeaways:
+
+= R bpfloader:
+  - CHOWN + SYS_ADMIN
+  - asynchronous startup
+  - platform only
+  - proc file setup handled by initrc
+
+= S bpfloader
+  - adds NET_ADMIN
+  - synchronous startup
+  - platform + mainline tethering offload
+
+= T bpfloader
+  - platform + mainline networking (including tethering offload)
+  - supported btf for maps via exec of btfloader
+
+= U bpfloader
+  - 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
+  - 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 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").
diff --git a/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc b/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc
new file mode 100644
index 0000000..482a7db
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk30-11-R.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc b/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc
new file mode 100644
index 0000000..4117887
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk31-12-S.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    exec_start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc b/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc
new file mode 100644
index 0000000..f0b6700
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk33-13-T.rc
@@ -0,0 +1,12 @@
+on load_bpf_programs
+    write /proc/sys/kernel/unprivileged_bpf_disabled 0
+    write /proc/sys/net/core/bpf_jit_enable 1
+    write /proc/sys/net/core/bpf_jit_kallsyms 1
+    exec_start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    reboot_on_failure reboot,bpfloader-failed
+    updatable
diff --git a/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc b/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
new file mode 100644
index 0000000..8f3f462
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk34-14-U-QPR2.rc
@@ -0,0 +1,11 @@
+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/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc b/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc
new file mode 100644
index 0000000..592303e
--- /dev/null
+++ b/netbpfload/initrc-doc/bpfloader-sdk34-14-U.rc
@@ -0,0 +1,11 @@
+on load_bpf_programs
+    exec_start bpfloader
+
+service bpfloader /system/bin/bpfloader
+    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/netbpfload/netbpfload.rc b/netbpfload/netbpfload.rc
new file mode 100644
index 0000000..14181dc
--- /dev/null
+++ b/netbpfload/netbpfload.rc
@@ -0,0 +1,86 @@
+# zygote-start is what officially starts netd (see //system/core/rootdir/init.rc)
+# However, on some hardware it's started from post-fs-data as well, which is just
+# a tad earlier.  There's no benefit to that though, since on 4.9+ P+ devices netd
+# will just block until bpfloader finishes and sets the bpf.progs_loaded property.
+#
+# It is important that we start bpfloader after:
+#   - /sys/fs/bpf is already mounted,
+#   - apex (incl. rollback) is initialized (so that in the future we can load bpf
+#     programs shipped as part of apex mainline modules)
+#   - logd is ready for us to log stuff
+#
+# At the same time we want to be as early as possible to reduce races and thus
+# failures (before memory is fragmented, and cpu is busy running tons of other
+# stuff) and we absolutely want to be before netd and the system boot slot is
+# considered to have booted successfully.
+#
+on load_bpf_programs
+    exec_start bpfloader
+
+service bpfloader /system/bin/netbpfload
+    # netbpfload will do network bpf loading, then execute /system/bin/bpfloader
+    capabilities CHOWN SYS_ADMIN NET_ADMIN
+    # The following group memberships are a workaround for lack of DAC_OVERRIDE
+    # and allow us to open (among other things) files that we created and are
+    # no longer root owned (due to CHOWN) but still have group read access to
+    # one of the following groups.  This is not perfect, but a more correct
+    # solution requires significantly more effort to implement.
+    group root graphics network_stack net_admin net_bw_acct net_bw_stats net_raw system
+    user root
+    #
+    # Set RLIMIT_MEMLOCK to 1GiB for bpfloader
+    #
+    # Actually only 8MiB would be needed if bpfloader ran as its own uid.
+    #
+    # However, while the rlimit is per-thread, the accounting is system wide.
+    # So, for example, if the graphics stack has already allocated 10MiB of
+    # memlock data before bpfloader even gets a chance to run, it would fail
+    # if its memlock rlimit is only 8MiB - since there would be none left for it.
+    #
+    # bpfloader succeeding is critical to system health, since a failure will
+    # cause netd crashloop and thus system server crashloop... and the only
+    # recovery is a full kernel reboot.
+    #
+    # We've had issues where devices would sometimes (rarely) boot into
+    # a crashloop because bpfloader would occasionally lose a boot time
+    # race against the graphics stack's boot time locked memory allocation.
+    #
+    # Thus bpfloader's memlock has to be 8MB higher then the locked memory
+    # consumption of the root uid anywhere else in the system...
+    # But we don't know what that is for all possible devices...
+    #
+    # Ideally, we'd simply grant bpfloader the IPC_LOCK capability and it
+    # would simply ignore it's memlock rlimit... but it turns that this
+    # capability is not even checked by the kernel's bpf system call.
+    #
+    # As such we simply use 1GiB as a reasonable approximation of infinity.
+    #
+    rlimit memlock 1073741824 1073741824
+    oneshot
+    #
+    # How to debug bootloops caused by 'bpfloader-failed'.
+    #
+    # 1. On some lower RAM devices (like wembley) you may need to first enable developer mode
+    #    (from the Settings app UI), and change the developer option "Logger buffer sizes"
+    #    from the default (wembley: 64kB) to the maximum (1M) per log buffer.
+    #    Otherwise buffer will overflow before you manage to dump it and you'll get useless logs.
+    #
+    # 2. comment out 'reboot_on_failure reboot,bpfloader-failed' below
+    # 3. rebuild/reflash/reboot
+    # 4. as the device is booting up capture bpfloader logs via:
+    #    adb logcat -s 'bpfloader:*' 'LibBpfLoader:*' 'NetBpfLoad:*' 'NetBpfLoader:*'
+    #
+    # something like:
+    #   $ adb reboot; sleep 1; adb wait-for-device; adb root; sleep 1; adb wait-for-device; adb logcat -s 'bpfloader:*' 'LibBpfLoader:*' 'NetBpfLoad:*' 'NetBpfLoader:*'
+    # will take care of capturing logs as early as possible
+    #
+    # 5. look through the logs from the kernel's bpf verifier that bpfloader dumps out,
+    #    it usually makes sense to search back from the end and find the particular
+    #    bpf verifier failure that caused bpfloader to terminate early with an error code.
+    #    This will probably be something along the lines of 'too many jumps' or
+    #    'cannot prove return value is 0 or 1' or 'unsupported / unknown operation / helper',
+    #    'invalid bpf_context access', etc.
+    #
+    reboot_on_failure reboot,bpfloader-failed
+    # we're not really updatable, but want to be able to load bpf programs shipped in apexes
+    updatable
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 93ccb85..cc3f019 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -1703,15 +1703,20 @@
         am.addOnUidImportanceListener(new UidImportanceListener(handler),
                 mRunningAppActiveImportanceCutoff);
 
+        final MdnsFeatureFlags flags = new MdnsFeatureFlags.Builder()
+                .setIsMdnsOffloadFeatureEnabled(mDeps.isTetheringFeatureNotChickenedOut(
+                        mContext, MdnsFeatureFlags.NSD_FORCE_DISABLE_MDNS_OFFLOAD))
+                .setIncludeInetAddressRecordsInProbing(mDeps.isFeatureEnabled(
+                        mContext, MdnsFeatureFlags.INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING))
+                .setIsExpiredServicesRemovalEnabled(mDeps.isTrunkStableFeatureEnabled(
+                        MdnsFeatureFlags.NSD_EXPIRED_SERVICES_REMOVAL))
+                .build();
         mMdnsSocketClient =
                 new MdnsMultinetworkSocketClient(handler.getLooper(), mMdnsSocketProvider,
                         LOGGER.forSubComponent("MdnsMultinetworkSocketClient"));
         mMdnsDiscoveryManager = deps.makeMdnsDiscoveryManager(new ExecutorProvider(),
-                mMdnsSocketClient, LOGGER.forSubComponent("MdnsDiscoveryManager"));
+                mMdnsSocketClient, LOGGER.forSubComponent("MdnsDiscoveryManager"), flags);
         handler.post(() -> mMdnsSocketClient.setCallback(mMdnsDiscoveryManager));
-        MdnsFeatureFlags flags = new MdnsFeatureFlags.Builder().setIsMdnsOffloadFeatureEnabled(
-                mDeps.isTetheringFeatureNotChickenedOut(mContext,
-                        MdnsFeatureFlags.NSD_FORCE_DISABLE_MDNS_OFFLOAD)).build();
         mAdvertiser = deps.makeMdnsAdvertiser(handler.getLooper(), mMdnsSocketProvider,
                 new AdvertiserCallback(), LOGGER.forSubComponent("MdnsAdvertiser"), flags);
         mClock = deps.makeClock();
@@ -1769,12 +1774,21 @@
         }
 
         /**
+         * @see DeviceConfigUtils#isTrunkStableFeatureEnabled
+         */
+        public boolean isTrunkStableFeatureEnabled(String feature) {
+            return DeviceConfigUtils.isTrunkStableFeatureEnabled(feature);
+        }
+
+        /**
          * @see MdnsDiscoveryManager
          */
         public MdnsDiscoveryManager makeMdnsDiscoveryManager(
                 @NonNull ExecutorProvider executorProvider,
-                @NonNull MdnsMultinetworkSocketClient socketClient, @NonNull SharedLog sharedLog) {
-            return new MdnsDiscoveryManager(executorProvider, socketClient, sharedLog);
+                @NonNull MdnsMultinetworkSocketClient socketClient, @NonNull SharedLog sharedLog,
+                @NonNull MdnsFeatureFlags featureFlags) {
+            return new MdnsDiscoveryManager(
+                    executorProvider, socketClient, sharedLog, featureFlags);
         }
 
         /**
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
index a946bca..28e3924 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsAdvertiser.java
@@ -96,10 +96,11 @@
                 @NonNull Looper looper, @NonNull byte[] packetCreationBuffer,
                 @NonNull MdnsInterfaceAdvertiser.Callback cb,
                 @NonNull String[] deviceHostName,
-                @NonNull SharedLog sharedLog) {
+                @NonNull SharedLog sharedLog,
+                @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
             // Note NetworkInterface is final and not mockable
             return new MdnsInterfaceAdvertiser(socket, initialAddresses, looper,
-                    packetCreationBuffer, cb, deviceHostName, sharedLog);
+                    packetCreationBuffer, cb, deviceHostName, sharedLog, mdnsFeatureFlags);
         }
 
         /**
@@ -394,7 +395,8 @@
             if (advertiser == null) {
                 advertiser = mDeps.makeAdvertiser(socket, addresses, mLooper, mPacketCreationBuffer,
                         mInterfaceAdvertiserCb, mDeviceHostName,
-                        mSharedLog.forSubComponent(socket.getInterface().getName()));
+                        mSharedLog.forSubComponent(socket.getInterface().getName()),
+                        mMdnsFeatureFlags);
                 mAllAdvertisers.put(socket, advertiser);
                 advertiser.start();
             }
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 24e9fa8..766f999 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsDiscoveryManager.java
@@ -53,6 +53,7 @@
     @NonNull private final Handler handler;
     @Nullable private final HandlerThread handlerThread;
     @NonNull private final MdnsServiceCache serviceCache;
+    @NonNull private final MdnsFeatureFlags mdnsFeatureFlags;
 
     private static class PerSocketServiceTypeClients {
         private final ArrayMap<Pair<String, SocketKey>, MdnsServiceTypeClient> clients =
@@ -117,20 +118,22 @@
     }
 
     public MdnsDiscoveryManager(@NonNull ExecutorProvider executorProvider,
-            @NonNull MdnsSocketClientBase socketClient, @NonNull SharedLog sharedLog) {
+            @NonNull MdnsSocketClientBase socketClient, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         this.executorProvider = executorProvider;
         this.socketClient = socketClient;
         this.sharedLog = sharedLog;
         this.perSocketServiceTypeClients = new PerSocketServiceTypeClients();
+        this.mdnsFeatureFlags = mdnsFeatureFlags;
         if (socketClient.getLooper() != null) {
             this.handlerThread = null;
             this.handler = new Handler(socketClient.getLooper());
-            this.serviceCache = new MdnsServiceCache(socketClient.getLooper());
+            this.serviceCache = new MdnsServiceCache(socketClient.getLooper(), mdnsFeatureFlags);
         } else {
             this.handlerThread = new HandlerThread(MdnsDiscoveryManager.class.getSimpleName());
             this.handlerThread.start();
             this.handler = new Handler(handlerThread.getLooper());
-            this.serviceCache = new MdnsServiceCache(handlerThread.getLooper());
+            this.serviceCache = new MdnsServiceCache(handlerThread.getLooper(), mdnsFeatureFlags);
         }
     }
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index 9840409..6f7645e 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -20,18 +20,39 @@
  */
 public class MdnsFeatureFlags {
     /**
-     * The feature flag for control whether the  mDNS offload is enabled or not.
+     * A feature flag to control whether the mDNS offload is enabled or not.
      */
     public static final String NSD_FORCE_DISABLE_MDNS_OFFLOAD = "nsd_force_disable_mdns_offload";
 
+    /**
+     * A feature flag to control whether the probing question should include
+     * InetAddressRecords or not.
+     */
+    public static final String INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING =
+            "include_inet_address_records_in_probing";
+    /**
+     * A feature flag to control whether expired services removal should be enabled.
+     */
+    public static final String NSD_EXPIRED_SERVICES_REMOVAL =
+            "nsd_expired_services_removal";
+
     // Flag for offload feature
     public final boolean mIsMdnsOffloadFeatureEnabled;
 
+    // Flag for including InetAddressRecords in probing questions.
+    public final boolean mIncludeInetAddressRecordsInProbing;
+
+    // Flag for expired services removal
+    public final boolean mIsExpiredServicesRemovalEnabled;
+
     /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
-    public MdnsFeatureFlags(boolean isOffloadFeatureEnabled) {
+    public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
+            boolean includeInetAddressRecordsInProbing, boolean isExpiredServicesRemovalEnabled) {
         mIsMdnsOffloadFeatureEnabled = isOffloadFeatureEnabled;
+        mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
+        mIsExpiredServicesRemovalEnabled = isExpiredServicesRemovalEnabled;
     }
 
 
@@ -44,16 +65,22 @@
     public static final class Builder {
 
         private boolean mIsMdnsOffloadFeatureEnabled;
+        private boolean mIncludeInetAddressRecordsInProbing;
+        private boolean mIsExpiredServicesRemovalEnabled;
 
         /**
          * The constructor for {@link Builder}.
          */
         public Builder() {
             mIsMdnsOffloadFeatureEnabled = false;
+            mIncludeInetAddressRecordsInProbing = false;
+            mIsExpiredServicesRemovalEnabled = true; // Default enabled.
         }
 
         /**
-         * Set if the mDNS offload  feature is enabled.
+         * Set whether the mDNS offload feature is enabled.
+         *
+         * @see #NSD_FORCE_DISABLE_MDNS_OFFLOAD
          */
         public Builder setIsMdnsOffloadFeatureEnabled(boolean isMdnsOffloadFeatureEnabled) {
             mIsMdnsOffloadFeatureEnabled = isMdnsOffloadFeatureEnabled;
@@ -61,11 +88,32 @@
         }
 
         /**
+         * Set whether the probing question should include InetAddressRecords.
+         *
+         * @see #INCLUDE_INET_ADDRESS_RECORDS_IN_PROBING
+         */
+        public Builder setIncludeInetAddressRecordsInProbing(
+                boolean includeInetAddressRecordsInProbing) {
+            mIncludeInetAddressRecordsInProbing = includeInetAddressRecordsInProbing;
+            return this;
+        }
+
+        /**
+         * Set whether the expired services removal is enabled.
+         *
+         * @see #NSD_EXPIRED_SERVICES_REMOVAL
+         */
+        public Builder setIsExpiredServicesRemovalEnabled(boolean isExpiredServicesRemovalEnabled) {
+            mIsExpiredServicesRemovalEnabled = isExpiredServicesRemovalEnabled;
+            return this;
+        }
+
+        /**
          * Builds a {@link MdnsFeatureFlags} with the arguments supplied to this builder.
          */
         public MdnsFeatureFlags build() {
-            return new MdnsFeatureFlags(mIsMdnsOffloadFeatureEnabled);
+            return new MdnsFeatureFlags(mIsMdnsOffloadFeatureEnabled,
+                    mIncludeInetAddressRecordsInProbing, mIsExpiredServicesRemovalEnabled);
         }
-
     }
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
index dd8a526..973fd96 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInetAddressRecord.java
@@ -18,7 +18,7 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
 
 import java.io.IOException;
 import java.net.Inet4Address;
@@ -29,7 +29,7 @@
 import java.util.Objects;
 
 /** An mDNS "AAAA" or "A" record, which holds an IPv6 or IPv4 address. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsInetAddressRecord extends MdnsRecord {
     @Nullable private Inet6Address inet6Address;
     @Nullable private Inet4Address inet4Address;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
index 37e9743..e07d380 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiser.java
@@ -150,8 +150,8 @@
         /** @see MdnsRecordRepository */
         @NonNull
         public MdnsRecordRepository makeRecordRepository(@NonNull Looper looper,
-                @NonNull String[] deviceHostName) {
-            return new MdnsRecordRepository(looper, deviceHostName);
+                @NonNull String[] deviceHostName, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+            return new MdnsRecordRepository(looper, deviceHostName, mdnsFeatureFlags);
         }
 
         /** @see MdnsReplySender */
@@ -187,16 +187,18 @@
     public MdnsInterfaceAdvertiser(@NonNull MdnsInterfaceSocket socket,
             @NonNull List<LinkAddress> initialAddresses, @NonNull Looper looper,
             @NonNull byte[] packetCreationBuffer, @NonNull Callback cb,
-            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog) {
+            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         this(socket, initialAddresses, looper, packetCreationBuffer, cb,
-                new Dependencies(), deviceHostName, sharedLog);
+                new Dependencies(), deviceHostName, sharedLog, mdnsFeatureFlags);
     }
 
     public MdnsInterfaceAdvertiser(@NonNull MdnsInterfaceSocket socket,
             @NonNull List<LinkAddress> initialAddresses, @NonNull Looper looper,
             @NonNull byte[] packetCreationBuffer, @NonNull Callback cb, @NonNull Dependencies deps,
-            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog) {
-        mRecordRepository = deps.makeRecordRepository(looper, deviceHostName);
+            @NonNull String[] deviceHostName, @NonNull SharedLog sharedLog,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        mRecordRepository = deps.makeRecordRepository(looper, deviceHostName, mdnsFeatureFlags);
         mRecordRepository.updateAddresses(initialAddresses);
         mSocket = socket;
         mCb = cb;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
index c88ead0..41cc380 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsPointerRecord.java
@@ -18,14 +18,15 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
 import java.util.Arrays;
 
 /** An mDNS "PTR" record, which holds a name (the "pointer"). */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsPointerRecord extends MdnsRecord {
     private String[] pointer;
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
index 130ff48..73c1758 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -92,16 +92,19 @@
     private final Looper mLooper;
     @NonNull
     private final String[] mDeviceHostname;
+    private final MdnsFeatureFlags mMdnsFeatureFlags;
 
-    public MdnsRecordRepository(@NonNull Looper looper, @NonNull String[] deviceHostname) {
-        this(looper, new Dependencies(), deviceHostname);
+    public MdnsRecordRepository(@NonNull Looper looper, @NonNull String[] deviceHostname,
+            @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
+        this(looper, new Dependencies(), deviceHostname, mdnsFeatureFlags);
     }
 
     @VisibleForTesting
     public MdnsRecordRepository(@NonNull Looper looper, @NonNull Dependencies deps,
-            @NonNull String[] deviceHostname) {
+            @NonNull String[] deviceHostname, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         mDeviceHostname = deviceHostname;
         mLooper = looper;
+        mMdnsFeatureFlags = mdnsFeatureFlags;
     }
 
     /**
@@ -351,7 +354,8 @@
     }
 
     private MdnsProber.ProbingInfo makeProbingInfo(int serviceId,
-            @NonNull MdnsServiceRecord srvRecord) {
+            @NonNull MdnsServiceRecord srvRecord,
+            @NonNull List<MdnsInetAddressRecord> inetAddressRecords) {
         final List<MdnsRecord> probingRecords = new ArrayList<>();
         // Probe with cacheFlush cleared; it is set when announcing, as it was verified unique:
         // RFC6762 10.2
@@ -363,6 +367,15 @@
                 srvRecord.getServicePort(),
                 srvRecord.getServiceHost()));
 
+        for (MdnsInetAddressRecord inetAddressRecord : inetAddressRecords) {
+            probingRecords.add(new MdnsInetAddressRecord(inetAddressRecord.getName(),
+                    0L /* receiptTimeMillis */,
+                    false /* cacheFlush */,
+                    inetAddressRecord.getTtl(),
+                    inetAddressRecord.getInet4Address() == null
+                            ? inetAddressRecord.getInet6Address()
+                            : inetAddressRecord.getInet4Address()));
+        }
         return new MdnsProber.ProbingInfo(serviceId, probingRecords);
     }
 
@@ -824,6 +837,18 @@
         return conflicting;
     }
 
+    private List<MdnsInetAddressRecord> makeProbingInetAddressRecords() {
+        final List<MdnsInetAddressRecord> records = new ArrayList<>();
+        if (mMdnsFeatureFlags.mIncludeInetAddressRecordsInProbing) {
+            for (RecordInfo<?> record : mGeneralRecords) {
+                if (record.record instanceof MdnsInetAddressRecord) {
+                    records.add((MdnsInetAddressRecord) record.record);
+                }
+            }
+        }
+        return records;
+    }
+
     /**
      * (Re)set a service to the probing state.
      * @return The {@link MdnsProber.ProbingInfo} to send for probing.
@@ -834,7 +859,8 @@
         if (registration == null) return null;
 
         registration.setProbing(true);
-        return makeProbingInfo(serviceId, registration.srvRecord.record);
+        return makeProbingInfo(
+                serviceId, registration.srvRecord.record, makeProbingInetAddressRecords());
     }
 
     /**
@@ -870,7 +896,8 @@
         final ServiceRegistration newService = new ServiceRegistration(mDeviceHostname, newInfo,
                 existing.subtype, existing.repliedServiceCount, existing.sentPacketCount);
         mServices.put(serviceId, newService);
-        return makeProbingInfo(serviceId, newService.srvRecord.record);
+        return makeProbingInfo(
+                serviceId, newService.srvRecord.record, makeProbingInetAddressRecords());
     }
 
     /**
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 ec6af9b..d3493c7 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceCache.java
@@ -42,7 +42,7 @@
  *  to their default value (0, false or null).
  */
 public class MdnsServiceCache {
-    private static class CacheKey {
+    static class CacheKey {
         @NonNull final String mLowercaseServiceType;
         @NonNull final SocketKey mSocketKey;
 
@@ -72,27 +72,33 @@
      */
     @NonNull
     private final ArrayMap<CacheKey, List<MdnsResponse>> mCachedServices = new ArrayMap<>();
+    /**
+     * A map of service expire callbacks. Key is composed of service type and socket and value is
+     * the callback listener.
+     */
+    @NonNull
+    private final ArrayMap<CacheKey, ServiceExpiredCallback> mCallbacks = new ArrayMap<>();
     @NonNull
     private final Handler mHandler;
+    @NonNull
+    private final MdnsFeatureFlags mMdnsFeatureFlags;
 
-    public MdnsServiceCache(@NonNull Looper looper) {
+    public MdnsServiceCache(@NonNull Looper looper, @NonNull MdnsFeatureFlags mdnsFeatureFlags) {
         mHandler = new Handler(looper);
+        mMdnsFeatureFlags = mdnsFeatureFlags;
     }
 
     /**
      * Get the cache services which are queried from given service type and socket.
      *
-     * @param serviceType the target service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @return the set of services which matches the given service type.
      */
     @NonNull
-    public List<MdnsResponse> getCachedServices(@NonNull String serviceType,
-            @NonNull SocketKey socketKey) {
+    public List<MdnsResponse> getCachedServices(@NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final CacheKey key = new CacheKey(serviceType, socketKey);
-        return mCachedServices.containsKey(key)
-                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(key)))
+        return mCachedServices.containsKey(cacheKey)
+                ? Collections.unmodifiableList(new ArrayList<>(mCachedServices.get(cacheKey)))
                 : Collections.emptyList();
     }
 
@@ -117,16 +123,13 @@
      * Get the cache service.
      *
      * @param serviceName the target service name.
-     * @param serviceType the target service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @return the service which matches given conditions.
      */
     @Nullable
-    public MdnsResponse getCachedService(@NonNull String serviceName,
-            @NonNull String serviceType, @NonNull SocketKey socketKey) {
+    public MdnsResponse getCachedService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses =
-                mCachedServices.get(new CacheKey(serviceType, socketKey));
+        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
         if (responses == null) {
             return null;
         }
@@ -137,15 +140,13 @@
     /**
      * Add or update a service.
      *
-     * @param serviceType the service type.
-     * @param socketKey the target socket
+     * @param cacheKey the target CacheKey.
      * @param response the response of the discovered service.
      */
-    public void addOrUpdateService(@NonNull String serviceType, @NonNull SocketKey socketKey,
-            @NonNull MdnsResponse response) {
+    public void addOrUpdateService(@NonNull CacheKey cacheKey, @NonNull MdnsResponse response) {
         ensureRunningOnHandlerThread(mHandler);
         final List<MdnsResponse> responses = mCachedServices.computeIfAbsent(
-                new CacheKey(serviceType, socketKey), key -> new ArrayList<>());
+                cacheKey, key -> new ArrayList<>());
         // Remove existing service if present.
         final MdnsResponse existing =
                 findMatchedResponse(responses, response.getServiceInstanceName());
@@ -157,15 +158,12 @@
      * Remove a service which matches the given service name, type and socket.
      *
      * @param serviceName the target service name.
-     * @param serviceType the target service type.
-     * @param socketKey the target socket.
+     * @param cacheKey the target CacheKey.
      */
     @Nullable
-    public MdnsResponse removeService(@NonNull String serviceName, @NonNull String serviceType,
-            @NonNull SocketKey socketKey) {
+    public MdnsResponse removeService(@NonNull String serviceName, @NonNull CacheKey cacheKey) {
         ensureRunningOnHandlerThread(mHandler);
-        final List<MdnsResponse> responses =
-                mCachedServices.get(new CacheKey(serviceType, socketKey));
+        final List<MdnsResponse> responses = mCachedServices.get(cacheKey);
         if (responses == null) {
             return null;
         }
@@ -180,5 +178,37 @@
         return null;
     }
 
+    /**
+     * Register a callback to listen to service expiration.
+     *
+     * <p> Registering the same callback instance twice is a no-op, since MdnsServiceTypeClient
+     * relies on this.
+     *
+     * @param cacheKey the target CacheKey.
+     * @param callback the callback that notify the service is expired.
+     */
+    public void registerServiceExpiredCallback(@NonNull CacheKey cacheKey,
+            @NonNull ServiceExpiredCallback callback) {
+        ensureRunningOnHandlerThread(mHandler);
+        mCallbacks.put(cacheKey, callback);
+    }
+
+    /**
+     * Unregister the service expired callback.
+     *
+     * @param cacheKey the CacheKey that is registered to listen service expiration before.
+     */
+    public void unregisterServiceExpiredCallback(@NonNull CacheKey cacheKey) {
+        ensureRunningOnHandlerThread(mHandler);
+        mCallbacks.remove(cacheKey);
+    }
+
+    /*** Callbacks for listening service expiration */
+    public interface ServiceExpiredCallback {
+        /*** Notify the service is expired */
+        void onServiceRecordExpired(@NonNull MdnsResponse previousResponse,
+                @Nullable MdnsResponse newResponse);
+    }
+
     // TODO: check ttl expiration for each service and notify to the clients.
 }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
index f851b35..4d407be 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceRecord.java
@@ -18,7 +18,8 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.util.MdnsUtils;
 
 import java.io.IOException;
@@ -27,7 +28,7 @@
 import java.util.Objects;
 
 /** An mDNS "SRV" record, which contains service information. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsServiceRecord extends MdnsRecord {
     public static final int PROTO_NONE = 0;
     public static final int PROTO_TCP = 1;
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index bbe8f4c..0a03186 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -16,6 +16,7 @@
 
 package com.android.server.connectivity.mdns;
 
+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;
@@ -71,6 +72,15 @@
      * The service caches for each socket. It should be accessed from looper thread only.
      */
     @NonNull private final MdnsServiceCache serviceCache;
+    @NonNull private final MdnsServiceCache.CacheKey cacheKey;
+    @NonNull private final ServiceExpiredCallback serviceExpiredCallback =
+            new ServiceExpiredCallback() {
+                @Override
+                public void onServiceRecordExpired(@NonNull MdnsResponse previousResponse,
+                        @Nullable MdnsResponse newResponse) {
+                    notifyRemovedServiceToListeners(previousResponse, "Service record expired");
+                }
+            };
     private final ArrayMap<MdnsServiceBrowserListener, MdnsSearchOptions> listeners =
             new ArrayMap<>();
     private final boolean removeServiceAfterTtlExpires =
@@ -225,6 +235,16 @@
         this.dependencies = dependencies;
         this.serviceCache = serviceCache;
         this.mdnsQueryScheduler = new MdnsQueryScheduler();
+        this.cacheKey = new MdnsServiceCache.CacheKey(serviceType, socketKey);
+    }
+
+    /**
+     * Do the cleanup of the MdnsServiceTypeClient
+     */
+    private void shutDown() {
+        removeScheduledTask();
+        mdnsQueryScheduler.cancelScheduledRun();
+        serviceCache.unregisterServiceExpiredCallback(cacheKey);
     }
 
     private static MdnsServiceInfo buildMdnsServiceInfoFromResponse(
@@ -293,7 +313,7 @@
         boolean hadReply = false;
         if (listeners.put(listener, searchOptions) == null) {
             for (MdnsResponse existingResponse :
-                    serviceCache.getCachedServices(serviceType, socketKey)) {
+                    serviceCache.getCachedServices(cacheKey)) {
                 if (!responseMatchesOptions(existingResponse, searchOptions)) continue;
                 final MdnsServiceInfo info =
                         buildMdnsServiceInfoFromResponse(existingResponse, serviceTypeLabels);
@@ -341,6 +361,8 @@
                     servicesToResolve.size() < listeners.size() /* sendDiscoveryQueries */);
             executor.submit(queryTask);
         }
+
+        serviceCache.registerServiceExpiredCallback(cacheKey, serviceExpiredCallback);
     }
 
     /**
@@ -390,8 +412,7 @@
             return listeners.isEmpty();
         }
         if (listeners.isEmpty()) {
-            removeScheduledTask();
-            mdnsQueryScheduler.cancelScheduledRun();
+            shutDown();
         }
         return listeners.isEmpty();
     }
@@ -404,8 +425,7 @@
         ensureRunningOnHandlerThread(handler);
         // Augment the list of current known responses, and generated responses for resolve
         // requests if there is no known response
-        final List<MdnsResponse> cachedList =
-                serviceCache.getCachedServices(serviceType, socketKey);
+        final List<MdnsResponse> cachedList = serviceCache.getCachedServices(cacheKey);
         final List<MdnsResponse> currentList = new ArrayList<>(cachedList);
         List<MdnsResponse> additionalResponses = makeResponsesForResolve(socketKey);
         for (MdnsResponse additionalResponse : additionalResponses) {
@@ -432,7 +452,7 @@
             } else if (findMatchedResponse(cachedList, serviceInstanceName) != null) {
                 // If the response is not modified and already in the cache. The cache will
                 // need to be updated to refresh the last receipt time.
-                serviceCache.addOrUpdateService(serviceType, socketKey, response);
+                serviceCache.addOrUpdateService(cacheKey, response);
             }
         }
         if (dependencies.hasMessages(handler, EVENT_START_QUERYTASK)) {
@@ -458,44 +478,50 @@
         }
     }
 
-    /** Notify all services are removed because the socket is destroyed. */
-    public void notifySocketDestroyed() {
-        ensureRunningOnHandlerThread(handler);
-        for (MdnsResponse response : serviceCache.getCachedServices(serviceType, socketKey)) {
-            final String name = response.getServiceInstanceName();
-            if (name == null) continue;
-            for (int i = 0; i < listeners.size(); i++) {
-                if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
-                final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-                final MdnsServiceInfo serviceInfo =
-                        buildMdnsServiceInfoFromResponse(response, serviceTypeLabels);
+    private void notifyRemovedServiceToListeners(@NonNull MdnsResponse response,
+            @NonNull String message) {
+        for (int i = 0; i < listeners.size(); i++) {
+            if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
+            final MdnsServiceBrowserListener listener = listeners.keyAt(i);
+            if (response.getServiceInstanceName() != null) {
+                final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
+                        response, serviceTypeLabels);
                 if (response.isComplete()) {
-                    sharedLog.log("Socket destroyed. onServiceRemoved: " + name);
+                    sharedLog.log(message + ". onServiceRemoved: " + serviceInfo);
                     listener.onServiceRemoved(serviceInfo);
                 }
-                sharedLog.log("Socket destroyed. onServiceNameRemoved: " + name);
+                sharedLog.log(message + ". onServiceNameRemoved: " + serviceInfo);
                 listener.onServiceNameRemoved(serviceInfo);
             }
         }
-        removeScheduledTask();
-        mdnsQueryScheduler.cancelScheduledRun();
+    }
+
+    /** Notify all services are removed because the socket is destroyed. */
+    public void notifySocketDestroyed() {
+        ensureRunningOnHandlerThread(handler);
+        for (MdnsResponse response : serviceCache.getCachedServices(cacheKey)) {
+            final String name = response.getServiceInstanceName();
+            if (name == null) continue;
+            notifyRemovedServiceToListeners(response, "Socket destroyed");
+        }
+        shutDown();
     }
 
     private void onResponseModified(@NonNull MdnsResponse response) {
         final String serviceInstanceName = response.getServiceInstanceName();
         final MdnsResponse currentResponse =
-                serviceCache.getCachedService(serviceInstanceName, serviceType, socketKey);
+                serviceCache.getCachedService(serviceInstanceName, cacheKey);
 
         boolean newServiceFound = false;
         boolean serviceBecomesComplete = false;
         if (currentResponse == null) {
             newServiceFound = true;
             if (serviceInstanceName != null) {
-                serviceCache.addOrUpdateService(serviceType, socketKey, response);
+                serviceCache.addOrUpdateService(cacheKey, response);
             }
         } else {
             boolean before = currentResponse.isComplete();
-            serviceCache.addOrUpdateService(serviceType, socketKey, response);
+            serviceCache.addOrUpdateService(cacheKey, response);
             boolean after = response.isComplete();
             serviceBecomesComplete = !before && after;
         }
@@ -529,22 +555,11 @@
 
     private void onGoodbyeReceived(@Nullable String serviceInstanceName) {
         final MdnsResponse response =
-                serviceCache.removeService(serviceInstanceName, serviceType, socketKey);
+                serviceCache.removeService(serviceInstanceName, cacheKey);
         if (response == null) {
             return;
         }
-        for (int i = 0; i < listeners.size(); i++) {
-            if (!responseMatchesOptions(response, listeners.valueAt(i))) continue;
-            final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-            final MdnsServiceInfo serviceInfo =
-                    buildMdnsServiceInfoFromResponse(response, serviceTypeLabels);
-            if (response.isComplete()) {
-                sharedLog.log("onServiceRemoved: " + serviceInfo);
-                listener.onServiceRemoved(serviceInfo);
-            }
-            sharedLog.log("onServiceNameRemoved: " + serviceInfo);
-            listener.onServiceNameRemoved(serviceInfo);
-        }
+        notifyRemovedServiceToListeners(response, "Goodbye received");
     }
 
     private boolean shouldRemoveServiceAfterTtlExpires() {
@@ -567,7 +582,7 @@
                 continue;
             }
             MdnsResponse knownResponse =
-                    serviceCache.getCachedService(resolveName, serviceType, socketKey);
+                    serviceCache.getCachedService(resolveName, cacheKey);
             if (knownResponse == null) {
                 final ArrayList<String> instanceFullName = new ArrayList<>(
                         serviceTypeLabels.length + 1);
@@ -585,36 +600,18 @@
     private void tryRemoveServiceAfterTtlExpires() {
         if (!shouldRemoveServiceAfterTtlExpires()) return;
 
-        Iterator<MdnsResponse> iter =
-                serviceCache.getCachedServices(serviceType, socketKey).iterator();
+        final Iterator<MdnsResponse> iter = serviceCache.getCachedServices(cacheKey).iterator();
         while (iter.hasNext()) {
             MdnsResponse existingResponse = iter.next();
-            final String serviceInstanceName = existingResponse.getServiceInstanceName();
             if (existingResponse.hasServiceRecord()
                     && existingResponse.getServiceRecord()
                     .getRemainingTTL(clock.elapsedRealtime()) == 0) {
-                serviceCache.removeService(serviceInstanceName, serviceType, socketKey);
-                for (int i = 0; i < listeners.size(); i++) {
-                    if (!responseMatchesOptions(existingResponse, listeners.valueAt(i))) {
-                        continue;
-                    }
-                    final MdnsServiceBrowserListener listener = listeners.keyAt(i);
-                    if (serviceInstanceName != null) {
-                        final MdnsServiceInfo serviceInfo = buildMdnsServiceInfoFromResponse(
-                                existingResponse, serviceTypeLabels);
-                        if (existingResponse.isComplete()) {
-                            sharedLog.log("TTL expired. onServiceRemoved: " + serviceInfo);
-                            listener.onServiceRemoved(serviceInfo);
-                        }
-                        sharedLog.log("TTL expired. onServiceNameRemoved: " + serviceInfo);
-                        listener.onServiceNameRemoved(serviceInfo);
-                    }
-                }
+                serviceCache.removeService(existingResponse.getServiceInstanceName(), cacheKey);
+                notifyRemovedServiceToListeners(existingResponse, "TTL expired");
             }
         }
     }
 
-
     private static class QuerySentArguments {
         private final int transactionId;
         private final List<String> subTypes = new ArrayList<>();
@@ -672,7 +669,7 @@
 
     private long getMinRemainingTtl(long now) {
         long minRemainingTtl = Long.MAX_VALUE;
-        for (MdnsResponse response : serviceCache.getCachedServices(serviceType, socketKey)) {
+        for (MdnsResponse response : serviceCache.getCachedServices(cacheKey)) {
             if (!response.isComplete()) {
                 continue;
             }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
index 4149dbe..cf6c8ac 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsTextRecord.java
@@ -18,7 +18,8 @@
 
 import android.annotation.Nullable;
 
-import com.android.internal.annotations.VisibleForTesting;
+import androidx.annotation.VisibleForTesting;
+
 import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry;
 
 import java.io.IOException;
@@ -28,7 +29,7 @@
 import java.util.Objects;
 
 /** An mDNS "TXT" record, which contains a list of {@link TextEntry}. */
-@VisibleForTesting
+@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
 public class MdnsTextRecord extends MdnsRecord {
     private List<TextEntry> entries;
 
diff --git a/service-t/src/com/android/server/ethernet/EthernetTracker.java b/service-t/src/com/android/server/ethernet/EthernetTracker.java
index 48e86d8..01b8de7 100644
--- a/service-t/src/com/android/server/ethernet/EthernetTracker.java
+++ b/service-t/src/com/android/server/ethernet/EthernetTracker.java
@@ -48,6 +48,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.NetdUtils;
 import com.android.net.module.util.PermissionUtils;
 import com.android.net.module.util.SharedLog;
@@ -237,7 +238,18 @@
         mDeps = deps;
 
         // Interface match regex.
-        mIfaceMatch = mDeps.getInterfaceRegexFromResource(mContext);
+        String ifaceMatchRegex = mDeps.getInterfaceRegexFromResource(mContext);
+        // "*" is a magic string to indicate "pick the default".
+        if (ifaceMatchRegex.equals("*")) {
+            if (SdkLevel.isAtLeastU()) {
+                // On U+, include both usb%d and eth%d interfaces.
+                ifaceMatchRegex = "(usb|eth)\\d+";
+            } else {
+                // On T, include only eth%d interfaces.
+                ifaceMatchRegex = "eth\\d+";
+            }
+        }
+        mIfaceMatch = ifaceMatchRegex;
 
         // Read default Ethernet interface configuration from resources
         final String[] interfaceConfigs = mDeps.getInterfaceConfigFromResource(context);
diff --git a/service-t/src/com/android/server/net/NetworkStatsEventLogger.java b/service-t/src/com/android/server/net/NetworkStatsEventLogger.java
new file mode 100644
index 0000000..679837a
--- /dev/null
+++ b/service-t/src/com/android/server/net/NetworkStatsEventLogger.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.net;
+
+import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
+
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.util.IndentingPrintWriter;
+import android.util.LocalLog;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Helper class for NetworkStatsService to log events.
+ *
+ * @hide
+ */
+public class NetworkStatsEventLogger {
+    static final int POLL_REASON_DUMPSYS = 0;
+    static final int POLL_REASON_FORCE_UPDATE = 1;
+    static final int POLL_REASON_GLOBAL_ALERT = 2;
+    static final int POLL_REASON_NETWORK_STATUS_CHANGED = 3;
+    static final int POLL_REASON_OPEN_SESSION = 4;
+    static final int POLL_REASON_PERIODIC = 5;
+    static final int POLL_REASON_RAT_CHANGED = 6;
+    static final int POLL_REASON_REG_CALLBACK = 7;
+    static final int POLL_REASON_REMOVE_UIDS = 8;
+    static final int POLL_REASON_UPSTREAM_CHANGED = 9;
+
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(prefix = { "POLL_REASON_" }, value = {
+            POLL_REASON_DUMPSYS,
+            POLL_REASON_FORCE_UPDATE,
+            POLL_REASON_GLOBAL_ALERT,
+            POLL_REASON_NETWORK_STATUS_CHANGED,
+            POLL_REASON_OPEN_SESSION,
+            POLL_REASON_PERIODIC,
+            POLL_REASON_RAT_CHANGED,
+            POLL_REASON_REMOVE_UIDS,
+            POLL_REASON_REG_CALLBACK,
+            POLL_REASON_UPSTREAM_CHANGED
+    })
+    public @interface PollReason {
+    }
+    static final int MAX_POLL_REASON = POLL_REASON_UPSTREAM_CHANGED;
+
+    @VisibleForTesting(visibility = PRIVATE)
+    public static final int MAX_EVENTS_LOGS = 50;
+    private final LocalLog mEventChanges = new LocalLog(MAX_EVENTS_LOGS);
+    private final int[] mPollEventCounts = new int[MAX_POLL_REASON + 1];
+
+    /**
+     * Log a poll event.
+     *
+     * @param flags Flags used when polling. See NetworkStatsService#FLAG_PERSIST_*.
+     * @param event The event of polling to be logged.
+     */
+    public void logPollEvent(int flags, @NonNull PollEvent event) {
+        mEventChanges.log("Poll(flags=" + flags + ", " + event + ")");
+        mPollEventCounts[event.reason]++;
+    }
+
+    /**
+     * Print poll counts per reason into the given stream.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public void dumpPollCountsPerReason(@NonNull IndentingPrintWriter pw) {
+        pw.println("Poll counts per reason:");
+        pw.increaseIndent();
+        for (int i = 0; i <= MAX_POLL_REASON; i++) {
+            pw.println(PollEvent.pollReasonNameOf(i) + ": " + mPollEventCounts[i]);
+        }
+        pw.decreaseIndent();
+        pw.println();
+    }
+
+    /**
+     * Print recent poll events into the given stream.
+     */
+    @VisibleForTesting(visibility = PRIVATE)
+    public void dumpRecentPollEvents(@NonNull IndentingPrintWriter pw) {
+        pw.println("Recent poll events:");
+        pw.increaseIndent();
+        mEventChanges.reverseDump(pw);
+        pw.decreaseIndent();
+        pw.println();
+    }
+
+    /**
+     * Print the object's state into the given stream.
+     */
+    public void dump(@NonNull IndentingPrintWriter pw) {
+        dumpPollCountsPerReason(pw);
+        dumpRecentPollEvents(pw);
+    }
+
+    public static class PollEvent {
+        public final int reason;
+
+        public PollEvent(@PollReason int reason) {
+            if (reason < 0 || reason > MAX_POLL_REASON) {
+                throw new IllegalArgumentException("Unsupported poll reason: " + reason);
+            }
+            this.reason = reason;
+        }
+
+        @Override
+        public String toString() {
+            return "PollEvent{" + "reason=" + pollReasonNameOf(reason) + "}";
+        }
+
+        /**
+         * Get the name of the given reason.
+         *
+         * If the reason does not have a String representation, returns its integer representation.
+         */
+        @NonNull
+        public static String pollReasonNameOf(@PollReason int reason) {
+            switch (reason) {
+                case POLL_REASON_DUMPSYS:                   return "DUMPSYS";
+                case POLL_REASON_FORCE_UPDATE:              return "FORCE_UPDATE";
+                case POLL_REASON_GLOBAL_ALERT:              return "GLOBAL_ALERT";
+                case POLL_REASON_NETWORK_STATUS_CHANGED:    return "NETWORK_STATUS_CHANGED";
+                case POLL_REASON_OPEN_SESSION:              return "OPEN_SESSION";
+                case POLL_REASON_PERIODIC:                  return "PERIODIC";
+                case POLL_REASON_RAT_CHANGED:               return "RAT_CHANGED";
+                case POLL_REASON_REMOVE_UIDS:               return "REMOVE_UIDS";
+                case POLL_REASON_REG_CALLBACK:              return "REG_CALLBACK";
+                case POLL_REASON_UPSTREAM_CHANGED:          return "UPSTREAM_CHANGED";
+                default:                                    return Integer.toString(reason);
+            }
+        }
+    }
+}
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 25e59d5..46afd31 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -66,6 +66,17 @@
 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
 import static com.android.net.module.util.NetworkCapabilitiesUtils.getDisplayTransport;
 import static com.android.net.module.util.NetworkStatsUtils.LIMIT_GLOBAL_ALERT;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_PERIODIC;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_DUMPSYS;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_FORCE_UPDATE;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_GLOBAL_ALERT;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_NETWORK_STATUS_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_OPEN_SESSION;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REG_CALLBACK;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REMOVE_UIDS;
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_UPSTREAM_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.PollEvent;
 
 import android.annotation.NonNull;
 import android.annotation.Nullable;
@@ -281,6 +292,8 @@
     static final String NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME = "import.attempts";
     static final String NETSTATS_IMPORT_SUCCESSES_COUNTER_NAME = "import.successes";
     static final String NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME = "import.fallbacks";
+    static final String CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER =
+            "enable_network_stats_event_logger";
 
     private final Context mContext;
     private final NetworkStatsFactory mStatsFactory;
@@ -441,6 +454,7 @@
      * Map from key {@code OpenSessionKey} to count of opened sessions. This is for recording
      * the caller of open session and it is only for debugging.
      */
+    // TODO: Move to NetworkStatsEventLogger to centralize event logging.
     @GuardedBy("mOpenSessionCallsLock")
     private final HashMap<OpenSessionKey, Integer> mOpenSessionCallsPerCaller = new HashMap<>();
 
@@ -513,19 +527,21 @@
         public void handleMessage(Message msg) {
             switch (msg.what) {
                 case MSG_PERFORM_POLL: {
-                    performPoll(FLAG_PERSIST_ALL);
+                    performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent((int) msg.obj));
                     break;
                 }
                 case MSG_NOTIFY_NETWORK_STATUS: {
-                    // If no cached states, ignore.
-                    if (mLastNetworkStateSnapshots == null) break;
-                    // TODO (b/181642673): Protect mDefaultNetworks from concurrent accessing.
-                    handleNotifyNetworkStatus(
-                            mDefaultNetworks, mLastNetworkStateSnapshots, mActiveIface);
+                    synchronized (mStatsLock) {
+                        // If no cached states, ignore.
+                        if (mLastNetworkStateSnapshots == null) break;
+                        handleNotifyNetworkStatus(mDefaultNetworks, mLastNetworkStateSnapshots,
+                                mActiveIface, maybeCreatePollEvent((int) msg.obj));
+                    }
                     break;
                 }
                 case MSG_PERFORM_POLL_REGISTER_ALERT: {
-                    performPoll(FLAG_PERSIST_NETWORK);
+                    performPoll(FLAG_PERSIST_NETWORK,
+                            maybeCreatePollEvent(POLL_REASON_GLOBAL_ALERT));
                     registerGlobalAlert();
                     break;
                 }
@@ -611,6 +627,13 @@
         mStatsMapB = mDeps.getStatsMapB();
         mAppUidStatsMap = mDeps.getAppUidStatsMap();
         mIfaceStatsMap = mDeps.getIfaceStatsMap();
+        // To prevent any possible races, the flag is not allowed to change until rebooting.
+        mSupportEventLogger = mDeps.supportEventLogger(mContext);
+        if (mSupportEventLogger) {
+            mEventLogger = new NetworkStatsEventLogger();
+        } else {
+            mEventLogger = null;
+        }
 
         // TODO: Remove bpfNetMaps creation and always start SkDestroyListener
         // Following code is for the experiment to verify the SkDestroyListener refactoring. Based
@@ -839,6 +862,14 @@
                 IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
             return new SkDestroyListener(cookieTagMap, handler, new SharedLog(TAG));
         }
+
+        /**
+         * Get whether event logger feature is supported.
+         */
+        public boolean supportEventLogger(Context ctx) {
+            return DeviceConfigUtils.isTetheringFeatureNotChickenedOut(
+                    ctx, CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER);
+        }
     }
 
     /**
@@ -1431,7 +1462,7 @@
                 | NetworkStatsManager.FLAG_POLL_FORCE)) != 0) {
             final long ident = Binder.clearCallingIdentity();
             try {
-                performPoll(FLAG_PERSIST_ALL);
+                performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_OPEN_SESSION));
             } finally {
                 Binder.restoreCallingIdentity(ident);
             }
@@ -1827,7 +1858,8 @@
 
         final long token = Binder.clearCallingIdentity();
         try {
-            handleNotifyNetworkStatus(defaultNetworks, networkStates, activeIface);
+            handleNotifyNetworkStatus(defaultNetworks, networkStates, activeIface,
+                    maybeCreatePollEvent(POLL_REASON_NETWORK_STATUS_CHANGED));
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -1844,7 +1876,8 @@
 
         final long token = Binder.clearCallingIdentity();
         try {
-            performPoll(FLAG_PERSIST_ALL);
+            // TODO: Log callstack for system server callers.
+            performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_FORCE_UPDATE));
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -1901,7 +1934,8 @@
         }
 
         // Create baseline stats
-        mHandler.sendMessage(mHandler.obtainMessage(MSG_PERFORM_POLL));
+        mHandler.sendMessage(mHandler.obtainMessage(MSG_PERFORM_POLL,
+                POLL_REASON_REG_CALLBACK));
 
         return normalizedRequest;
    }
@@ -1998,7 +2032,8 @@
             new TetheringManager.TetheringEventCallback() {
                 @Override
                 public void onUpstreamChanged(@Nullable Network network) {
-                    performPoll(FLAG_PERSIST_NETWORK);
+                    performPoll(FLAG_PERSIST_NETWORK,
+                            maybeCreatePollEvent(POLL_REASON_UPSTREAM_CHANGED));
                 }
             };
 
@@ -2007,7 +2042,7 @@
         public void onReceive(Context context, Intent intent) {
             // on background handler thread, and verified UPDATE_DEVICE_STATS
             // permission above.
-            performPoll(FLAG_PERSIST_ALL);
+            performPoll(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_PERIODIC));
 
             // verify that we're watching global alert
             registerGlobalAlert();
@@ -2071,19 +2106,20 @@
     public void handleOnCollapsedRatTypeChanged() {
         // Protect service from frequently updating. Remove pending messages if any.
         mHandler.removeMessages(MSG_NOTIFY_NETWORK_STATUS);
-        mHandler.sendMessageDelayed(
-                mHandler.obtainMessage(MSG_NOTIFY_NETWORK_STATUS), mSettings.getPollDelay());
+        mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_NOTIFY_NETWORK_STATUS,
+                        POLL_REASON_RAT_CHANGED), mSettings.getPollDelay());
     }
 
     private void handleNotifyNetworkStatus(
             Network[] defaultNetworks,
             NetworkStateSnapshot[] snapshots,
-            String activeIface) {
+            String activeIface,
+            @Nullable PollEvent event) {
         synchronized (mStatsLock) {
             mWakeLock.acquire();
             try {
                 mActiveIface = activeIface;
-                handleNotifyNetworkStatusLocked(defaultNetworks, snapshots);
+                handleNotifyNetworkStatusLocked(defaultNetworks, snapshots, event);
             } finally {
                 mWakeLock.release();
             }
@@ -2097,7 +2133,7 @@
      */
     @GuardedBy("mStatsLock")
     private void handleNotifyNetworkStatusLocked(@NonNull Network[] defaultNetworks,
-            @NonNull NetworkStateSnapshot[] snapshots) {
+            @NonNull NetworkStateSnapshot[] snapshots, @Nullable PollEvent event) {
         if (!mSystemReady) return;
         if (LOGV) Log.v(TAG, "handleNotifyNetworkStatusLocked()");
 
@@ -2107,7 +2143,7 @@
 
         // poll, but only persist network stats to keep codepath fast. UID stats
         // will be persisted during next alarm poll event.
-        performPollLocked(FLAG_PERSIST_NETWORK);
+        performPollLocked(FLAG_PERSIST_NETWORK, event);
 
         // Rebuild active interfaces based on connected networks
         mActiveIfaces.clear();
@@ -2324,12 +2360,12 @@
         }
     }
 
-    private void performPoll(int flags) {
+    private void performPoll(int flags, @Nullable PollEvent event) {
         synchronized (mStatsLock) {
             mWakeLock.acquire();
 
             try {
-                performPollLocked(flags);
+                performPollLocked(flags, event);
             } finally {
                 mWakeLock.release();
             }
@@ -2341,11 +2377,15 @@
      * {@link NetworkStatsHistory}.
      */
     @GuardedBy("mStatsLock")
-    private void performPollLocked(int flags) {
+    private void performPollLocked(int flags, @Nullable PollEvent event) {
         if (!mSystemReady) return;
         if (LOGV) Log.v(TAG, "performPollLocked(flags=0x" + Integer.toHexString(flags) + ")");
         Trace.traceBegin(TRACE_TAG_NETWORK, "performPollLocked");
 
+        if (mSupportEventLogger) {
+            mEventLogger.logPollEvent(flags, event);
+        }
+
         final boolean persistNetwork = (flags & FLAG_PERSIST_NETWORK) != 0;
         final boolean persistUid = (flags & FLAG_PERSIST_UID) != 0;
         final boolean persistForce = (flags & FLAG_PERSIST_FORCE) != 0;
@@ -2545,7 +2585,7 @@
         if (LOGV) Log.v(TAG, "removeUidsLocked() for UIDs " + Arrays.toString(uids));
 
         // Perform one last poll before removing
-        performPollLocked(FLAG_PERSIST_ALL);
+        performPollLocked(FLAG_PERSIST_ALL, maybeCreatePollEvent(POLL_REASON_REMOVE_UIDS));
 
         mUidRecorder.removeUidsLocked(uids);
         mUidTagRecorder.removeUidsLocked(uids);
@@ -2628,7 +2668,8 @@
             }
 
             if (poll) {
-                performPollLocked(FLAG_PERSIST_ALL | FLAG_PERSIST_FORCE);
+                performPollLocked(FLAG_PERSIST_ALL | FLAG_PERSIST_FORCE,
+                        maybeCreatePollEvent(POLL_REASON_DUMPSYS));
                 pw.println("Forced poll");
                 return;
             }
@@ -2688,6 +2729,7 @@
                     pw.println("(failed to dump platform legacy stats import counters)");
                 }
             }
+            pw.println(CONFIG_ENABLE_NETWORK_STATS_EVENT_LOGGER + ": " + mSupportEventLogger);
 
             pw.decreaseIndent();
 
@@ -2745,6 +2787,10 @@
             pw.decreaseIndent();
             pw.println();
 
+            if (mSupportEventLogger) {
+                mEventLogger.dump(pw);
+            }
+
             pw.println("Stats Providers:");
             pw.increaseIndent();
             invokeForAllStatsProviderCallbacks((cb) -> {
@@ -3214,6 +3260,22 @@
         }
     }
 
+    private final boolean mSupportEventLogger;
+    @GuardedBy("mStatsLock")
+    @Nullable
+    private final NetworkStatsEventLogger mEventLogger;
+
+    /**
+     * Create a PollEvent instance if the feature is enabled.
+     */
+    @Nullable
+    public PollEvent maybeCreatePollEvent(@NetworkStatsEventLogger.PollReason int reason) {
+        if (mSupportEventLogger) {
+            return new PollEvent(reason);
+        }
+        return null;
+    }
+
     private class DropBoxNonMonotonicObserver implements NonMonotonicObserver<String> {
         @Override
         public void foundNonMonotonic(NetworkStats left, int leftIndex, NetworkStats right,
diff --git a/service/Android.bp b/service/Android.bp
index 8e59e86..250693f 100644
--- a/service/Android.bp
+++ b/service/Android.bp
@@ -188,7 +188,6 @@
         "dnsresolver_aidl_interface-V11-java",
         "modules-utils-shell-command-handler",
         "net-utils-device-common",
-        "net-utils-device-common-bpf",
         "net-utils-device-common-ip",
         "net-utils-device-common-netlink",
         "net-utils-services-common",
diff --git a/service/ServiceConnectivityResources/res/values/config.xml b/service/ServiceConnectivityResources/res/values/config.xml
index f30abc6..045d707f 100644
--- a/service/ServiceConnectivityResources/res/values/config.xml
+++ b/service/ServiceConnectivityResources/res/values/config.xml
@@ -194,8 +194,11 @@
         -->
     </string-array>
 
-    <!-- Regex of wired ethernet ifaces -->
-    <string translatable="false" name="config_ethernet_iface_regex">eth\\d</string>
+    <!-- Regex of wired ethernet ifaces. Network interfaces that match this regex will be tracked
+         by ethernet service.
+         If set to "*", ethernet service uses "(eth|usb)\\d+" on Android U+ and eth\\d+ on
+         Android T. -->
+    <string translatable="false" name="config_ethernet_iface_regex">*</string>
 
     <!-- Ignores Wi-Fi validation failures after roam.
     If validation fails on a Wi-Fi network after a roam to a new BSSID,
diff --git a/service/src/com/android/server/BpfNetMaps.java b/service/src/com/android/server/BpfNetMaps.java
index 6a34a24..671c4ac 100644
--- a/service/src/com/android/server/BpfNetMaps.java
+++ b/service/src/com/android/server/BpfNetMaps.java
@@ -26,6 +26,7 @@
 import static android.net.BpfNetMapsConstants.UID_OWNER_MAP_PATH;
 import static android.net.BpfNetMapsConstants.UID_PERMISSION_MAP_PATH;
 import static android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY;
+import static android.net.BpfNetMapsUtils.PRE_T;
 import static android.net.BpfNetMapsUtils.getMatchByFirewallChain;
 import static android.net.BpfNetMapsUtils.matchToString;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_DOZABLE;
@@ -51,7 +52,9 @@
 
 import android.app.StatsManager;
 import android.content.Context;
+import android.net.BpfNetMapsReader;
 import android.net.INetd;
+import android.net.UidOwnerValue;
 import android.os.Build;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -92,7 +95,6 @@
  * {@hide}
  */
 public class BpfNetMaps {
-    private static final boolean PRE_T = !SdkLevel.isAtLeastT();
     static {
         if (!PRE_T) {
             System.loadLibrary("service-connectivity");
@@ -298,6 +300,7 @@
     }
 
     /** Constructor used after T that doesn't need to use netd anymore. */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public BpfNetMaps(final Context context) {
         this(context, null);
 
@@ -420,6 +423,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void addNaughtyApp(final int uid) {
         throwIfPreT("addNaughtyApp is not available on pre-T devices");
 
@@ -438,6 +442,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void removeNaughtyApp(final int uid) {
         throwIfPreT("removeNaughtyApp is not available on pre-T devices");
 
@@ -456,6 +461,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void addNiceApp(final int uid) {
         throwIfPreT("addNiceApp is not available on pre-T devices");
 
@@ -474,6 +480,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void removeNiceApp(final int uid) {
         throwIfPreT("removeNiceApp is not available on pre-T devices");
 
@@ -494,6 +501,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setChildChain(final int childChain, final boolean enable) {
         throwIfPreT("setChildChain is not available on pre-T devices");
 
@@ -523,18 +531,14 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
+     *
+     * @deprecated Use {@link BpfNetMapsReader#isChainEnabled} instead.
      */
+    // TODO: Migrate the callers to use {@link BpfNetMapsReader#isChainEnabled} instead.
+    @Deprecated
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public boolean isChainEnabled(final int childChain) {
-        throwIfPreT("isChainEnabled is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(childChain);
-        try {
-            final U32 config = sConfigurationMap.getValue(UID_RULES_CONFIGURATION_KEY);
-            return (config.val & match) != 0;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno,
-                    "Unable to get firewall chain status: " + Os.strerror(e.errno));
-        }
+        return BpfNetMapsReader.isChainEnabled(sConfigurationMap, childChain);
     }
 
     private Set<Integer> asSet(final int[] uids) {
@@ -554,6 +558,7 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws IllegalArgumentException if {@code chain} is not a valid chain.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void replaceUidChain(final int chain, final int[] uids) {
         throwIfPreT("replaceUidChain is not available on pre-T devices");
 
@@ -638,6 +643,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setUidRule(final int childChain, final int uid, final int firewallRule) {
         throwIfPreT("setUidRule is not available on pre-T devices");
 
@@ -667,20 +673,12 @@
      * @throws UnsupportedOperationException if called on pre-T devices.
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
+     *
+     * @deprecated use {@link BpfNetMapsReader#getUidRule} instead.
      */
+    // TODO: Migrate the callers to use {@link BpfNetMapsReader#getUidRule} instead.
     public int getUidRule(final int childChain, final int uid) {
-        throwIfPreT("isUidChainEnabled is not available on pre-T devices");
-
-        final long match = getMatchByFirewallChain(childChain);
-        final boolean isAllowList = isFirewallAllowList(childChain);
-        try {
-            final UidOwnerValue uidMatch = sUidOwnerMap.getValue(new S32(uid));
-            final boolean isMatchEnabled = uidMatch != null && (uidMatch.rule & match) != 0;
-            return isMatchEnabled == isAllowList ? FIREWALL_RULE_ALLOW : FIREWALL_RULE_DENY;
-        } catch (ErrnoException e) {
-            throw new ServiceSpecificException(e.errno,
-                    "Unable to get uid rule status: " + Os.strerror(e.errno));
-        }
+        return BpfNetMapsReader.getUidRule(sUidOwnerMap, childChain, uid);
     }
 
     private Set<Integer> getUidsMatchEnabled(final int childChain) throws ErrnoException {
@@ -830,6 +828,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void updateUidLockdownRule(final int uid, final boolean add) {
         throwIfPreT("updateUidLockdownRule is not available on pre-T devices");
 
@@ -852,6 +851,7 @@
      * @throws ServiceSpecificException in case of failure, with an error code indicating the
      *                                  cause of the failure.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void swapActiveStatsMap() {
         throwIfPreT("swapActiveStatsMap is not available on pre-T devices");
 
@@ -927,6 +927,7 @@
     }
 
     /** Register callback for statsd to pull atom. */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void setPullAtomCallback(final Context context) {
         throwIfPreT("setPullAtomCallback is not available on pre-T devices");
 
@@ -1016,6 +1017,7 @@
      * @throws IOException when file descriptor is invalid.
      * @throws ServiceSpecificException when the method is called on an unsupported device.
      */
+    @RequiresApi(Build.VERSION_CODES.TIRAMISU)
     public void dump(final IndentingPrintWriter pw, final FileDescriptor fd, boolean verbose)
             throws IOException, ServiceSpecificException {
         if (PRE_T) {
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index ea4d82a..c4cb4c7 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -19,6 +19,7 @@
 import static android.Manifest.permission.RECEIVE_DATA_ACTIVITY_CHANGE;
 import static android.app.ActivityManager.UidFrozenStateChangedCallback.UID_FROZEN_STATE_FROZEN;
 import static android.content.pm.PackageManager.FEATURE_BLUETOOTH;
+import static android.content.pm.PackageManager.FEATURE_LEANBACK;
 import static android.content.pm.PackageManager.FEATURE_WATCH;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
 import static android.content.pm.PackageManager.FEATURE_WIFI_DIRECT;
@@ -67,6 +68,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_ENTERPRISE;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOREGROUND;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -98,6 +100,11 @@
 import static android.system.OsConstants.IPPROTO_TCP;
 import static android.system.OsConstants.IPPROTO_UDP;
 
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET4_BIND;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET6_BIND;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_EGRESS;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_INGRESS;
+import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET_SOCK_CREATE;
 import static com.android.net.module.util.NetworkMonitorUtils.isPrivateDnsValidationRequired;
 import static com.android.net.module.util.PermissionUtils.checkAnyPermissionOf;
 import static com.android.net.module.util.PermissionUtils.enforceAnyPermissionOf;
@@ -159,6 +166,7 @@
 import android.net.IpMemoryStore;
 import android.net.IpPrefix;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.MatchAllNetworkSpecifier;
 import android.net.NativeNetworkConfig;
 import android.net.NativeNetworkType;
@@ -191,7 +199,6 @@
 import android.net.QosSocketFilter;
 import android.net.QosSocketInfo;
 import android.net.RouteInfo;
-import android.net.RouteInfoParcel;
 import android.net.SocketKeepalive;
 import android.net.TetheringManager;
 import android.net.TransportInfo;
@@ -277,6 +284,7 @@
 import com.android.net.module.util.BaseNetdUnsolicitedEventListener;
 import com.android.net.module.util.BinderUtils;
 import com.android.net.module.util.BitUtils;
+import com.android.net.module.util.BpfUtils;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.DeviceConfigUtils;
 import com.android.net.module.util.InterfaceParams;
@@ -303,6 +311,7 @@
 import com.android.server.connectivity.DnsManager.PrivateDnsValidationUpdate;
 import com.android.server.connectivity.DscpPolicyTracker;
 import com.android.server.connectivity.FullScore;
+import com.android.server.connectivity.HandlerUtils;
 import com.android.server.connectivity.InvalidTagException;
 import com.android.server.connectivity.KeepaliveResourceUtil;
 import com.android.server.connectivity.KeepaliveTracker;
@@ -320,6 +329,7 @@
 import com.android.server.connectivity.ProfileNetworkPreferenceInfo;
 import com.android.server.connectivity.ProxyTracker;
 import com.android.server.connectivity.QosCallbackTracker;
+import com.android.server.connectivity.RoutingCoordinatorService;
 import com.android.server.connectivity.UidRangeUtils;
 import com.android.server.connectivity.VpnNetworkPreferenceInfo;
 import com.android.server.connectivity.wear.CompanionDeviceManagerProxyService;
@@ -483,6 +493,7 @@
     @GuardedBy("mTNSLock")
     private TestNetworkService mTNS;
     private final CompanionDeviceManagerProxyService mCdmps;
+    private final RoutingCoordinatorService mRoutingCoordinatorService;
 
     private final Object mTNSLock = new Object();
 
@@ -1255,16 +1266,24 @@
         private static final String PRIORITY_ARG = "--dump-priority";
         private static final String PRIORITY_ARG_HIGH = "HIGH";
         private static final String PRIORITY_ARG_NORMAL = "NORMAL";
+        private static final int DUMPSYS_DEFAULT_TIMEOUT_MS = 10_000;
 
         LocalPriorityDump() {}
 
         private void dumpHigh(FileDescriptor fd, PrintWriter pw) {
-            doDump(fd, pw, new String[] {DIAG_ARG});
-            doDump(fd, pw, new String[] {SHORT_ARG});
+            if (!HandlerUtils.runWithScissors(mHandler, () -> {
+                doDump(fd, pw, new String[]{DIAG_ARG});
+                doDump(fd, pw, new String[]{SHORT_ARG});
+            }, DUMPSYS_DEFAULT_TIMEOUT_MS)) {
+                pw.println("dumpHigh timeout");
+            }
         }
 
         private void dumpNormal(FileDescriptor fd, PrintWriter pw, String[] args) {
-            doDump(fd, pw, args);
+            if (!HandlerUtils.runWithScissors(mHandler, () -> doDump(fd, pw, args),
+                    DUMPSYS_DEFAULT_TIMEOUT_MS)) {
+                pw.println("dumpNormal timeout");
+            }
         }
 
         public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
@@ -1517,6 +1536,14 @@
         }
 
         /**
+         * Get BPF program Id from CGROUP. See {@link BpfUtils#getProgramId}.
+         */
+        public int getBpfProgramId(final int attachType)
+                throws IOException {
+            return BpfUtils.getProgramId(attachType);
+        }
+
+        /**
          * Wraps {@link BroadcastOptionsShimImpl#newInstance(BroadcastOptions)}
          */
         // TODO: when available in all active branches:
@@ -1773,7 +1800,7 @@
         mNoServiceNetwork = new NetworkAgentInfo(null,
                 new Network(INetd.UNREACHABLE_NET_ID),
                 new NetworkInfo(TYPE_NONE, 0, "", ""),
-                new LinkProperties(), new NetworkCapabilities(),
+                new LinkProperties(), new NetworkCapabilities(), null /* localNetworkConfig */,
                 new NetworkScore.Builder().setLegacyInt(0).build(), mContext, null,
                 new NetworkAgentConfig(), this, null, null, 0, INVALID_UID,
                 mLingerDelayMs, mQosCallbackTracker, mDeps);
@@ -1800,6 +1827,8 @@
             mCdmps = null;
         }
 
+        mRoutingCoordinatorService = new RoutingCoordinatorService(netd);
+
         mDestroyFrozenSockets = mDeps.isAtLeastU()
                 && mDeps.isFeatureEnabled(context, KEY_DESTROY_FROZEN_SOCKETS_VERSION);
         mDelayDestroyFrozenSockets = mDeps.isAtLeastU()
@@ -3240,6 +3269,26 @@
         pw.decreaseIndent();
     }
 
+    private void dumpBpfProgramStatus(IndentingPrintWriter pw) {
+        pw.println("Bpf Program Status:");
+        pw.increaseIndent();
+        try {
+            pw.print("CGROUP_INET_INGRESS: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_INGRESS));
+            pw.print("CGROUP_INET_EGRESS: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_EGRESS));
+            pw.print("CGROUP_INET_SOCK_CREATE: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET_SOCK_CREATE));
+            pw.print("CGROUP_INET4_BIND: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET4_BIND));
+            pw.print("CGROUP_INET6_BIND: ");
+            pw.println(mDeps.getBpfProgramId(BPF_CGROUP_INET6_BIND));
+        } catch (IOException e) {
+            pw.println("  IOException");
+        }
+        pw.decreaseIndent();
+    }
+
     @VisibleForTesting
     static final String KEY_DESTROY_FROZEN_SOCKETS_VERSION = "destroy_frozen_sockets_version";
     @VisibleForTesting
@@ -3854,6 +3903,9 @@
         dumpCloseFrozenAppSockets(pw);
 
         pw.println();
+        dumpBpfProgramStatus(pw);
+
+        pw.println();
 
         if (!CollectionUtils.contains(args, SHORT_ARG)) {
             pw.println();
@@ -4121,6 +4173,11 @@
                     updateNetworkInfo(nai, info);
                     break;
                 }
+                case NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED: {
+                    final LocalNetworkConfig config = (LocalNetworkConfig) arg.second;
+                    updateLocalNetworkConfig(nai, config);
+                    break;
+                }
                 case NetworkAgent.EVENT_NETWORK_SCORE_CHANGED: {
                     updateNetworkScore(nai, (NetworkScore) arg.second);
                     break;
@@ -5006,7 +5063,10 @@
                         !nai.networkAgentConfig.allowBypass /* secure */,
                         getVpnType(nai), nai.networkAgentConfig.excludeLocalRouteVpn);
             } else {
-                config = new NativeNetworkConfig(nai.network.getNetId(), NativeNetworkType.PHYSICAL,
+                final boolean hasLocalCap =
+                        nai.networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK);
+                config = new NativeNetworkConfig(nai.network.getNetId(),
+                        hasLocalCap ? NativeNetworkType.PHYSICAL_LOCAL : NativeNetworkType.PHYSICAL,
                         getNetworkPermission(nai.networkCapabilities),
                         false /* secure */,
                         VpnManager.TYPE_VPN_NONE,
@@ -8034,6 +8094,7 @@
                 }
             }
         }
+        if (!highestPriorityNri.isBeingSatisfied()) return null;
         return highestPriorityNri.getSatisfier();
     }
 
@@ -8056,6 +8117,18 @@
     }
 
     /**
+     * Returns whether local agents are supported on this device.
+     *
+     * Local agents are supported from U on TVs, and from V on all devices.
+     */
+    @VisibleForTesting
+    public boolean areLocalAgentsSupported() {
+        final PackageManager pm = mContext.getPackageManager();
+        // Local agents are supported starting on U on TVs and on V on everything else.
+        return mDeps.isAtLeastV() || (mDeps.isAtLeastU() && pm.hasSystemFeature(FEATURE_LEANBACK));
+    }
+
+    /**
      * Register a new agent with ConnectivityService to handle a network.
      *
      * @param na a reference for ConnectivityService to contact the agent asynchronously.
@@ -8066,13 +8139,18 @@
      * @param networkCapabilities the initial capabilites of this network. They can be updated
      *         later : see {@link #updateCapabilities}.
      * @param initialScore the initial score of the network. See {@link NetworkAgentInfo#getScore}.
+     * @param localNetworkConfig config about this local network, or null if not a local network
      * @param networkAgentConfig metadata about the network. This is never updated.
      * @param providerId the ID of the provider owning this NetworkAgent.
      * @return the network created for this agent.
      */
-    public Network registerNetworkAgent(INetworkAgent na, NetworkInfo networkInfo,
-            LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            @NonNull NetworkScore initialScore, NetworkAgentConfig networkAgentConfig,
+    public Network registerNetworkAgent(INetworkAgent na,
+            NetworkInfo networkInfo,
+            LinkProperties linkProperties,
+            NetworkCapabilities networkCapabilities,
+            @NonNull NetworkScore initialScore,
+            @Nullable LocalNetworkConfig localNetworkConfig,
+            NetworkAgentConfig networkAgentConfig,
             int providerId) {
         Objects.requireNonNull(networkInfo, "networkInfo must not be null");
         Objects.requireNonNull(linkProperties, "linkProperties must not be null");
@@ -8084,12 +8162,26 @@
         } else {
             enforceNetworkFactoryPermission();
         }
+        final boolean hasLocalCap =
+                networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        if (hasLocalCap && !areLocalAgentsSupported()) {
+            // Before U, netd doesn't support PHYSICAL_LOCAL networks so this can't work.
+            throw new IllegalArgumentException("Local agents are not supported in this version");
+        }
+        final boolean hasLocalNetworkConfig = null != localNetworkConfig;
+        if (hasLocalCap != hasLocalNetworkConfig) {
+            throw new IllegalArgumentException(null != localNetworkConfig
+                    ? "Only local network agents can have a LocalNetworkConfig"
+                    : "Local network agents must have a LocalNetworkConfig"
+            );
+        }
 
         final int uid = mDeps.getCallingUid();
         final long token = Binder.clearCallingIdentity();
         try {
             return registerNetworkAgentInternal(na, networkInfo, linkProperties,
-                    networkCapabilities, initialScore, networkAgentConfig, providerId, uid);
+                    networkCapabilities, initialScore, networkAgentConfig, localNetworkConfig,
+                    providerId, uid);
         } finally {
             Binder.restoreCallingIdentity(token);
         }
@@ -8097,7 +8189,8 @@
 
     private Network registerNetworkAgentInternal(INetworkAgent na, NetworkInfo networkInfo,
             LinkProperties linkProperties, NetworkCapabilities networkCapabilities,
-            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig, int providerId,
+            NetworkScore currentScore, NetworkAgentConfig networkAgentConfig,
+            @Nullable LocalNetworkConfig localNetworkConfig, int providerId,
             int uid) {
 
         // Make a copy of the passed NI, LP, NC as the caller may hold a reference to them
@@ -8105,6 +8198,7 @@
         final NetworkInfo niCopy = new NetworkInfo(networkInfo);
         final NetworkCapabilities ncCopy = new NetworkCapabilities(networkCapabilities);
         final LinkProperties lpCopy = new LinkProperties(linkProperties);
+        // No need to copy |localNetworkConfiguration| as it is immutable.
 
         // At this point the capabilities/properties are untrusted and unverified, e.g. checks that
         // the capabilities' access UIDs comply with security limitations. They will be sanitized
@@ -8112,9 +8206,9 @@
         // because some of the checks must happen on the handler thread.
         final NetworkAgentInfo nai = new NetworkAgentInfo(na,
                 new Network(mNetIdManager.reserveNetId()), niCopy, lpCopy, ncCopy,
-                currentScore, mContext, mTrackerHandler, new NetworkAgentConfig(networkAgentConfig),
-                this, mNetd, mDnsResolver, providerId, uid, mLingerDelayMs,
-                mQosCallbackTracker, mDeps);
+                localNetworkConfig, currentScore, mContext, mTrackerHandler,
+                new NetworkAgentConfig(networkAgentConfig), this, mNetd, mDnsResolver, providerId,
+                uid, mLingerDelayMs, mQosCallbackTracker, mDeps);
 
         final String extraInfo = niCopy.getExtraInfo();
         final String name = TextUtils.isEmpty(extraInfo)
@@ -8424,7 +8518,7 @@
             for (final String iface : interfaceDiff.added) {
                 try {
                     if (DBG) log("Adding iface " + iface + " to network " + netId);
-                    mNetd.networkAddInterface(netId, iface);
+                    mRoutingCoordinatorService.addInterfaceToNetwork(netId, iface);
                     wakeupModifyInterface(iface, nai, true);
                     mDeps.reportNetworkInterfaceForTransports(mContext, iface,
                             nai.networkCapabilities.getTransportTypes());
@@ -8437,45 +8531,13 @@
             try {
                 if (DBG) log("Removing iface " + iface + " from network " + netId);
                 wakeupModifyInterface(iface, nai, false);
-                mNetd.networkRemoveInterface(netId, iface);
+                mRoutingCoordinatorService.removeInterfaceFromNetwork(netId, iface);
             } catch (Exception e) {
                 loge("Exception removing interface: " + e);
             }
         }
     }
 
-    // TODO: move to frameworks/libs/net.
-    private RouteInfoParcel convertRouteInfo(RouteInfo route) {
-        final String nextHop;
-
-        switch (route.getType()) {
-            case RouteInfo.RTN_UNICAST:
-                if (route.hasGateway()) {
-                    nextHop = route.getGateway().getHostAddress();
-                } else {
-                    nextHop = INetd.NEXTHOP_NONE;
-                }
-                break;
-            case RouteInfo.RTN_UNREACHABLE:
-                nextHop = INetd.NEXTHOP_UNREACHABLE;
-                break;
-            case RouteInfo.RTN_THROW:
-                nextHop = INetd.NEXTHOP_THROW;
-                break;
-            default:
-                nextHop = INetd.NEXTHOP_NONE;
-                break;
-        }
-
-        final RouteInfoParcel rip = new RouteInfoParcel();
-        rip.ifName = route.getInterface();
-        rip.destination = route.getDestination().toString();
-        rip.nextHop = nextHop;
-        rip.mtu = route.getMtu();
-
-        return rip;
-    }
-
     /**
      * Have netd update routes from oldLp to newLp.
      * @return true if routes changed between oldLp and newLp
@@ -8496,10 +8558,10 @@
             if (route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getDestination().getAddress() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for non-gateway: " + e);
+                    loge("Exception in addRoute for non-gateway: " + e);
                 }
             }
         }
@@ -8507,10 +8569,10 @@
             if (!route.hasGateway()) continue;
             if (VDBG || DDBG) log("Adding Route [" + route + "] to network " + netId);
             try {
-                mNetd.networkAddRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.addRoute(netId, route);
             } catch (Exception e) {
                 if ((route.getGateway() instanceof Inet4Address) || VDBG) {
-                    loge("Exception in networkAddRouteParcel for gateway: " + e);
+                    loge("Exception in addRoute for gateway: " + e);
                 }
             }
         }
@@ -8518,18 +8580,18 @@
         for (RouteInfo route : routeDiff.removed) {
             if (VDBG || DDBG) log("Removing Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkRemoveRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.removeRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkRemoveRouteParcel: " + e);
+                loge("Exception in removeRoute: " + e);
             }
         }
 
         for (RouteInfo route : routeDiff.updated) {
             if (VDBG || DDBG) log("Updating Route [" + route + "] from network " + netId);
             try {
-                mNetd.networkUpdateRouteParcel(netId, convertRouteInfo(route));
+                mRoutingCoordinatorService.updateRoute(netId, route);
             } catch (Exception e) {
-                loge("Exception in networkUpdateRouteParcel: " + e);
+                loge("Exception in updateRoute: " + e);
             }
         }
         return !routeDiff.added.isEmpty() || !routeDiff.removed.isEmpty()
@@ -8849,6 +8911,16 @@
         updateCapabilities(nai.getScore(), nai, nai.networkCapabilities);
     }
 
+    private void updateLocalNetworkConfig(@NonNull final NetworkAgentInfo nai,
+            @NonNull final LocalNetworkConfig config) {
+        if (!nai.networkCapabilities.hasCapability(NET_CAPABILITY_LOCAL_NETWORK)) {
+            Log.wtf(TAG, "Ignoring update of a local network info on non-local network " + nai);
+            return;
+        }
+        // TODO : actually apply the diff.
+        nai.localNetworkConfig = config;
+    }
+
     /**
      * Returns the interface which requires VPN isolation (ingress interface filtering).
      *
@@ -9189,7 +9261,7 @@
             // are Type.LISTEN, but should not have NetworkCallbacks invoked.
             return;
         }
-        Bundle bundle = new Bundle();
+        final Bundle bundle = new Bundle();
         // TODO b/177608132: make sure callbacks are indexed by NRIs and not NetworkRequest objects.
         // TODO: check if defensive copies of data is needed.
         final NetworkRequest nrForCallback = nri.getNetworkRequestForCallback();
@@ -10160,7 +10232,7 @@
             // If a rate limit has been configured and is applicable to this network (network
             // provides internet connectivity), apply it. The tc police filter cannot be attached
             // before the clsact qdisc is added which happens as part of updateLinkProperties ->
-            // updateInterfaces -> INetd#networkAddInterface.
+            // updateInterfaces -> RoutingCoordinatorManager#addInterfaceToNetwork
             // Note: in case of a system server crash, the NetworkController constructor in netd
             // (called when netd starts up) deletes the clsact qdisc of all interfaces.
             if (canNetworkBeRateLimited(networkAgent) && mIngressRateLimit >= 0) {
@@ -10737,6 +10809,17 @@
                         Log.d(TAG, "Reevaluating network " + nai.network);
                         reportNetworkConnectivity(nai.network, !nai.isValidated());
                         return 0;
+                    case "bpf-get-cgroup-program-id": {
+                        // Usage : adb shell cmd connectivity bpf-get-cgroup-program-id <type>
+                        // Get cgroup bpf program Id for the given type. See BpfUtils#getProgramId
+                        // for more detail.
+                        // If type can't be parsed, this throws NumberFormatException, which
+                        // is passed back to adb who prints it.
+                        final int type = Integer.parseInt(getNextArg());
+                        final int ret = BpfUtils.getProgramId(type);
+                        pw.println(ret);
+                        return 0;
+                    }
                     default:
                         return handleDefaultCommands(cmd);
                 }
@@ -11468,7 +11551,8 @@
         // If there is no default network, default network is considered active to keep the existing
         // behavior. Initial value is used until first connect to the default network.
         private volatile boolean mIsDefaultNetworkActive = true;
-        private final ArrayMap<String, IdleTimerParams> mActiveIdleTimers = new ArrayMap<>();
+        // Key is netId. Value is configured idle timer information.
+        private final SparseArray<IdleTimerParams> mActiveIdleTimers = new SparseArray<>();
 
         private static class IdleTimerParams {
             public final int timeout;
@@ -11496,7 +11580,7 @@
 
         public void handleReportNetworkActivity(NetworkActivityParams activityParams) {
             ensureRunningOnConnectivityServiceThread();
-            if (mActiveIdleTimers.isEmpty()) {
+            if (mActiveIdleTimers.size() == 0) {
                 // This activity change is not for the current default network.
                 // This can happen if netd callback post activity change event message but
                 // the default network is lost before processing this message.
@@ -11572,6 +11656,7 @@
          */
         private boolean setupDataActivityTracking(NetworkAgentInfo networkAgent) {
             final String iface = networkAgent.linkProperties.getInterfaceName();
+            final int netId = networkAgent.network().netId;
 
             final int timeout;
             final int type;
@@ -11596,7 +11681,7 @@
 
             if (timeout > 0 && iface != null) {
                 try {
-                    mActiveIdleTimers.put(iface, new IdleTimerParams(timeout, type));
+                    mActiveIdleTimers.put(netId, new IdleTimerParams(timeout, type));
                     mNetd.idletimerAddInterface(iface, timeout, Integer.toString(type));
                     return true;
                 } catch (Exception e) {
@@ -11612,6 +11697,7 @@
          */
         private void removeDataActivityTracking(NetworkAgentInfo networkAgent) {
             final String iface = networkAgent.linkProperties.getInterfaceName();
+            final int netId = networkAgent.network().netId;
             final NetworkCapabilities caps = networkAgent.networkCapabilities;
 
             if (iface == null) return;
@@ -11627,11 +11713,12 @@
 
             try {
                 updateRadioPowerState(false /* isActive */, type);
-                final IdleTimerParams params = mActiveIdleTimers.remove(iface);
+                final IdleTimerParams params = mActiveIdleTimers.get(netId);
                 if (params == null) {
                     // IdleTimer is not added if the configured timeout is 0 or negative value
                     return;
                 }
+                mActiveIdleTimers.remove(netId);
                 // The call fails silently if no idle timer setup for this interface
                 mNetd.idletimerRemoveInterface(iface, params.timeout,
                         Integer.toString(params.transportType));
@@ -11702,9 +11789,9 @@
             pw.print("mIsDefaultNetworkActive="); pw.println(mIsDefaultNetworkActive);
             pw.println("Idle timers:");
             try {
-                for (Map.Entry<String, IdleTimerParams> ent : mActiveIdleTimers.entrySet()) {
-                    pw.print("  "); pw.print(ent.getKey()); pw.println(":");
-                    final IdleTimerParams params = ent.getValue();
+                for (int i = 0; i < mActiveIdleTimers.size(); i++) {
+                    pw.print("  "); pw.print(mActiveIdleTimers.keyAt(i)); pw.println(":");
+                    final IdleTimerParams params = mActiveIdleTimers.valueAt(i);
                     pw.print("    timeout="); pw.print(params.timeout);
                     pw.print(" type="); pw.println(params.transportType);
                 }
@@ -12471,6 +12558,20 @@
     }
 
     @Override
+    public void setDataSaverEnabled(final boolean enable) {
+        enforceNetworkStackOrSettingsPermission();
+        try {
+            final boolean ret = mNetd.bandwidthEnableDataSaver(enable);
+            if (!ret) {
+                throw new IllegalStateException("Error when changing iptables: " + enable);
+            }
+        } catch (RemoteException e) {
+            // Lack of permission or binder errors.
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @Override
     public void updateMeteredNetworkAllowList(final int uid, final boolean add) {
         enforceNetworkStackOrSettingsPermission();
 
@@ -12624,4 +12725,10 @@
         enforceNetworkStackPermission(mContext);
         return mCdmps;
     }
+
+    @Override
+    public IBinder getRoutingCoordinatorService() {
+        enforceNetworkStackPermission(mContext);
+        return mRoutingCoordinatorService;
+    }
 }
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index e16117b..cf6127f 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -16,9 +16,6 @@
 
 package com.android.server.connectivity;
 
-import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET4_BIND;
-import static com.android.net.module.util.BpfUtils.BPF_CGROUP_INET6_BIND;
-
 import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
@@ -31,11 +28,9 @@
 
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.net.module.util.BpfBitmap;
-import com.android.net.module.util.BpfUtils;
 import com.android.net.module.util.CollectionUtils;
 import com.android.net.module.util.PermissionUtils;
 
-import java.io.IOException;
 import java.util.ArrayList;
 
 /**
@@ -45,7 +40,7 @@
     public static final String SERVICE_NAME = "connectivity_native";
 
     private static final String TAG = ConnectivityNativeService.class.getSimpleName();
-    private static final String CGROUP_PATH = "/sys/fs/cgroup";
+
     private static final String BLOCKED_PORTS_MAP_PATH =
             "/sys/fs/bpf/net_shared/map_block_blocked_ports_map";
 
diff --git a/service/src/com/android/server/connectivity/HandlerUtils.java b/service/src/com/android/server/connectivity/HandlerUtils.java
new file mode 100644
index 0000000..997ecbf
--- /dev/null
+++ b/service/src/com/android/server/connectivity/HandlerUtils.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import android.annotation.NonNull;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+
+/**
+ * Helper class for Handler related utilities.
+ *
+ * @hide
+ */
+public class HandlerUtils {
+    // Note: @hide methods copied from android.os.Handler
+    /**
+     * Runs the specified task synchronously.
+     * <p>
+     * If the current thread is the same as the handler thread, then the runnable
+     * runs immediately without being enqueued.  Otherwise, posts the runnable
+     * to the handler and waits for it to complete before returning.
+     * </p><p>
+     * This method is dangerous!  Improper use can result in deadlocks.
+     * Never call this method while any locks are held or use it in a
+     * possibly re-entrant manner.
+     * </p><p>
+     * This method is occasionally useful in situations where a background thread
+     * must synchronously await completion of a task that must run on the
+     * handler's thread.  However, this problem is often a symptom of bad design.
+     * Consider improving the design (if possible) before resorting to this method.
+     * </p><p>
+     * One example of where you might want to use this method is when you just
+     * set up a Handler thread and need to perform some initialization steps on
+     * it before continuing execution.
+     * </p><p>
+     * If timeout occurs then this method returns <code>false</code> but the runnable
+     * will remain posted on the handler and may already be in progress or
+     * complete at a later time.
+     * </p><p>
+     * When using this method, be sure to use {@link Looper#quitSafely} when
+     * quitting the looper.  Otherwise {@link #runWithScissors} may hang indefinitely.
+     * (TODO: We should fix this by making MessageQueue aware of blocking runnables.)
+     * </p>
+     *
+     * @param h The target handler.
+     * @param r The Runnable that will be executed synchronously.
+     * @param timeout The timeout in milliseconds, or 0 to wait indefinitely.
+     *
+     * @return Returns true if the Runnable was successfully executed.
+     *         Returns false on failure, usually because the
+     *         looper processing the message queue is exiting.
+     *
+     * @hide This method is prone to abuse and should probably not be in the API.
+     * If we ever do make it part of the API, we might want to rename it to something
+     * less funny like runUnsafe().
+     */
+    public static boolean runWithScissors(@NonNull Handler h, @NonNull Runnable r, long timeout) {
+        if (r == null) {
+            throw new IllegalArgumentException("runnable must not be null");
+        }
+        if (timeout < 0) {
+            throw new IllegalArgumentException("timeout must be non-negative");
+        }
+
+        if (Looper.myLooper() == h.getLooper()) {
+            r.run();
+            return true;
+        }
+
+        BlockingRunnable br = new BlockingRunnable(r);
+        return br.postAndWait(h, timeout);
+    }
+
+    private static final class BlockingRunnable implements Runnable {
+        private final Runnable mTask;
+        private boolean mDone;
+
+        BlockingRunnable(Runnable task) {
+            mTask = task;
+        }
+
+        @Override
+        public void run() {
+            try {
+                mTask.run();
+            } finally {
+                synchronized (this) {
+                    mDone = true;
+                    notifyAll();
+                }
+            }
+        }
+
+        public boolean postAndWait(Handler handler, long timeout) {
+            if (!handler.post(this)) {
+                return false;
+            }
+
+            synchronized (this) {
+                if (timeout > 0) {
+                    final long expirationTime = SystemClock.uptimeMillis() + timeout;
+                    while (!mDone) {
+                        long delay = expirationTime - SystemClock.uptimeMillis();
+                        if (delay <= 0) {
+                            return false; // timeout
+                        }
+                        try {
+                            wait(delay);
+                        } catch (InterruptedException ex) {
+                        }
+                    }
+                } else {
+                    while (!mDone) {
+                        try {
+                            wait();
+                        } catch (InterruptedException ex) {
+                        }
+                    }
+                }
+            }
+            return true;
+        }
+    }
+}
diff --git a/service/src/com/android/server/connectivity/NetworkAgentInfo.java b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
index bdd841f..b0ad978 100644
--- a/service/src/com/android/server/connectivity/NetworkAgentInfo.java
+++ b/service/src/com/android/server/connectivity/NetworkAgentInfo.java
@@ -35,6 +35,7 @@
 import android.net.INetworkAgentRegistry;
 import android.net.INetworkMonitor;
 import android.net.LinkProperties;
+import android.net.LocalNetworkConfig;
 import android.net.NattKeepalivePacketData;
 import android.net.Network;
 import android.net.NetworkAgent;
@@ -64,7 +65,6 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.IndentingPrintWriter;
 import com.android.internal.util.WakeupMessage;
-import com.android.modules.utils.build.SdkLevel;
 import com.android.server.ConnectivityService;
 
 import java.io.PrintWriter;
@@ -174,6 +174,7 @@
     // TODO: make this private with a getter.
     @NonNull public NetworkCapabilities networkCapabilities;
     @NonNull public final NetworkAgentConfig networkAgentConfig;
+    @Nullable public LocalNetworkConfig localNetworkConfig;
 
     // Underlying networks declared by the agent.
     // The networks in this list might be declared by a VPN using setUnderlyingNetworks and are
@@ -453,6 +454,8 @@
      * apply to the allowedUids field.
      * They also should not mutate immutable capabilities, although for backward-compatibility
      * this is not enforced and limited to just a log.
+     * Forbidden capabilities also make no sense for networks, so they are disallowed and
+     * will be ignored with a warning.
      *
      * @param carrierPrivilegeAuthenticator the authenticator, to check access UIDs.
      */
@@ -461,14 +464,15 @@
         final NetworkCapabilities nc = new NetworkCapabilities(mDeclaredCapabilitiesUnsanitized);
         if (nc.hasConnectivityManagedCapability()) {
             Log.wtf(TAG, "BUG: " + this + " has CS-managed capability.");
+            nc.removeAllForbiddenCapabilities();
         }
         if (networkCapabilities.getOwnerUid() != nc.getOwnerUid()) {
             Log.e(TAG, toShortString() + ": ignoring attempt to change owner from "
                     + networkCapabilities.getOwnerUid() + " to " + nc.getOwnerUid());
             nc.setOwnerUid(networkCapabilities.getOwnerUid());
         }
-        restrictCapabilitiesFromNetworkAgent(
-                nc, creatorUid, mHasAutomotiveFeature, carrierPrivilegeAuthenticator);
+        restrictCapabilitiesFromNetworkAgent(nc, creatorUid, mHasAutomotiveFeature,
+                mConnServiceDeps, carrierPrivilegeAuthenticator);
         return nc;
     }
 
@@ -598,6 +602,7 @@
     private static final String TAG = ConnectivityService.class.getSimpleName();
     private static final boolean VDBG = false;
     private final ConnectivityService mConnService;
+    private final ConnectivityService.Dependencies mConnServiceDeps;
     private final Context mContext;
     private final Handler mHandler;
     private final QosCallbackTracker mQosCallbackTracker;
@@ -606,6 +611,7 @@
 
     public NetworkAgentInfo(INetworkAgent na, Network net, NetworkInfo info,
             @NonNull LinkProperties lp, @NonNull NetworkCapabilities nc,
+            @Nullable LocalNetworkConfig localNetworkConfig,
             @NonNull NetworkScore score, Context context,
             Handler handler, NetworkAgentConfig config, ConnectivityService connService, INetd netd,
             IDnsResolver dnsResolver, int factorySerialNumber, int creatorUid,
@@ -623,8 +629,10 @@
         networkInfo = info;
         linkProperties = lp;
         networkCapabilities = nc;
+        this.localNetworkConfig = localNetworkConfig;
         networkAgentConfig = config;
         mConnService = connService;
+        mConnServiceDeps = deps;
         setScore(score); // uses members connService, networkCapabilities and networkAgentConfig
         clatd = new Nat464Xlat(this, netd, dnsResolver, deps);
         mContext = context;
@@ -901,6 +909,12 @@
         }
 
         @Override
+        public void sendLocalNetworkConfig(@NonNull final LocalNetworkConfig config) {
+            mHandler.obtainMessage(NetworkAgent.EVENT_LOCAL_NETWORK_CONFIG_CHANGED,
+                    new Pair<>(NetworkAgentInfo.this, config)).sendToTarget();
+        }
+
+        @Override
         public void sendScore(@NonNull final NetworkScore score) {
             mHandler.obtainMessage(NetworkAgent.EVENT_NETWORK_SCORE_CHANGED,
                     new Pair<>(NetworkAgentInfo.this, score)).sendToTarget();
@@ -1515,23 +1529,26 @@
      */
     public static void restrictCapabilitiesFromNetworkAgent(@NonNull final NetworkCapabilities nc,
             final int creatorUid, final boolean hasAutomotiveFeature,
+            @NonNull final ConnectivityService.Dependencies deps,
             @Nullable final CarrierPrivilegeAuthenticator authenticator) {
         if (nc.hasTransport(TRANSPORT_TEST)) {
             nc.restrictCapabilitiesForTestNetwork(creatorUid);
         }
-        if (!areAllowedUidsAcceptableFromNetworkAgent(nc, hasAutomotiveFeature, authenticator)) {
+        if (!areAllowedUidsAcceptableFromNetworkAgent(
+                nc, hasAutomotiveFeature, deps, authenticator)) {
             nc.setAllowedUids(new ArraySet<>());
         }
     }
 
     private static boolean areAllowedUidsAcceptableFromNetworkAgent(
             @NonNull final NetworkCapabilities nc, final boolean hasAutomotiveFeature,
+            @NonNull final ConnectivityService.Dependencies deps,
             @Nullable final CarrierPrivilegeAuthenticator carrierPrivilegeAuthenticator) {
         // NCs without access UIDs are fine.
         if (!nc.hasAllowedUids()) return true;
         // S and below must never accept access UIDs, even if an agent sends them, because netd
         // didn't support the required feature in S.
-        if (!SdkLevel.isAtLeastT()) return false;
+        if (!deps.isAtLeastT()) return false;
 
         // On a non-restricted network, access UIDs make no sense
         if (nc.hasCapability(NET_CAPABILITY_NOT_RESTRICTED)) return false;
diff --git a/service/src/com/android/server/connectivity/RoutingCoordinatorService.java b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
new file mode 100644
index 0000000..92ea610
--- /dev/null
+++ b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity;
+
+import static com.android.net.module.util.NetdUtils.toRouteInfoParcel;
+
+import android.annotation.NonNull;
+import android.net.INetd;
+import android.net.IRoutingCoordinator;
+import android.net.RouteInfo;
+import android.os.RemoteException;
+import android.os.ServiceSpecificException;
+import android.util.ArraySet;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.Objects;
+
+/**
+ * Class to coordinate routing across multiple clients.
+ *
+ * At present this is just a wrapper for netd methods, but it will be used to host some more
+ * coordination logic in the near future. It can be used to pull up some of the routing logic
+ * from netd into Java land.
+ *
+ * Note that usage of this class is not thread-safe. Clients are responsible for their own
+ * synchronization.
+ */
+public class RoutingCoordinatorService extends IRoutingCoordinator.Stub {
+    private final INetd mNetd;
+
+    public RoutingCoordinatorService(@NonNull INetd netd) {
+        mNetd = netd;
+    }
+
+    /**
+     * Add a route for specific network
+     *
+     * @param netId the network to add the route to
+     * @param route the route to add
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void addRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Remove a route for specific network
+     *
+     * @param netId the network to remove the route from
+     * @param route the route to remove
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void removeRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Update a route for specific network
+     *
+     * @param netId the network to update the route for
+     * @param route parcelable with route information
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    @Override
+    public void updateRoute(final int netId, final RouteInfo route)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkUpdateRouteParcel(netId, toRouteInfoParcel(route));
+    }
+
+    /**
+     * Adds an interface to a network. The interface must not be assigned to any network, including
+     * the specified network.
+     *
+     * @param netId the network to add the interface to.
+     * @param iface the name of the interface to add.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void addInterfaceToNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkAddInterface(netId, iface);
+    }
+
+    /**
+     * Removes an interface from a network. The interface must be assigned to the specified network.
+     *
+     * @param netId the network to remove the interface from.
+     * @param iface the name of the interface to remove.
+     *
+     * @throws ServiceSpecificException in case of failure, with an error code corresponding to the
+     *         unix errno.
+     */
+    @Override
+    public void removeInterfaceFromNetwork(final int netId, final String iface)
+            throws ServiceSpecificException, RemoteException {
+        mNetd.networkRemoveInterface(netId, iface);
+    }
+
+    private final Object mIfacesLock = new Object();
+    private static final class ForwardingPair {
+        @NonNull public final String fromIface;
+        @NonNull public final String toIface;
+        ForwardingPair(@NonNull final String fromIface, @NonNull final String toIface) {
+            this.fromIface = fromIface;
+            this.toIface = toIface;
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) return true;
+            if (!(o instanceof ForwardingPair)) return false;
+
+            final ForwardingPair that = (ForwardingPair) o;
+
+            return fromIface.equals(that.fromIface) && toIface.equals(that.toIface);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = fromIface.hashCode();
+            result = 2 * result + toIface.hashCode();
+            return result;
+        }
+    }
+
+    @GuardedBy("mIfacesLock")
+    private final ArraySet<ForwardingPair> mForwardedInterfaces = new ArraySet<>();
+
+    /**
+     * Add forwarding ip rule
+     *
+     * @param fromIface interface name to add forwarding ip rule
+     * @param toIface interface name to add forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void addInterfaceForward(final String fromIface, final String toIface)
+            throws ServiceSpecificException, RemoteException {
+        Objects.requireNonNull(fromIface);
+        Objects.requireNonNull(toIface);
+        synchronized (mIfacesLock) {
+            if (mForwardedInterfaces.size() == 0) {
+                mNetd.ipfwdEnableForwarding("RoutingCoordinator");
+            }
+            final ForwardingPair fwp = new ForwardingPair(fromIface, toIface);
+            if (mForwardedInterfaces.contains(fwp)) {
+                throw new IllegalStateException("Forward already exists between ifaces "
+                        + fromIface + " → " + toIface);
+            }
+            mForwardedInterfaces.add(fwp);
+            // Enables NAT for v4 and filters packets from unknown interfaces
+            mNetd.tetherAddForward(fromIface, toIface);
+            mNetd.ipfwdAddInterfaceForward(fromIface, toIface);
+        }
+    }
+
+    /**
+     * Remove forwarding ip rule
+     *
+     * @param fromIface interface name to remove forwarding ip rule
+     * @param toIface interface name to remove forwarding ip rule
+     * @throws ServiceSpecificException in case of failure, with an error code indicating the
+     *         cause of the failure.
+     */
+    public void removeInterfaceForward(final String fromIface, final String toIface)
+            throws ServiceSpecificException, RemoteException {
+        Objects.requireNonNull(fromIface);
+        Objects.requireNonNull(toIface);
+        synchronized (mIfacesLock) {
+            final ForwardingPair fwp = new ForwardingPair(fromIface, toIface);
+            if (!mForwardedInterfaces.contains(fwp)) {
+                throw new IllegalStateException("No forward set up between interfaces "
+                        + fromIface + " → " + toIface);
+            }
+            mForwardedInterfaces.remove(fwp);
+            mNetd.ipfwdRemoveInterfaceForward(fromIface, toIface);
+            mNetd.tetherRemoveForward(fromIface, toIface);
+            if (mForwardedInterfaces.size() == 0) {
+                mNetd.ipfwdDisableForwarding("RoutingCoordinator");
+            }
+        }
+    }
+}
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 621759e..0bcb757 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -181,6 +181,8 @@
     },
 }
 
+// The net-utils-device-common-netlink library requires the callers to contain
+// net-utils-device-common-struct.
 java_library {
     name: "net-utils-device-common-netlink",
     srcs: [
@@ -192,12 +194,13 @@
         "//packages/modules/Connectivity:__subpackages__",
         "//packages/modules/NetworkStack:__subpackages__",
     ],
-    static_libs: [
-        "net-utils-device-common-struct",
-    ],
     libs: [
         "androidx.annotation_annotation",
         "framework-connectivity.stubs.module_lib",
+        // For libraries which are statically linked in framework-connectivity, do not
+        // statically link here because callers of this library might already have a static
+        // version linked.
+        "net-utils-device-common-struct",
     ],
     apex_available: [
         "com.android.tethering",
@@ -209,6 +212,8 @@
     },
 }
 
+// The net-utils-device-common-ip library requires the callers to contain
+// net-utils-device-common-struct.
 java_library {
     // TODO : this target should probably be folded into net-utils-device-common
     name: "net-utils-device-common-ip",
diff --git a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
index 98fda56..d99eedc 100644
--- a/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
+++ b/staticlibs/client-libs/netd/com/android/net/module/util/NetdUtils.java
@@ -28,6 +28,7 @@
 import android.net.InterfaceConfigurationParcel;
 import android.net.IpPrefix;
 import android.net.RouteInfo;
+import android.net.RouteInfoParcel;
 import android.net.TetherConfigParcel;
 import android.os.RemoteException;
 import android.os.ServiceSpecificException;
@@ -159,9 +160,11 @@
             throws RemoteException, ServiceSpecificException {
         netd.tetherInterfaceAdd(iface);
         networkAddInterface(netd, iface, maxAttempts, pollingIntervalMs);
-        List<RouteInfo> routes = new ArrayList<>();
-        routes.add(new RouteInfo(dest, null, iface, RTN_UNICAST));
-        addRoutesToLocalNetwork(netd, iface, routes);
+        // Activate a route to dest and IPv6 link local.
+        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+                new RouteInfo(dest, null, iface, RTN_UNICAST));
+        modifyRoute(netd, ModifyOperation.ADD, INetd.LOCAL_NET_ID,
+                new RouteInfo(new IpPrefix("fe80::/64"), null, iface, RTN_UNICAST));
     }
 
     /**
@@ -276,4 +279,38 @@
             throw new IllegalStateException(e);
         }
     }
+
+    /**
+     * Convert a RouteInfo into a RouteInfoParcel.
+     */
+    public static RouteInfoParcel toRouteInfoParcel(RouteInfo route) {
+        final String nextHop;
+
+        switch (route.getType()) {
+            case RouteInfo.RTN_UNICAST:
+                if (route.hasGateway()) {
+                    nextHop = route.getGateway().getHostAddress();
+                } else {
+                    nextHop = INetd.NEXTHOP_NONE;
+                }
+                break;
+            case RouteInfo.RTN_UNREACHABLE:
+                nextHop = INetd.NEXTHOP_UNREACHABLE;
+                break;
+            case RouteInfo.RTN_THROW:
+                nextHop = INetd.NEXTHOP_THROW;
+                break;
+            default:
+                nextHop = INetd.NEXTHOP_NONE;
+                break;
+        }
+
+        final RouteInfoParcel rip = new RouteInfoParcel();
+        rip.ifName = route.getInterface();
+        rip.destination = route.getDestination().toString();
+        rip.nextHop = nextHop;
+        rip.mtu = route.getMtu();
+
+        return rip;
+    }
 }
diff --git a/staticlibs/device/com/android/net/module/util/BpfMap.java b/staticlibs/device/com/android/net/module/util/BpfMap.java
index d45cace..595ac74 100644
--- a/staticlibs/device/com/android/net/module/util/BpfMap.java
+++ b/staticlibs/device/com/android/net/module/util/BpfMap.java
@@ -187,12 +187,6 @@
         return nativeDeleteMapEntry(mMapFd.getFd(), key.writeToBytes());
     }
 
-    /** Returns {@code true} if this map contains no elements. */
-    @Override
-    public boolean isEmpty() throws ErrnoException {
-        return getFirstKey() == null;
-    }
-
     private K getNextKeyInternal(@Nullable K key) throws ErrnoException {
         byte[] rawKey = new byte[mKeySize];
 
@@ -245,49 +239,6 @@
         return Struct.parse(mValueClass, buffer);
     }
 
-    /**
-     * Iterate through the map and handle each key -> value retrieved base on the given BiConsumer.
-     * The given BiConsumer may to delete the passed-in entry, but is not allowed to perform any
-     * other structural modifications to the map, such as adding entries or deleting other entries.
-     * Otherwise, iteration will result in undefined behaviour.
-     */
-    @Override
-    public void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException {
-        @Nullable K nextKey = getFirstKey();
-
-        while (nextKey != null) {
-            @NonNull final K curKey = nextKey;
-            @NonNull final V value = getValue(curKey);
-
-            nextKey = getNextKey(curKey);
-            action.accept(curKey, value);
-        }
-    }
-
-    /* Empty implementation to implement AutoCloseable, so we can use BpfMaps
-     * with try with resources, but due to persistent FD cache, there is no actual
-     * need to close anything.  File descriptors will actually be closed when we
-     * unlock the BpfMap class and destroy the ParcelFileDescriptor objects.
-     */
-    @Override
-    public void close() throws IOException {
-    }
-
-    /**
-     * Clears the map. The map may already be empty.
-     *
-     * @throws ErrnoException if the map is already closed, if an error occurred during iteration,
-     *                        or if a non-ENOENT error occurred when deleting a key.
-     */
-    @Override
-    public void clear() throws ErrnoException {
-        K key = getFirstKey();
-        while (key != null) {
-            deleteEntry(key);  // ignores ENOENT.
-            key = getFirstKey();
-        }
-    }
-
     private static native int nativeBpfFdGet(String path, int mode, int keySize, int valueSize)
             throws ErrnoException, NullPointerException;
 
diff --git a/staticlibs/device/com/android/net/module/util/BpfUtils.java b/staticlibs/device/com/android/net/module/util/BpfUtils.java
index f1546c0..10a8f60 100644
--- a/staticlibs/device/com/android/net/module/util/BpfUtils.java
+++ b/staticlibs/device/com/android/net/module/util/BpfUtils.java
@@ -32,40 +32,27 @@
     // Defined in include/uapi/linux/bpf.h. Only adding the CGROUPS currently being used for now.
     public static final int BPF_CGROUP_INET_INGRESS = 0;
     public static final int BPF_CGROUP_INET_EGRESS = 1;
+    public static final int BPF_CGROUP_INET_SOCK_CREATE = 2;
     public static final int BPF_CGROUP_INET4_BIND = 8;
     public static final int BPF_CGROUP_INET6_BIND = 9;
 
+    // Note: This is only guaranteed to be accurate on U+ devices. It is likely to be accurate
+    // on T+ devices as well, but this is not guaranteed.
+    public static final String CGROUP_PATH = "/sys/fs/cgroup/";
 
     /**
-     * Attach BPF program to CGROUP
+     * Get BPF program Id from CGROUP.
+     *
+     * Note: This requires a 4.19 kernel which is only guaranteed on V+.
+     *
+     * @param attachType Bpf attach type. See bpf_attach_type in include/uapi/linux/bpf.h.
+     * @return Positive integer for a Program Id. 0 if no program is attached.
+     * @throws IOException if failed to open the cgroup directory or query bpf program.
      */
-    public static void attachProgram(int type, @NonNull String programPath,
-            @NonNull String cgroupPath, int flags) throws IOException {
-        native_attachProgramToCgroup(type, programPath, cgroupPath, flags);
+    public static int getProgramId(int attachType) throws IOException {
+        return native_getProgramIdFromCgroup(attachType, CGROUP_PATH);
     }
 
-    /**
-     * Detach BPF program from CGROUP
-     */
-    public static void detachProgram(int type, @NonNull String cgroupPath)
-            throws IOException {
-        native_detachProgramFromCgroup(type, cgroupPath);
-    }
-
-    /**
-     * Detach single BPF program from CGROUP
-     */
-    public static void detachSingleProgram(int type, @NonNull String programPath,
-            @NonNull String cgroupPath) throws IOException {
-        native_detachSingleProgramFromCgroup(type, programPath, cgroupPath);
-    }
-
-    private static native boolean native_attachProgramToCgroup(int type, String programPath,
-            String cgroupPath, int flags) throws IOException;
-    private static native boolean native_detachProgramFromCgroup(int type, String cgroupPath)
-            throws IOException;
-    private static native boolean native_detachSingleProgramFromCgroup(int type,
-            String programPath, String cgroupPath) throws IOException;
     private static native int native_getProgramIdFromCgroup(int type, String cgroupPath)
             throws IOException;
 }
diff --git a/staticlibs/device/com/android/net/module/util/IBpfMap.java b/staticlibs/device/com/android/net/module/util/IBpfMap.java
index 83ff875..ca56830 100644
--- a/staticlibs/device/com/android/net/module/util/IBpfMap.java
+++ b/staticlibs/device/com/android/net/module/util/IBpfMap.java
@@ -18,6 +18,7 @@
 import android.system.ErrnoException;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.NoSuchElementException;
@@ -49,15 +50,17 @@
     /** Remove existing key from eBpf map. Return true if something was deleted. */
     boolean deleteEntry(K key) throws ErrnoException;
 
-    /** Returns {@code true} if this map contains no elements. */
-    boolean isEmpty() throws ErrnoException;
-
     /** Get the key after the passed-in key. */
     K getNextKey(@NonNull K key) throws ErrnoException;
 
     /** Get the first key of the eBpf map. */
     K getFirstKey() throws ErrnoException;
 
+    /** Returns {@code true} if this map contains no elements. */
+    default boolean isEmpty() throws ErrnoException {
+        return getFirstKey() == null;
+    }
+
     /** Check whether a key exists in the map. */
     boolean containsKey(@NonNull K key) throws ErrnoException;
 
@@ -70,13 +73,38 @@
 
     /**
      * Iterate through the map and handle each key -> value retrieved base on the given BiConsumer.
+     * The given BiConsumer may to delete the passed-in entry, but is not allowed to perform any
+     * other structural modifications to the map, such as adding entries or deleting other entries.
+     * Otherwise, iteration will result in undefined behaviour.
      */
-    void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException;
+    default public void forEach(ThrowingBiConsumer<K, V> action) throws ErrnoException {
+        @Nullable K nextKey = getFirstKey();
 
-    /** Clears the map. */
-    void clear() throws ErrnoException;
+        while (nextKey != null) {
+            @NonNull final K curKey = nextKey;
+            @NonNull final V value = getValue(curKey);
+
+            nextKey = getNextKey(curKey);
+            action.accept(curKey, value);
+        }
+    }
+
+    /**
+     * Clears the map. The map may already be empty.
+     *
+     * @throws ErrnoException if the map is already closed, if an error occurred during iteration,
+     *                        or if a non-ENOENT error occurred when deleting a key.
+     */
+    default public void clear() throws ErrnoException {
+        K key = getFirstKey();
+        while (key != null) {
+            deleteEntry(key);  // ignores ENOENT.
+            key = getFirstKey();
+        }
+    }
 
     /** Close for AutoCloseable. */
     @Override
-    void close() throws IOException;
+    default void close() throws IOException {
+    };
 }
diff --git a/staticlibs/device/com/android/net/module/util/Struct.java b/staticlibs/device/com/android/net/module/util/Struct.java
index b638a46..dc0d19b 100644
--- a/staticlibs/device/com/android/net/module/util/Struct.java
+++ b/staticlibs/device/com/android/net/module/util/Struct.java
@@ -146,6 +146,14 @@
         int arraysize() default 0;
     }
 
+    /**
+     * Indicates that this field contains a computed value and is ignored for the purposes of Struct
+     * parsing.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target(ElementType.FIELD)
+    public @interface Computed {}
+
     private static class FieldInfo {
         @NonNull
         public final Field annotation;
@@ -533,6 +541,7 @@
         final FieldInfo[] annotationFields = new FieldInfo[getAnnotationFieldCount(clazz)];
         for (java.lang.reflect.Field field : clazz.getDeclaredFields()) {
             if (Modifier.isStatic(field.getModifiers())) continue;
+            if (field.getAnnotation(Computed.class) != null) continue;
 
             final Field annotation = field.getAnnotation(Field.class);
             if (annotation == null) {
diff --git a/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java b/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
index 176b7bc..b0f19e2 100644
--- a/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
+++ b/staticlibs/device/com/android/net/module/util/structs/IaPrefixOption.java
@@ -18,12 +18,17 @@
 
 import static com.android.net.module.util.NetworkStackConstants.DHCP6_OPTION_IAPREFIX;
 
+import android.net.IpPrefix;
 import android.util.Log;
 
 import com.android.net.module.util.Struct;
+import com.android.net.module.util.Struct.Computed;
 import com.android.net.module.util.Struct.Field;
 import com.android.net.module.util.Struct.Type;
 
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 
@@ -70,7 +75,11 @@
     @Field(order = 5, type = Type.ByteArray, arraysize = 16)
     public final byte[] prefix;
 
-    public IaPrefixOption(final short code, final short length, final long preferred,
+    @Computed
+    private final IpPrefix mIpPrefix;
+
+    // Constructor used by Struct.parse()
+    protected IaPrefixOption(final short code, final short length, final long preferred,
             final long valid, final byte prefixLen, final byte[] prefix) {
         this.code = code;
         this.length = length;
@@ -78,42 +87,72 @@
         this.valid = valid;
         this.prefixLen = prefixLen;
         this.prefix = prefix.clone();
+
+        try {
+            final Inet6Address addr = (Inet6Address) InetAddress.getByAddress(prefix);
+            mIpPrefix = new IpPrefix(addr, prefixLen);
+        } catch (UnknownHostException | ClassCastException e) {
+            // UnknownHostException should never happen unless prefix is null.
+            // ClassCastException can occur when prefix is an IPv6 mapped IPv4 address.
+            // Both scenarios should throw an exception in the context of Struct#parse().
+            throw new IllegalArgumentException(e);
+        }
+    }
+
+    public IaPrefixOption(final short length, final long preferred, final long valid,
+            final byte prefixLen, final byte[] prefix) {
+        this((byte) DHCP6_OPTION_IAPREFIX, length, preferred, valid, prefixLen, prefix);
     }
 
     /**
      * Check whether or not IA Prefix option in IA_PD option is valid per RFC8415#section-21.22.
+     *
+     * Note: an expired prefix can still be valid.
      */
-    public boolean isValid(int t2) {
-        if (preferred < 0 || valid < 0) {
-            Log.w(TAG, "IA_PD option with invalid lifetime, preferred lifetime " + preferred
-                    + ", valid lifetime " + valid);
+    public boolean isValid() {
+        if (preferred < 0) {
+            Log.w(TAG, "Invalid preferred lifetime: " + this);
+            return false;
+        }
+        if (valid < 0) {
+            Log.w(TAG, "Invalid valid lifetime: " + this);
             return false;
         }
         if (preferred > valid) {
-            Log.w(TAG, "IA_PD option with preferred lifetime " + preferred
-                    + " greater than valid lifetime " + valid);
+            Log.w(TAG, "Invalid lifetime. Preferred lifetime > valid lifetime: " + this);
             return false;
         }
         if (prefixLen > 64) {
-            Log.w(TAG, "IA_PD option with prefix length " + prefixLen
-                    + " longer than 64");
-            return false;
-        }
-        // Either preferred lifetime or t2 might be 0 which is valid, then ignore it.
-        if (preferred != 0 && t2 != 0 && preferred < t2) {
-            Log.w(TAG, "preferred lifetime " + preferred + " is smaller than T2 " + t2);
+            Log.w(TAG, "Invalid prefix length: " + this);
             return false;
         }
         return true;
     }
 
+    public IpPrefix getIpPrefix() {
+        return mIpPrefix;
+    }
+
+    /**
+     * Check whether or not IA Prefix option has 0 preferred and valid lifetimes.
+     */
+    public boolean withZeroLifetimes() {
+        return preferred == 0 && valid == 0;
+    }
+
     /**
      * Build an IA_PD prefix option with given specific parameters.
      */
     public static ByteBuffer build(final short length, final long preferred, final long valid,
             final byte prefixLen, final byte[] prefix) {
-        final IaPrefixOption option = new IaPrefixOption((byte) DHCP6_OPTION_IAPREFIX,
+        final IaPrefixOption option = new IaPrefixOption(
                 length /* 25 + IAPrefix options length */, preferred, valid, prefixLen, prefix);
         return ByteBuffer.wrap(option.writeToBytes(ByteOrder.BIG_ENDIAN));
     }
+
+    @Override
+    public String toString() {
+        return "IA Prefix, length " + length + ": " + mIpPrefix + ", pref " + preferred + ", valid "
+                + valid;
+    }
 }
diff --git a/staticlibs/framework/com/android/net/module/util/SdkUtil.java b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
new file mode 100644
index 0000000..5006ba9
--- /dev/null
+++ b/staticlibs/framework/com/android/net/module/util/SdkUtil.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util;
+
+import android.annotation.Nullable;
+
+/**
+ * Utilities to deal with multiple SDKs in a single mainline module.
+ * @hide
+ */
+public class SdkUtil {
+    /**
+     * Holder class taking advantage of erasure to avoid reflection running into class not found
+     * exceptions.
+     *
+     * This is useful to store a reference to a class that might not be present at runtime when
+     * fields are examined through reflection. An example is the MessageUtils class, which tries
+     * to get all fields in a class and therefore will try to load any class for which there
+     * is a member. Another example would be arguments or return values of methods in tests,
+     * when the testing framework uses reflection to list methods and their arguments.
+     *
+     * In these cases, LateSdk<T> can be used to hide type T from reflection, since it's erased
+     * and it becomes a vanilla LateSdk in Java bytecode. The T still can't be instantiated at
+     * runtime of course, but runtime tests will avoid that.
+     *
+     * @param <T> The held type
+     * @hide
+     */
+    public static class LateSdk<T> {
+        @Nullable public final T value;
+        public LateSdk(@Nullable final T value) {
+            this.value = value;
+        }
+    }
+}
diff --git a/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp b/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
index cf09379..bcc3ded 100644
--- a/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
+++ b/staticlibs/native/bpfutiljni/com_android_net_module_util_BpfUtils.cpp
@@ -30,86 +30,6 @@
 
 using base::unique_fd;
 
-// If attach fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_attachProgramToCgroup(JNIEnv *env,
-        jclass clazz, jint type, jstring bpfProgPath, jstring cgroupPath, jint flags) {
-
-    ScopedUtfChars dirPath(env, cgroupPath);
-    unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (cg_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to open the cgroup directory %s: %s",
-                             dirPath.c_str(), strerror(errno));
-        return false;
-    }
-
-    ScopedUtfChars bpfProg(env, bpfProgPath);
-    unique_fd bpf_fd(bpf::retrieveProgram(bpfProg.c_str()));
-    if (bpf_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to retrieve bpf program from %s: %s",
-                             bpfProg.c_str(), strerror(errno));
-        return false;
-    }
-    if (bpf::attachProgram((bpf_attach_type) type, bpf_fd, cg_fd, flags)) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to attach bpf program %s to %s: %s",
-                             bpfProg.c_str(), dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
-}
-
-// If detach fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_detachProgramFromCgroup(JNIEnv *env,
-        jclass clazz, jint type, jstring cgroupPath) {
-
-    ScopedUtfChars dirPath(env, cgroupPath);
-    unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (cg_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to open the cgroup directory %s: %s",
-                             dirPath.c_str(), strerror(errno));
-        return false;
-    }
-
-    if (bpf::detachProgram((bpf_attach_type) type, cg_fd)) {
-        jniThrowExceptionFmt(env, "Failed to detach bpf program from %s: %s",
-                dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
-}
-
-// If detach single program fails throw error and return false.
-static jboolean com_android_net_module_util_BpfUtil_detachSingleProgramFromCgroup(JNIEnv *env,
-        jclass clazz, jint type, jstring bpfProgPath, jstring cgroupPath) {
-
-    ScopedUtfChars dirPath(env, cgroupPath);
-    unique_fd cg_fd(open(dirPath.c_str(), O_DIRECTORY | O_RDONLY | O_CLOEXEC));
-    if (cg_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to open the cgroup directory %s: %s",
-                             dirPath.c_str(), strerror(errno));
-        return false;
-    }
-
-    ScopedUtfChars bpfProg(env, bpfProgPath);
-    unique_fd bpf_fd(bpf::retrieveProgram(bpfProg.c_str()));
-    if (bpf_fd == -1) {
-        jniThrowExceptionFmt(env, "java/io/IOException",
-                             "Failed to retrieve bpf program from %s: %s",
-                             bpfProg.c_str(), strerror(errno));
-        return false;
-    }
-    if (bpf::detachSingleProgram((bpf_attach_type) type, bpf_fd, cg_fd)) {
-        jniThrowExceptionFmt(env, "Failed to detach bpf program %s from %s: %s",
-                bpfProg.c_str(), dirPath.c_str(), strerror(errno));
-        return false;
-    }
-    return true;
-}
-
 static jint com_android_net_module_util_BpfUtil_getProgramIdFromCgroup(JNIEnv *env,
         jclass clazz, jint type, jstring cgroupPath) {
 
@@ -138,12 +58,6 @@
  */
 static const JNINativeMethod gMethods[] = {
     /* name, signature, funcPtr */
-    { "native_attachProgramToCgroup", "(ILjava/lang/String;Ljava/lang/String;I)Z",
-        (void*) com_android_net_module_util_BpfUtil_attachProgramToCgroup },
-    { "native_detachProgramFromCgroup", "(ILjava/lang/String;)Z",
-        (void*) com_android_net_module_util_BpfUtil_detachProgramFromCgroup },
-    { "native_detachSingleProgramFromCgroup", "(ILjava/lang/String;Ljava/lang/String;)Z",
-        (void*) com_android_net_module_util_BpfUtil_detachSingleProgramFromCgroup },
     { "native_getProgramIdFromCgroup", "(ILjava/lang/String;)I",
         (void*) com_android_net_module_util_BpfUtil_getProgramIdFromCgroup },
 };
diff --git a/staticlibs/testutils/Android.bp b/staticlibs/testutils/Android.bp
index 5fe7ac3..a5e5afb 100644
--- a/staticlibs/testutils/Android.bp
+++ b/staticlibs/testutils/Android.bp
@@ -38,6 +38,7 @@
         "net-utils-device-common",
         "net-utils-device-common-async",
         "net-utils-device-common-netlink",
+        "net-utils-device-common-struct",
         "net-utils-device-common-wear",
         "modules-utils-build_system",
     ],
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
index 35f22b9..46229b0 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DevSdkIgnoreRule.kt
@@ -27,6 +27,9 @@
 
 @Deprecated("Use Build.VERSION_CODES", ReplaceWith("Build.VERSION_CODES.S_V2"))
 const val SC_V2 = Build.VERSION_CODES.S_V2
+// TODO: Remove this when Build.VERSION_CODES.VANILLA_ICE_CREAM is available in all branches
+// where this code builds
+const val VANILLA_ICE_CREAM = 35 // Bui1ld.VERSION_CODES.VANILLA_ICE_CREAM
 
 private val MAX_TARGET_SDK_ANNOTATION_RE = Pattern.compile("MaxTargetSdk([0-9]+)$")
 private val targetSdk = InstrumentationRegistry.getContext().applicationInfo.targetSdkVersion
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
index 733bd98..70f20d6 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestBpfMap.java
@@ -43,6 +43,8 @@
 public class TestBpfMap<K extends Struct, V extends Struct> implements IBpfMap<K, V> {
     private final ConcurrentHashMap<K, V> mMap = new ConcurrentHashMap<>();
 
+    public TestBpfMap() {}
+
     // TODO: Remove this constructor
     public TestBpfMap(final Class<K> key, final Class<V> value) {
     }
diff --git a/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt b/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
index eb94781..600a623 100644
--- a/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
+++ b/staticlibs/testutils/host/com/android/testutils/ConnectivityTestTargetPreparer.kt
@@ -128,7 +128,7 @@
         if (testInfo.device.getApiLevel() < 31) return
         testInfo.exec("cmd connectivity set-chain3-enabled $enableChain")
         enablePkgs.forEach { (pkg, allow) ->
-            testInfo.exec("cmd connectivity set-package-networking-enabled $pkg $allow")
+            testInfo.exec("cmd connectivity set-package-networking-enabled $allow $pkg")
         }
     }
 
diff --git a/tests/benchmark/Android.bp b/tests/benchmark/Android.bp
index 77383ad..6ea5347 100644
--- a/tests/benchmark/Android.bp
+++ b/tests/benchmark/Android.bp
@@ -29,6 +29,7 @@
         "src/**/*.kt",
         "src/**/*.aidl",
     ],
+    asset_dirs: ["assets"],
     static_libs: [
         "androidx.test.rules",
         "mockito-target-minus-junit4",
diff --git a/tests/benchmark/assets/dataset/A052701.zip b/tests/benchmark/assets/dataset/A052701.zip
new file mode 100644
index 0000000..fdde1ad
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052701.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052801.zip b/tests/benchmark/assets/dataset/A052801.zip
new file mode 100644
index 0000000..7f908b7
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052801.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052802.zip b/tests/benchmark/assets/dataset/A052802.zip
new file mode 100644
index 0000000..180ad3e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052802.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052803.zip b/tests/benchmark/assets/dataset/A052803.zip
new file mode 100644
index 0000000..321a79b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052803.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052804.zip b/tests/benchmark/assets/dataset/A052804.zip
new file mode 100644
index 0000000..298ec04
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052804.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052901.zip b/tests/benchmark/assets/dataset/A052901.zip
new file mode 100644
index 0000000..0f49543
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052901.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A052902.zip b/tests/benchmark/assets/dataset/A052902.zip
new file mode 100644
index 0000000..ec22456
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A052902.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053001.zip b/tests/benchmark/assets/dataset/A053001.zip
new file mode 100644
index 0000000..ad5d82e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053001.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053002.zip b/tests/benchmark/assets/dataset/A053002.zip
new file mode 100644
index 0000000..8a4bb0c
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053002.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053003.zip b/tests/benchmark/assets/dataset/A053003.zip
new file mode 100644
index 0000000..24d2057
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053003.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053004.zip b/tests/benchmark/assets/dataset/A053004.zip
new file mode 100644
index 0000000..352f93f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053004.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053005.zip b/tests/benchmark/assets/dataset/A053005.zip
new file mode 100644
index 0000000..2b49a1b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053005.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053006.zip b/tests/benchmark/assets/dataset/A053006.zip
new file mode 100644
index 0000000..a59f2ec
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053006.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053007.zip b/tests/benchmark/assets/dataset/A053007.zip
new file mode 100644
index 0000000..df7ae74
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053007.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053101.zip b/tests/benchmark/assets/dataset/A053101.zip
new file mode 100644
index 0000000..c10ed64
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053102.zip b/tests/benchmark/assets/dataset/A053102.zip
new file mode 100644
index 0000000..8c9f9cf
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053103.zip b/tests/benchmark/assets/dataset/A053103.zip
new file mode 100644
index 0000000..9202c50
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053103.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A053104.zip b/tests/benchmark/assets/dataset/A053104.zip
new file mode 100644
index 0000000..3c77724
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A053104.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060101.zip b/tests/benchmark/assets/dataset/A060101.zip
new file mode 100644
index 0000000..86443a7
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060102.zip b/tests/benchmark/assets/dataset/A060102.zip
new file mode 100644
index 0000000..4f2cf49
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060201.zip b/tests/benchmark/assets/dataset/A060201.zip
new file mode 100644
index 0000000..3c28bec
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060201.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/A060202.zip b/tests/benchmark/assets/dataset/A060202.zip
new file mode 100644
index 0000000..e39e493
--- /dev/null
+++ b/tests/benchmark/assets/dataset/A060202.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B053001.zip b/tests/benchmark/assets/dataset/B053001.zip
new file mode 100644
index 0000000..8408744
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B053001.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B053002.zip b/tests/benchmark/assets/dataset/B053002.zip
new file mode 100644
index 0000000..5245f70
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B053002.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060101.zip b/tests/benchmark/assets/dataset/B060101.zip
new file mode 100644
index 0000000..242c0d1
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060201.zip b/tests/benchmark/assets/dataset/B060201.zip
new file mode 100644
index 0000000..29df25a
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060201.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060202.zip b/tests/benchmark/assets/dataset/B060202.zip
new file mode 100644
index 0000000..bda9edd
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060202.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060203.zip b/tests/benchmark/assets/dataset/B060203.zip
new file mode 100644
index 0000000..b9fccfe
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060203.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060204.zip b/tests/benchmark/assets/dataset/B060204.zip
new file mode 100644
index 0000000..66227d2
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060204.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060205.zip b/tests/benchmark/assets/dataset/B060205.zip
new file mode 100644
index 0000000..6aaa06b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060205.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060206.zip b/tests/benchmark/assets/dataset/B060206.zip
new file mode 100644
index 0000000..18445b0
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060206.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/B060207.zip b/tests/benchmark/assets/dataset/B060207.zip
new file mode 100644
index 0000000..20f7c5b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/B060207.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060101.zip b/tests/benchmark/assets/dataset/C060101.zip
new file mode 100644
index 0000000..0b1c29f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060101.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060102.zip b/tests/benchmark/assets/dataset/C060102.zip
new file mode 100644
index 0000000..8064905
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060102.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060103.zip b/tests/benchmark/assets/dataset/C060103.zip
new file mode 100644
index 0000000..d0e819f
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060103.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060104.zip b/tests/benchmark/assets/dataset/C060104.zip
new file mode 100644
index 0000000..f87ca8d
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060104.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060105.zip b/tests/benchmark/assets/dataset/C060105.zip
new file mode 100644
index 0000000..e869895
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060105.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060106.zip b/tests/benchmark/assets/dataset/C060106.zip
new file mode 100644
index 0000000..6d25a98
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060106.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060107.zip b/tests/benchmark/assets/dataset/C060107.zip
new file mode 100644
index 0000000..a7cb31c
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060107.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060108.zip b/tests/benchmark/assets/dataset/C060108.zip
new file mode 100644
index 0000000..c1a5898
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060108.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060109.zip b/tests/benchmark/assets/dataset/C060109.zip
new file mode 100644
index 0000000..bb9116e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060109.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060110.zip b/tests/benchmark/assets/dataset/C060110.zip
new file mode 100644
index 0000000..5ca0f96
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060110.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060111.zip b/tests/benchmark/assets/dataset/C060111.zip
new file mode 100644
index 0000000..6a12d7e
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060111.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060112.zip b/tests/benchmark/assets/dataset/C060112.zip
new file mode 100644
index 0000000..fa2c30b
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060112.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060113.zip b/tests/benchmark/assets/dataset/C060113.zip
new file mode 100644
index 0000000..63a34ba
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060113.zip
Binary files differ
diff --git a/tests/benchmark/assets/dataset/C060114.zip b/tests/benchmark/assets/dataset/C060114.zip
new file mode 100644
index 0000000..bd60927
--- /dev/null
+++ b/tests/benchmark/assets/dataset/C060114.zip
Binary files differ
diff --git a/tests/benchmark/res/raw/netstats-many-uids-zip b/tests/benchmark/assets/dataset/netstats-many-uids.zip
similarity index 98%
rename from tests/benchmark/res/raw/netstats-many-uids-zip
rename to tests/benchmark/assets/dataset/netstats-many-uids.zip
index 22e8254..9554aaa 100644
--- a/tests/benchmark/res/raw/netstats-many-uids-zip
+++ b/tests/benchmark/assets/dataset/netstats-many-uids.zip
Binary files differ
diff --git a/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt b/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
index e80548b..585157f 100644
--- a/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
+++ b/tests/benchmark/src/android/net/netstats/benchmarktests/NetworkStatsTest.kt
@@ -20,10 +20,9 @@
 import android.net.NetworkStatsCollection
 import android.net.netstats.NetworkStatsDataMigrationUtils.PREFIX_UID
 import android.os.DropBoxManager
-import androidx.test.InstrumentationRegistry
+import androidx.test.platform.app.InstrumentationRegistry
 import com.android.internal.util.FileRotator
 import com.android.internal.util.FileRotator.Reader
-import com.android.server.connectivity.benchmarktests.R
 import com.android.server.net.NetworkStatsRecorder
 import java.io.BufferedInputStream
 import java.io.DataInputStream
@@ -44,23 +43,22 @@
     companion object {
         private val DEFAULT_BUFFER_SIZE = 8192
         private val FILE_CACHE_WARM_UP_REPEAT_COUNT = 10
-        private val TEST_REPEAT_COUNT = 10
         private val UID_COLLECTION_BUCKET_DURATION_MS = TimeUnit.HOURS.toMillis(2)
         private val UID_RECORDER_ROTATE_AGE_MS = TimeUnit.DAYS.toMillis(15)
         private val UID_RECORDER_DELETE_AGE_MS = TimeUnit.DAYS.toMillis(90)
+        private val TEST_DATASET_SUBFOLDER = "dataset/"
 
-        private val testFilesDir by lazy {
-            // These file generated by using real user dataset which has many uid records
-            // and agreed to share the dataset for testing purpose. These dataset can be
-            // extracted from rooted devices by using
-            // "adb pull /data/misc/apexdata/com.android.tethering/netstats" command.
-            val zipInputStream =
-                ZipInputStream(getInputStreamForResource(R.raw.netstats_many_uids_zip))
-            unzipToTempDir(zipInputStream)
-        }
-
-        private val uidTestFiles: List<File> by lazy {
-            getSortedListForPrefix(testFilesDir, "uid")
+        // These files are generated by using real user dataset which has many uid records
+        // and agreed to share the dataset for testing purpose. These dataset can be
+        // extracted from rooted devices by using
+        // "adb pull /data/misc/apexdata/com.android.tethering/netstats" command.
+        private val testFilesAssets by lazy {
+            val zipFiles = context.assets.list(TEST_DATASET_SUBFOLDER)!!.asList()
+            zipFiles.map {
+                val zipInputStream =
+                    ZipInputStream((TEST_DATASET_SUBFOLDER + it).toAssetInputStream())
+                File(unzipToTempDir(zipInputStream), "netstats")
+            }
         }
 
         // Test results shows the test cases who read the file first will take longer time to
@@ -72,24 +70,34 @@
         @BeforeClass
         fun setUpOnce() {
             repeat(FILE_CACHE_WARM_UP_REPEAT_COUNT) {
-                val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
-                for (file in uidTestFiles) {
-                    readFile(file, collection)
+                testFilesAssets.forEach {
+                    val uidTestFiles = getSortedListForPrefix(it, "uid")
+                    val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
+                    for (file in uidTestFiles) {
+                        readFile(file, collection)
+                    }
                 }
             }
         }
 
-        private fun getInputStreamForResource(resourceId: Int): DataInputStream =
-            DataInputStream(
-                InstrumentationRegistry.getContext()
-                    .getResources().openRawResource(resourceId)
-            )
+        val context get() = InstrumentationRegistry.getInstrumentation().getContext()
+        private fun String.toAssetInputStream() = DataInputStream(context.assets.open(this))
 
         private fun unzipToTempDir(zis: ZipInputStream): File {
             val statsDir =
                 Files.createTempDirectory(NetworkStatsTest::class.simpleName).toFile()
             generateSequence { zis.nextEntry }.forEach { entry ->
-                FileOutputStream(File(statsDir, entry.name)).use {
+                val entryFile = File(statsDir, entry.name)
+                if (entry.isDirectory) {
+                    entryFile.mkdirs()
+                    return@forEach
+                }
+
+                // Make sure all folders exists. There is no guarantee anywhere.
+                entryFile.parentFile!!.mkdirs()
+
+                // If the entry is a file extract it.
+                FileOutputStream(entryFile).use {
                     zis.copyTo(it, DEFAULT_BUFFER_SIZE)
                 }
             }
@@ -99,7 +107,7 @@
         // List [xt|uid|uid_tag].<start>-<end> files under the given directory.
         private fun getSortedListForPrefix(statsDir: File, prefix: String): List<File> {
             assertTrue(statsDir.exists())
-            return statsDir.list() { dir, name -> name.startsWith("$prefix.") }
+            return statsDir.list { _, name -> name.startsWith("$prefix.") }
                 .orEmpty()
                 .map { it -> File(statsDir, it) }
                 .sorted()
@@ -115,7 +123,8 @@
     fun testReadCollection_manyUids() {
         // The file cache is warmed up by the @BeforeClass method, so now the test can repeat
         // this a number of time to have a stable number.
-        repeat(TEST_REPEAT_COUNT) {
+        testFilesAssets.forEach {
+            val uidTestFiles = getSortedListForPrefix(it, "uid")
             val collection = NetworkStatsCollection(UID_COLLECTION_BUCKET_DURATION_MS)
             for (file in uidTestFiles) {
                 readFile(file, collection)
@@ -127,10 +136,10 @@
     fun testReadFromRecorder_manyUids() {
         val mockObserver = mock<NonMonotonicObserver<String>>()
         val mockDropBox = mock<DropBoxManager>()
-        repeat(TEST_REPEAT_COUNT) {
+        testFilesAssets.forEach {
             val recorder = NetworkStatsRecorder(
                 FileRotator(
-                    testFilesDir, PREFIX_UID, UID_RECORDER_ROTATE_AGE_MS, UID_RECORDER_DELETE_AGE_MS
+                    it, PREFIX_UID, UID_RECORDER_ROTATE_AGE_MS, UID_RECORDER_DELETE_AGE_MS
                 ),
                 mockObserver,
                 mockDropBox,
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index aae3425..bec9a4a 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -26,6 +26,7 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_ENTERPRISE;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOREGROUND;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_MMS;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED;
@@ -63,6 +64,7 @@
 import static com.android.modules.utils.build.SdkLevel.isAtLeastR;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastS;
 import static com.android.modules.utils.build.SdkLevel.isAtLeastT;
+import static com.android.modules.utils.build.SdkLevel.isAtLeastV;
 import static com.android.testutils.DevSdkIgnoreRuleKt.SC_V2;
 import static com.android.testutils.MiscAsserts.assertEmpty;
 import static com.android.testutils.MiscAsserts.assertThrows;
@@ -369,6 +371,9 @@
             .addCapability(NET_CAPABILITY_INTERNET)
             .addCapability(NET_CAPABILITY_EIMS)
             .addCapability(NET_CAPABILITY_NOT_METERED);
+        if (isAtLeastV()) {
+            netCap.addCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        }
         if (isAtLeastS()) {
             final ArraySet<Integer> allowedUids = new ArraySet<>();
             allowedUids.add(4);
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
index 0610774..93cc911 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyServiceClient.java
@@ -27,7 +27,7 @@
 import android.os.RemoteException;
 
 public class MyServiceClient {
-    private static final int TIMEOUT_MS = 5000;
+    private static final int TIMEOUT_MS = 20_000;
     private static final String PACKAGE = MyServiceClient.class.getPackage().getName();
     private static final String APP2_PACKAGE = PACKAGE + ".app2";
     private static final String SERVICE_NAME = APP2_PACKAGE + ".MyService";
diff --git a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
index 5937655..392cba9 100644
--- a/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
+++ b/tests/cts/net/src/android/net/cts/NetworkAgentTest.kt
@@ -704,6 +704,7 @@
                 argThat<NetworkInfo> { it.detailedState == NetworkInfo.DetailedState.CONNECTING },
                 any(LinkProperties::class.java),
                 any(NetworkCapabilities::class.java),
+                any(), // LocalNetworkConfig TODO : specify when it's public
                 any(NetworkScore::class.java),
                 any(NetworkAgentConfig::class.java),
                 eq(NetworkProvider.ID_NONE))
diff --git a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
index 637ed26..594f3fb 100644
--- a/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
+++ b/tests/cts/net/src/android/net/cts/NetworkRequestTest.java
@@ -19,8 +19,10 @@
 import static android.net.NetworkCapabilities.NET_CAPABILITY_DUN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_FOTA;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_MMS;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_SUPL;
 import static android.net.NetworkCapabilities.NET_CAPABILITY_TEMPORARILY_NOT_METERED;
 import static android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH;
@@ -28,6 +30,8 @@
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 
+import static com.android.testutils.DevSdkIgnoreRuleKt.VANILLA_ICE_CREAM;
+
 import static junit.framework.Assert.fail;
 
 import static org.junit.Assert.assertArrayEquals;
@@ -104,6 +108,23 @@
         verifyNoCapabilities(nr);
     }
 
+    @Test @IgnoreUpTo(Build.VERSION_CODES.R)
+    public void testForbiddenCapabilities() {
+        final NetworkRequest.Builder builder = new NetworkRequest.Builder();
+        builder.addForbiddenCapability(NET_CAPABILITY_MMS);
+        assertTrue(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        builder.removeForbiddenCapability(NET_CAPABILITY_MMS);
+        assertFalse(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.addCapability(NET_CAPABILITY_MMS);
+        assertFalse(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        assertTrue(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.addForbiddenCapability(NET_CAPABILITY_MMS);
+        assertTrue(builder.build().hasForbiddenCapability(NET_CAPABILITY_MMS));
+        assertFalse(builder.build().hasCapability(NET_CAPABILITY_MMS));
+        builder.clearCapabilities();
+        verifyNoCapabilities(builder.build());
+    }
+
     @Test @IgnoreUpTo(Build.VERSION_CODES.Q)
     public void testTemporarilyNotMeteredCapability() {
         assertTrue(new NetworkRequest.Builder()
@@ -472,6 +493,32 @@
         assertArrayEquals(netCapabilities, nr.getCapabilities());
     }
 
+    @Test @IgnoreUpTo(VANILLA_ICE_CREAM)
+    public void testDefaultCapabilities() {
+        final NetworkRequest defaultNR = new NetworkRequest.Builder().build();
+        assertTrue(defaultNR.hasForbiddenCapability(NET_CAPABILITY_LOCAL_NETWORK));
+        assertFalse(defaultNR.hasCapability(NET_CAPABILITY_LOCAL_NETWORK));
+        assertTrue(defaultNR.hasCapability(NET_CAPABILITY_NOT_VPN));
+
+        final NetworkCapabilities emptyNC =
+                NetworkCapabilities.Builder.withoutDefaultCapabilities().build();
+        assertFalse(defaultNR.canBeSatisfiedBy(emptyNC));
+
+        // defaultNC represent the capabilities of a network agent, so they must not contain
+        // forbidden capabilities by default.
+        final NetworkCapabilities defaultNC = new NetworkCapabilities.Builder().build();
+        assertArrayEquals(new int[0], defaultNC.getForbiddenCapabilities());
+        // A default NR can be satisfied by default NC.
+        assertTrue(defaultNR.canBeSatisfiedBy(defaultNC));
+
+        // Conversely, network requests have forbidden capabilities by default to manage
+        // backward compatibility, so test that these forbidden capabilities are in place.
+        // Starting in V, NET_CAPABILITY_LOCAL_NETWORK is introduced but is not seen by
+        // default, thanks to a default forbidden capability in NetworkRequest.
+        defaultNC.addCapability(NET_CAPABILITY_LOCAL_NETWORK);
+        assertFalse(defaultNR.canBeSatisfiedBy(defaultNC));
+    }
+
     @Test
     public void testBuildRequestFromExistingRequestWithBuilder() {
         assumeTrue(TestUtils.shouldTestSApis());
diff --git a/tests/integration/Android.bp b/tests/integration/Android.bp
index 12919ae..f705e34 100644
--- a/tests/integration/Android.bp
+++ b/tests/integration/Android.bp
@@ -45,6 +45,7 @@
         // order-dependent setup.
         "NetworkStackApiStableLib",
         "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
         "frameworks-net-integration-testutils",
         "kotlin-reflect",
         "mockito-target-extended-minus-junit4",
diff --git a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
index e264b55..9b082a4 100644
--- a/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
+++ b/tests/integration/src/com/android/server/net/integrationtests/ConnectivityServiceIntegrationTest.kt
@@ -40,11 +40,14 @@
 import android.os.IBinder
 import android.os.SystemConfigManager
 import android.os.UserHandle
+import android.os.VintfRuntimeInfo
 import android.testing.TestableContext
 import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.SystemUtil
 import com.android.connectivity.resources.R
+import com.android.net.module.util.BpfUtils
 import com.android.server.BpfNetMaps
 import com.android.server.ConnectivityService
 import com.android.server.NetworkAgentWrapper
@@ -53,6 +56,7 @@
 import com.android.server.connectivity.MockableSystemProperties
 import com.android.server.connectivity.MultinetworkPolicyTracker
 import com.android.server.connectivity.ProxyTracker
+import com.android.testutils.DeviceInfoUtils
 import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
 import com.android.testutils.TestableNetworkCallback
 import kotlin.test.assertEquals
@@ -60,6 +64,7 @@
 import kotlin.test.assertTrue
 import kotlin.test.fail
 import org.junit.After
+import org.junit.Assume
 import org.junit.Before
 import org.junit.BeforeClass
 import org.junit.Test
@@ -302,4 +307,25 @@
                     !it.hasCapability(NET_CAPABILITY_VALIDATED)
         }
     }
+
+    private fun isBpfGetCgroupProgramIdSupportedByKernel(): Boolean {
+        val kVersionString = VintfRuntimeInfo.getKernelVersion()
+        return DeviceInfoUtils.compareMajorMinorVersion(kVersionString, "4.19") >= 0
+    }
+
+    @Test
+    fun testBpfProgramAttachStatus() {
+        Assume.assumeTrue(isBpfGetCgroupProgramIdSupportedByKernel())
+
+        listOf(
+                BpfUtils.BPF_CGROUP_INET_INGRESS,
+                BpfUtils.BPF_CGROUP_INET_EGRESS,
+                BpfUtils.BPF_CGROUP_INET_SOCK_CREATE
+        ).forEach {
+            val ret = SystemUtil.runShellCommand(InstrumentationRegistry.getInstrumentation(),
+                    "cmd connectivity bpf-get-cgroup-program-id $it").trim()
+
+            assertTrue(Integer.parseInt(ret) > 0, "Unexpected output $ret for type $it")
+        }
+    }
 }
diff --git a/tests/unit/java/android/net/BpfNetMapsReaderTest.kt b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt
new file mode 100644
index 0000000..facb932
--- /dev/null
+++ b/tests/unit/java/android/net/BpfNetMapsReaderTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net
+
+import android.net.BpfNetMapsConstants.UID_RULES_CONFIGURATION_KEY
+import android.net.BpfNetMapsUtils.getMatchByFirewallChain
+import android.os.Build
+import com.android.net.module.util.IBpfMap
+import com.android.net.module.util.Struct.S32
+import com.android.net.module.util.Struct.U32
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.TestBpfMap
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// pre-T devices does not support Bpf.
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.S_V2)
+class BpfNetMapsReaderTest {
+    private val testConfigurationMap: IBpfMap<S32, U32> = TestBpfMap()
+    private val testUidOwnerMap: IBpfMap<S32, UidOwnerValue> = TestBpfMap()
+    private val bpfNetMapsReader = BpfNetMapsReader(
+        TestDependencies(testConfigurationMap, testUidOwnerMap))
+
+    class TestDependencies(
+        private val configMap: IBpfMap<S32, U32>,
+        private val uidOwnerMap: IBpfMap<S32, UidOwnerValue>
+    ) : BpfNetMapsReader.Dependencies() {
+        override fun getConfigurationMap() = configMap
+        override fun getUidOwnerMap() = uidOwnerMap
+    }
+
+    private fun doTestIsChainEnabled(chain: Int) {
+        testConfigurationMap.updateEntry(
+            UID_RULES_CONFIGURATION_KEY,
+            U32(getMatchByFirewallChain(chain))
+        )
+        assertTrue(bpfNetMapsReader.isChainEnabled(chain))
+        testConfigurationMap.updateEntry(UID_RULES_CONFIGURATION_KEY, U32(0))
+        assertFalse(bpfNetMapsReader.isChainEnabled(chain))
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun testIsChainEnabled() {
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_DOZABLE)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_STANDBY)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_POWERSAVE)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_RESTRICTED)
+        doTestIsChainEnabled(ConnectivityManager.FIREWALL_CHAIN_LOW_POWER_STANDBY)
+    }
+}
diff --git a/tests/unit/java/android/net/NetworkStatsHistoryTest.java b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
index 2170882..1e1fd35 100644
--- a/tests/unit/java/android/net/NetworkStatsHistoryTest.java
+++ b/tests/unit/java/android/net/NetworkStatsHistoryTest.java
@@ -54,6 +54,7 @@
 import com.android.frameworks.tests.net.R;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
+import com.android.testutils.SkipPresubmit;
 
 import org.junit.After;
 import org.junit.Test;
@@ -343,6 +344,7 @@
 
     }
 
+    @SkipPresubmit(reason = "Flaky: b/302325928; add to presubmit after fixing")
     @Test
     public void testFuzzing() throws Exception {
         try {
diff --git a/tests/unit/java/com/android/server/BpfNetMapsTest.java b/tests/unit/java/com/android/server/BpfNetMapsTest.java
index 5f280c6..da5f7e1 100644
--- a/tests/unit/java/com/android/server/BpfNetMapsTest.java
+++ b/tests/unit/java/com/android/server/BpfNetMapsTest.java
@@ -66,6 +66,7 @@
 import android.content.Context;
 import android.net.BpfNetMapsUtils;
 import android.net.INetd;
+import android.net.UidOwnerValue;
 import android.os.Build;
 import android.os.ServiceSpecificException;
 import android.system.ErrnoException;
diff --git a/tests/unit/java/com/android/server/ConnectivityServiceTest.java b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
index eb03157..11cece1 100755
--- a/tests/unit/java/com/android/server/ConnectivityServiceTest.java
+++ b/tests/unit/java/com/android/server/ConnectivityServiceTest.java
@@ -77,6 +77,7 @@
 import static android.net.ConnectivityManager.TYPE_ETHERNET;
 import static android.net.ConnectivityManager.TYPE_MOBILE;
 import static android.net.ConnectivityManager.TYPE_MOBILE_SUPL;
+import static android.net.ConnectivityManager.TYPE_NONE;
 import static android.net.ConnectivityManager.TYPE_VPN;
 import static android.net.ConnectivityManager.TYPE_WIFI;
 import static android.net.ConnectivitySettingsManager.PRIVATE_DNS_MODE_OFF;
@@ -418,7 +419,6 @@
 import com.android.server.connectivity.UidRangeUtils;
 import com.android.server.connectivity.Vpn;
 import com.android.server.connectivity.VpnProfileStore;
-import com.android.server.net.LockdownVpnTracker;
 import com.android.server.net.NetworkPinner;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
@@ -470,6 +470,7 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
@@ -934,24 +935,39 @@
         return appUid + (firstSdkSandboxUid - Process.FIRST_APPLICATION_UID);
     }
 
-    // This function assumes the UID range for user 0 ([1, 99999])
-    private static UidRangeParcel[] uidRangeParcelsExcludingUids(Integer... excludedUids) {
-        int start = 1;
-        Arrays.sort(excludedUids);
-        List<UidRangeParcel> parcels = new ArrayList<UidRangeParcel>();
+    // Create the list of ranges for the primary user (User 0), excluding excludedUids.
+    private static List<Range<Integer>> intRangesPrimaryExcludingUids(List<Integer> excludedUids) {
+        final List<Integer> excludedUidsList = new ArrayList<>(excludedUids);
+        // Uid 0 is always excluded
+        if (!excludedUidsList.contains(0)) {
+            excludedUidsList.add(0);
+        }
+        return intRangesExcludingUids(PRIMARY_USER, excludedUidsList);
+    }
+
+    private static List<Range<Integer>> intRangesExcludingUids(int userId,
+            List<Integer> excludedAppIds) {
+        final List<Integer> excludedUids = CollectionUtils.map(excludedAppIds,
+                appId -> UserHandle.getUid(userId, appId));
+        final int userBase = userId * UserHandle.PER_USER_RANGE;
+        final int maxUid = userBase + UserHandle.PER_USER_RANGE - 1;
+
+        int start = userBase;
+        Collections.sort(excludedUids);
+        final List<Range<Integer>> ranges = new ArrayList<>();
         for (int excludedUid : excludedUids) {
             if (excludedUid == start) {
                 start++;
             } else {
-                parcels.add(new UidRangeParcel(start, excludedUid - 1));
+                ranges.add(new Range<>(start, excludedUid - 1));
                 start = excludedUid + 1;
             }
         }
-        if (start <= 99999) {
-            parcels.add(new UidRangeParcel(start, 99999));
+        if (start <= maxUid) {
+            ranges.add(new Range<>(start, maxUid));
         }
 
-        return parcels.toArray(new UidRangeParcel[0]);
+        return ranges;
     }
 
     private void waitForIdle() {
@@ -1499,15 +1515,7 @@
 
         private int mVpnType = VpnManager.TYPE_VPN_SERVICE;
         private UnderlyingNetworkInfo mUnderlyingNetworkInfo;
-
-        // These ConditionVariables allow tests to wait for LegacyVpnRunner to be stopped/started.
-        // TODO: this scheme is ad-hoc and error-prone because it does not fail if, for example, the
-        // test expects two starts in a row, or even if the production code calls start twice in a
-        // row. find a better solution. Simply putting a method to create a LegacyVpnRunner into
-        // Vpn.Dependencies doesn't work because LegacyVpnRunner is not a static class and has
-        // extensive access into the internals of Vpn.
-        private ConditionVariable mStartLegacyVpnCv = new ConditionVariable();
-        private ConditionVariable mStopVpnRunnerCv = new ConditionVariable();
+        private String mSessionKey;
 
         public MockVpn(int userId) {
             super(startHandlerThreadAndReturnLooper(), mServiceContext,
@@ -1668,36 +1676,52 @@
             mInterface = null;
         }
 
-        @Override
-        public void startLegacyVpnRunner() {
-            mStartLegacyVpnCv.open();
+        private synchronized void startLegacyVpn() {
+            updateState(DetailedState.CONNECTING, "startLegacyVpn");
         }
 
-        public void expectStartLegacyVpnRunner() {
-            assertTrue("startLegacyVpnRunner not called after " + TIMEOUT_MS + " ms",
-                    mStartLegacyVpnCv.block(TIMEOUT_MS));
-
-            // startLegacyVpn calls stopVpnRunnerPrivileged, which will open mStopVpnRunnerCv, just
-            // before calling startLegacyVpnRunner. Restore mStopVpnRunnerCv, so the test can expect
-            // that the VpnRunner is stopped and immediately restarted by calling
-            // expectStartLegacyVpnRunner() and expectStopVpnRunnerPrivileged() back-to-back.
-            mStopVpnRunnerCv = new ConditionVariable();
+        // Mock the interaction of IkeV2VpnRunner start. In the context of ConnectivityService,
+        // setVpnDefaultForUids() is the main interaction and a sessionKey is stored.
+        private synchronized void startPlatformVpn() {
+            updateState(DetailedState.CONNECTING, "startPlatformVpn");
+            mSessionKey = UUID.randomUUID().toString();
+            // Assuming no disallowed applications
+            final Set<Range<Integer>> ranges = UidRange.toIntRanges(Set.of(PRIMARY_UIDRANGE));
+            mCm.setVpnDefaultForUids(mSessionKey, ranges);
+            // Wait for vpn network preference updates.
+            waitForIdle();
         }
 
         @Override
-        public void stopVpnRunnerPrivileged() {
-            if (mVpnRunner != null) {
-                super.stopVpnRunnerPrivileged();
-                disconnect();
-                mStartLegacyVpnCv = new ConditionVariable();
+        public void startLegacyVpnPrivileged(VpnProfile profile,
+                @Nullable Network underlying, @NonNull LinkProperties egress) {
+            switch (profile.type) {
+                case VpnProfile.TYPE_IKEV2_IPSEC_RSA:
+                case VpnProfile.TYPE_IKEV2_IPSEC_USER_PASS:
+                case VpnProfile.TYPE_IKEV2_IPSEC_PSK:
+                case VpnProfile.TYPE_IKEV2_FROM_IKE_TUN_CONN_PARAMS:
+                    startPlatformVpn();
+                    break;
+                case VpnProfile.TYPE_L2TP_IPSEC_PSK:
+                case VpnProfile.TYPE_L2TP_IPSEC_RSA:
+                case VpnProfile.TYPE_IPSEC_XAUTH_PSK:
+                case VpnProfile.TYPE_IPSEC_XAUTH_RSA:
+                case VpnProfile.TYPE_IPSEC_HYBRID_RSA:
+                    startLegacyVpn();
+                    break;
+                default:
+                    fail("Unknown VPN profile type");
             }
-            mVpnRunner = null;
-            mStopVpnRunnerCv.open();
         }
 
-        public void expectStopVpnRunnerPrivileged() {
-            assertTrue("stopVpnRunnerPrivileged not called after " + TIMEOUT_MS + " ms",
-                    mStopVpnRunnerCv.block(TIMEOUT_MS));
+        @Override
+        public synchronized void stopVpnRunnerPrivileged() {
+            if (mSessionKey != null) {
+                // Clear vpn network preference.
+                mCm.setVpnDefaultForUids(mSessionKey, Collections.EMPTY_LIST);
+                mSessionKey = null;
+            }
+            disconnect();
         }
 
         @Override
@@ -1711,6 +1735,14 @@
                 UnderlyingNetworkInfo underlyingNetworkInfo) {
             mUnderlyingNetworkInfo = underlyingNetworkInfo;
         }
+
+        @Override
+        public synchronized boolean setUnderlyingNetworks(@Nullable Network[] networks) {
+            if (!mAgentRegistered) return false;
+            mMockNetworkAgent.setUnderlyingNetworks(
+                    (networks == null) ? null : Arrays.asList(networks));
+            return true;
+        }
     }
 
     private UidRangeParcel[] toUidRangeStableParcels(final @NonNull Set<UidRange> ranges) {
@@ -1722,6 +1754,12 @@
         return ranges.stream().map(r -> new UidRangeParcel(r, r)).toArray(UidRangeParcel[]::new);
     }
 
+    private static UidRangeParcel[] intToUidRangeStableParcels(
+            final @NonNull List<Range<Integer>> ranges) {
+        return ranges.stream().map(
+                r -> new UidRangeParcel(r.getLower(), r.getUpper())).toArray(UidRangeParcel[]::new);
+    }
+
     private void assertVpnTransportInfo(NetworkCapabilities nc, int type) {
         assertNotNull(nc);
         final TransportInfo ti = nc.getTransportInfo();
@@ -1854,6 +1892,8 @@
     private static final UserHandle TERTIARY_USER_HANDLE = new UserHandle(TERTIARY_USER);
 
     private static final int RESTRICTED_USER = 1;
+    private static final UidRange RESTRICTED_USER_UIDRANGE =
+            UidRange.createForUser(UserHandle.of(RESTRICTED_USER));
     private static final UserInfo RESTRICTED_USER_INFO = new UserInfo(RESTRICTED_USER, "",
             UserInfo.FLAG_RESTRICTED);
     static {
@@ -2259,6 +2299,11 @@
         }
 
         @Override
+        public int getBpfProgramId(final int attachType) {
+            return 0;
+        }
+
+        @Override
         public BroadcastOptionsShim makeBroadcastOptionsShim(BroadcastOptions options) {
             reset(mBroadcastOptionsShim);
             return mBroadcastOptionsShim;
@@ -9404,11 +9449,11 @@
                         && c.hasTransport(TRANSPORT_WIFI));
         callback.expectCaps(mWiFiAgent, c -> c.hasCapability(NET_CAPABILITY_VALIDATED));
 
-        doReturn(UserHandle.getUid(RESTRICTED_USER, VPN_UID)).when(mPackageManager)
-                .getPackageUidAsUser(ALWAYS_ON_PACKAGE, RESTRICTED_USER);
-
-        // New user added
-        mMockVpn.onUserAdded(RESTRICTED_USER);
+        // New user added, this updates the Vpn uids, coverage in VpnTest.
+        // This is equivalent to `mMockVpn.onUserAdded(RESTRICTED_USER);`
+        final Set<UidRange> ranges = uidRangesForUids(uid);
+        ranges.add(RESTRICTED_USER_UIDRANGE);
+        mMockVpn.setUids(ranges);
 
         // Expect that the VPN UID ranges contain both |uid| and the UID range for the newly-added
         // restricted user.
@@ -9433,7 +9478,9 @@
                 && !c.hasTransport(TRANSPORT_WIFI));
 
         // User removed and expect to lose the UID range for the restricted user.
-        mMockVpn.onUserRemoved(RESTRICTED_USER);
+        // This updates the Vpn uids, coverage in VpnTest.
+        // This is equivalent to `mMockVpn.onUserRemoved(RESTRICTED_USER);`
+        mMockVpn.setUids(uidRangesForUids(uid));
 
         // Expect that the VPN gains the UID range for the restricted user, and that the capability
         // change made just before that (i.e., loss of TRANSPORT_WIFI) is preserved.
@@ -9474,8 +9521,16 @@
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Enable always-on VPN lockdown. The main user loses network access because no VPN is up.
-        final ArrayList<String> allowList = new ArrayList<>();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Coverage in VpnTest.
+        final List<Integer> excludedUids = new ArrayList<>();
+        excludedUids.add(VPN_UID);
+        if (mDeps.isAtLeastT()) {
+            // On T onwards, the corresponding SDK sandbox UID should also be excluded
+            excludedUids.add(toSdkSandboxUid(VPN_UID));
+        }
+        final List<Range<Integer>> primaryRanges = intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRanges);
+
         waitForIdle();
         assertNull(mCm.getActiveNetworkForUid(uid));
         // This is arguably overspecified: a UID that is not running doesn't have an active network.
@@ -9484,32 +9539,28 @@
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Start the restricted profile, and check that the UID within it loses network access.
-        doReturn(UserHandle.getUid(RESTRICTED_USER, VPN_UID)).when(mPackageManager)
-                .getPackageUidAsUser(ALWAYS_ON_PACKAGE, RESTRICTED_USER);
-        doReturn(asList(PRIMARY_USER_INFO, RESTRICTED_USER_INFO)).when(mUserManager)
-                .getAliveUsers();
         // TODO: check that VPN app within restricted profile still has access, etc.
-        mMockVpn.onUserAdded(RESTRICTED_USER);
-        final Intent addedIntent = new Intent(ACTION_USER_ADDED);
-        addedIntent.putExtra(Intent.EXTRA_USER, UserHandle.of(RESTRICTED_USER));
-        addedIntent.putExtra(Intent.EXTRA_USER_HANDLE, RESTRICTED_USER);
-        processBroadcast(addedIntent);
+        // Add a restricted user.
+        // This is equivalent to `mMockVpn.onUserAdded(RESTRICTED_USER);`, coverage in VpnTest.
+        final List<Range<Integer>> restrictedRanges =
+                intRangesExcludingUids(RESTRICTED_USER, excludedUids);
+        mCm.setRequireVpnForUids(true, restrictedRanges);
+        waitForIdle();
+
         assertNull(mCm.getActiveNetworkForUid(uid));
         assertNull(mCm.getActiveNetworkForUid(restrictedUid));
 
         // Stop the restricted profile, and check that the UID within it has network access again.
-        doReturn(asList(PRIMARY_USER_INFO)).when(mUserManager).getAliveUsers();
+        // Remove the restricted user.
+        // This is equivalent to `mMockVpn.onUserRemoved(RESTRICTED_USER);`, coverage in VpnTest.
+        mCm.setRequireVpnForUids(false, restrictedRanges);
+        waitForIdle();
 
-        // Send a USER_REMOVED broadcast and expect to lose the UID range for the restricted user.
-        mMockVpn.onUserRemoved(RESTRICTED_USER);
-        final Intent removedIntent = new Intent(ACTION_USER_REMOVED);
-        removedIntent.putExtra(Intent.EXTRA_USER, UserHandle.of(RESTRICTED_USER));
-        removedIntent.putExtra(Intent.EXTRA_USER_HANDLE, RESTRICTED_USER);
-        processBroadcast(removedIntent);
         assertNull(mCm.getActiveNetworkForUid(uid));
         assertNotNull(mCm.getActiveNetworkForUid(restrictedUid));
 
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
+
         waitForIdle();
     }
 
@@ -9962,18 +10013,20 @@
                 new Handler(ConnectivityThread.getInstanceLooper()));
 
         final int uid = Process.myUid();
-        final ArrayList<String> allowList = new ArrayList<>();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
-        waitForIdle();
 
-        final Set<Integer> excludedUids = new ArraySet<Integer>();
+        // Enable always-on VPN lockdown, coverage in VpnTest.
+        final List<Integer> excludedUids = new ArrayList<Integer>();
         excludedUids.add(VPN_UID);
         if (mDeps.isAtLeastT()) {
             // On T onwards, the corresponding SDK sandbox UID should also be excluded
             excludedUids.add(toSdkSandboxUid(VPN_UID));
         }
-        final UidRangeParcel[] uidRangeParcels = uidRangeParcelsExcludingUids(
-                excludedUids.toArray(new Integer[0]));
+
+        final List<Range<Integer>> primaryRanges = intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRanges);
+        waitForIdle();
+
+        final UidRangeParcel[] uidRangeParcels = intToUidRangeStableParcels(primaryRanges);
         InOrder inOrder = inOrder(mMockNetd);
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcels);
 
@@ -9993,7 +10046,8 @@
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
 
         // Disable lockdown, expect to see the network unblocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
+        waitForIdle();
         callback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         vpnUidCallback.assertNoCallback();
@@ -10006,22 +10060,25 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Add our UID to the allowlist and re-enable lockdown, expect network is not blocked.
-        allowList.add(TEST_PACKAGE_NAME);
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Add our UID to the allowlist, expect network is not blocked. Coverage in VpnTest.
+        excludedUids.add(uid);
+        if (mDeps.isAtLeastT()) {
+            // On T onwards, the corresponding SDK sandbox UID should also be excluded
+            excludedUids.add(toSdkSandboxUid(uid));
+        }
+        final List<Range<Integer>> primaryRangesExcludingUid =
+                intRangesPrimaryExcludingUids(excludedUids);
+        mCm.setRequireVpnForUids(true, primaryRangesExcludingUid);
+        waitForIdle();
+
         callback.assertNoCallback();
         defaultCallback.assertNoCallback();
         vpnUidCallback.assertNoCallback();
         vpnUidDefaultCallback.assertNoCallback();
         vpnDefaultCallbackAsUid.assertNoCallback();
 
-        excludedUids.add(uid);
-        if (mDeps.isAtLeastT()) {
-            // On T onwards, the corresponding SDK sandbox UID should also be excluded
-            excludedUids.add(toSdkSandboxUid(uid));
-        }
-        final UidRangeParcel[] uidRangeParcelsAlsoExcludingUs = uidRangeParcelsExcludingUids(
-                excludedUids.toArray(new Integer[0]));
+        final UidRangeParcel[] uidRangeParcelsAlsoExcludingUs =
+                intToUidRangeStableParcels(primaryRangesExcludingUid);
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcelsAlsoExcludingUs);
         assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
         assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
@@ -10044,15 +10101,15 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Disable lockdown, remove our UID from the allowlist, and re-enable lockdown.
-        // Everything should now be blocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        // Disable lockdown
+        mCm.setRequireVpnForUids(false, primaryRangesExcludingUid);
         waitForIdle();
         expectNetworkRejectNonSecureVpn(inOrder, false, uidRangeParcelsAlsoExcludingUs);
-        allowList.clear();
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        // Remove our UID from the allowlist, and re-enable lockdown.
+        mCm.setRequireVpnForUids(true, primaryRanges);
         waitForIdle();
         expectNetworkRejectNonSecureVpn(inOrder, true, uidRangeParcels);
+        // Everything should now be blocked.
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, true, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10065,7 +10122,7 @@
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
 
         // Disable lockdown. Everything is unblocked.
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(false, primaryRanges);
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> !cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, false, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10077,36 +10134,8 @@
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
 
-        // Enable and disable an always-on VPN package without lockdown. Expect no changes.
-        reset(mMockNetd);
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, false /* lockdown */, allowList);
-        inOrder.verify(mMockNetd, never()).networkRejectNonSecureVpn(anyBoolean(), any());
-        callback.assertNoCallback();
-        defaultCallback.assertNoCallback();
-        vpnUidCallback.assertNoCallback();
-        vpnUidDefaultCallback.assertNoCallback();
-        vpnDefaultCallbackAsUid.assertNoCallback();
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-        assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-
-        mMockVpn.setAlwaysOnPackage(null, false /* lockdown */, allowList);
-        inOrder.verify(mMockNetd, never()).networkRejectNonSecureVpn(anyBoolean(), any());
-        callback.assertNoCallback();
-        defaultCallback.assertNoCallback();
-        vpnUidCallback.assertNoCallback();
-        vpnUidDefaultCallback.assertNoCallback();
-        vpnDefaultCallbackAsUid.assertNoCallback();
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetworkForUid(VPN_UID));
-        assertEquals(mWiFiAgent.getNetwork(), mCm.getActiveNetwork());
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-        assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
-
         // Enable lockdown and connect a VPN. The VPN is not blocked.
-        mMockVpn.setAlwaysOnPackage(ALWAYS_ON_PACKAGE, true /* lockdown */, allowList);
+        mCm.setRequireVpnForUids(true, primaryRanges);
         defaultCallback.expect(BLOCKED_STATUS, mWiFiAgent, cb -> cb.getBlocked());
         assertBlockedCallbackInAnyOrder(callback, true, mWiFiAgent, mCellAgent);
         vpnUidCallback.assertNoCallback();
@@ -10192,7 +10221,7 @@
         doAsUid(Process.SYSTEM_UID, () -> mCm.unregisterNetworkCallback(perUidCb));
     }
 
-    private VpnProfile setupLegacyLockdownVpn() {
+    private VpnProfile setupLockdownVpn(int profileType) {
         final String profileName = "testVpnProfile";
         final byte[] profileTag = profileName.getBytes(StandardCharsets.UTF_8);
         doReturn(profileTag).when(mVpnProfileStore).get(Credentials.LOCKDOWN_VPN);
@@ -10201,7 +10230,9 @@
         profile.name = "My VPN";
         profile.server = "192.0.2.1";
         profile.dnsServers = "8.8.8.8";
-        profile.type = VpnProfile.TYPE_IPSEC_XAUTH_PSK;
+        profile.ipsecIdentifier = "My ipsecIdentifier";
+        profile.ipsecSecret = "My PSK";
+        profile.type = profileType;
         final byte[] encodedProfile = profile.encode();
         doReturn(encodedProfile).when(mVpnProfileStore).get(Credentials.VPN + profileName);
 
@@ -10219,8 +10250,8 @@
         mMockVpn.connect(true);
     }
 
-    @Test
-    public void testLegacyLockdownVpn() throws Exception {
+    private void doTestLockdownVpn(VpnProfile profile, boolean expectSetVpnDefaultForUids)
+            throws Exception {
         mServiceContext.setPermission(
                 Manifest.permission.CONTROL_VPN, PERMISSION_GRANTED);
 
@@ -10235,107 +10266,64 @@
         mCm.registerSystemDefaultNetworkCallback(systemDefaultCallback,
                 new Handler(ConnectivityThread.getInstanceLooper()));
 
-        // Pretend lockdown VPN was configured.
-        final VpnProfile profile = setupLegacyLockdownVpn();
-
-        // LockdownVpnTracker disables the Vpn teardown code and enables lockdown.
-        // Check the VPN's state before it does so.
-        assertTrue(mMockVpn.getEnableTeardown());
-        assertFalse(mMockVpn.getLockdown());
-
-        // VMSHandlerThread was used inside VpnManagerService and taken into LockDownVpnTracker.
-        // VpnManagerService was decoupled from this test but this handlerThread is still required
-        // in LockDownVpnTracker. Keep it until LockDownVpnTracker related verification is moved to
-        // its own test.
-        final HandlerThread VMSHandlerThread = new HandlerThread("TestVpnManagerService");
-        VMSHandlerThread.start();
-
-        // LockdownVpnTracker is created from VpnManagerService but VpnManagerService is decoupled
-        // from ConnectivityServiceTest. Create it directly to simulate LockdownVpnTracker is
-        // created.
-        // TODO: move LockdownVpnTracker related tests to its own test.
-        // Lockdown VPN disables teardown and enables lockdown.
-        final LockdownVpnTracker lockdownVpnTracker = new LockdownVpnTracker(mServiceContext,
-                VMSHandlerThread.getThreadHandler(), mMockVpn, profile);
-        lockdownVpnTracker.init();
-        assertFalse(mMockVpn.getEnableTeardown());
-        assertTrue(mMockVpn.getLockdown());
+        // Init lockdown state to simulate LockdownVpnTracker behavior.
+        mCm.setLegacyLockdownVpnEnabled(true);
+        mMockVpn.setEnableTeardown(false);
+        final List<Range<Integer>> ranges =
+                intRangesPrimaryExcludingUids(Collections.EMPTY_LIST /* excludedeUids */);
+        mCm.setRequireVpnForUids(true /* requireVpn */, ranges);
 
         // Bring up a network.
-        // Expect nothing to happen because the network does not have an IPv4 default route: legacy
-        // VPN only supports IPv4.
         final LinkProperties cellLp = new LinkProperties();
         cellLp.setInterfaceName("rmnet0");
-        cellLp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
-        cellLp.addRoute(new RouteInfo(new IpPrefix("::/0"), null, "rmnet0"));
-        mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR, cellLp);
-        mCellAgent.connect(false /* validated */);
-        callback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        systemDefaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        waitForIdle();
-        assertNull(mMockVpn.getAgent());
-
-        // Add an IPv4 address. Ideally the VPN should start, but it doesn't because nothing calls
-        // LockdownVpnTracker#handleStateChangedLocked. This is a bug.
-        // TODO: consider fixing this.
         cellLp.addLinkAddress(new LinkAddress("192.0.2.2/25"));
         cellLp.addRoute(new RouteInfo(new IpPrefix("0.0.0.0/0"), null, "rmnet0"));
-        mCellAgent.sendLinkProperties(cellLp);
-        callback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        defaultCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        systemDefaultCallback.expect(LINK_PROPERTIES_CHANGED, mCellAgent);
-        waitForIdle();
-        assertNull(mMockVpn.getAgent());
-
-        // Disconnect, then try again with a network that supports IPv4 at connection time.
-        // Expect lockdown VPN to come up.
-        ExpectedBroadcast b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
-        mCellAgent.disconnect();
-        callback.expect(LOST, mCellAgent);
-        defaultCallback.expect(LOST, mCellAgent);
-        systemDefaultCallback.expect(LOST, mCellAgent);
-        b1.expectBroadcast();
-
         // When lockdown VPN is active, the NetworkInfo state in CONNECTIVITY_ACTION is overwritten
         // with the state of the VPN network. So expect a CONNECTING broadcast.
-        b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTING);
+        final ExpectedBroadcast b = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTING);
         mCellAgent = new TestNetworkAgentWrapper(TRANSPORT_CELLULAR, cellLp);
         mCellAgent.connect(false /* validated */);
         callback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
         defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
         systemDefaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mCellAgent);
-        b1.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
+        b.expectBroadcast();
+        // Simulate LockdownVpnTracker attempting to start the VPN since it received the
+        // systemDefault callback.
+        mMockVpn.startLegacyVpnPrivileged(profile, mCellAgent.getNetwork(), cellLp);
+        if (expectSetVpnDefaultForUids) {
+            // setVpnDefaultForUids() releases the original network request and creates a VPN
+            // request so LOST callback is received.
+            defaultCallback.expect(LOST, mCellAgent);
+            // Due to the VPN default request, getActiveNetworkInfo() gets the mNoServiceNetwork
+            // as the network satisfier.
+            assertNull(mCm.getActiveNetworkInfo());
+        } else {
+            assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_VPN, DetailedState.BLOCKED);
         assertExtraInfoFromCmBlocked(mCellAgent);
 
-        // TODO: it would be nice if we could simply rely on the production code here, and have
-        // LockdownVpnTracker start the VPN, have the VPN code register its NetworkAgent with
-        // ConnectivityService, etc. That would require duplicating a fair bit of code from the
-        // Vpn tests around how to mock out LegacyVpnRunner. But even if we did that, this does not
-        // work for at least two reasons:
-        // 1. In this test, calling registerNetworkAgent does not actually result in an agent being
-        //    registered. This is because nothing calls onNetworkMonitorCreated, which is what
-        //    actually ends up causing handleRegisterNetworkAgent to be called. Code in this test
-        //    that wants to register an agent must use TestNetworkAgentWrapper.
-        // 2. Even if we exposed Vpn#agentConnect to the test, and made MockVpn#agentConnect call
-        //    the TestNetworkAgentWrapper code, this would deadlock because the
-        //    TestNetworkAgentWrapper code cannot be called on the handler thread since it calls
-        //    waitForIdle().
-        mMockVpn.expectStartLegacyVpnRunner();
-        b1 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
-        ExpectedBroadcast b2 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTED);
+        final ExpectedBroadcast b2 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
+        final ExpectedBroadcast b3 = expectConnectivityAction(TYPE_MOBILE, DetailedState.CONNECTED);
         establishLegacyLockdownVpn(mCellAgent.getNetwork());
         callback.expectAvailableThenValidatedCallbacks(mMockVpn);
         defaultCallback.expectAvailableThenValidatedCallbacks(mMockVpn);
         systemDefaultCallback.assertNoCallback();
-        NetworkCapabilities vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
-        b1.expectBroadcast();
+        final NetworkCapabilities vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
         b2.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
+        b3.expectBroadcast();
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
@@ -10356,53 +10344,78 @@
         wifiNc.addCapability(NET_CAPABILITY_NOT_METERED);
         mWiFiAgent = new TestNetworkAgentWrapper(TRANSPORT_WIFI, wifiLp, wifiNc);
 
-        b1 = expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b4 =
+                expectConnectivityAction(TYPE_MOBILE, DetailedState.DISCONNECTED);
         // Wifi is CONNECTING because the VPN isn't up yet.
-        b2 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTING);
-        ExpectedBroadcast b3 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b5 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTING);
         mWiFiAgent.connect(false /* validated */);
-        b1.expectBroadcast();
-        b2.expectBroadcast();
-        b3.expectBroadcast();
-        mMockVpn.expectStopVpnRunnerPrivileged();
-        mMockVpn.expectStartLegacyVpnRunner();
-
-        // TODO: why is wifi not blocked? Is it because when this callback is sent, the VPN is still
-        // connected, so the network is not considered blocked by the lockdown UID ranges? But the
-        // fact that a VPN is connected should only result in the VPN itself being unblocked, not
-        // any other network. Bug in isUidBlockedByVpn?
+        // Wifi is not blocked since VPN network is still connected.
         callback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        defaultCallback.assertNoCallback();
+        systemDefaultCallback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        b4.expectBroadcast();
+        b5.expectBroadcast();
+
+        // Simulate LockdownVpnTracker restarting the VPN since it received the systemDefault
+        // callback with different network.
+        final ExpectedBroadcast b6 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        mMockVpn.stopVpnRunnerPrivileged();
+        mMockVpn.startLegacyVpnPrivileged(profile, mWiFiAgent.getNetwork(), wifiLp);
+        // VPN network is disconnected (to restart)
         callback.expect(LOST, mMockVpn);
         defaultCallback.expect(LOST, mMockVpn);
+        // The network preference is cleared when VPN is disconnected so it receives callbacks for
+        // the system-wide default.
         defaultCallback.expectAvailableCallbacksUnvalidatedAndBlocked(mWiFiAgent);
-        systemDefaultCallback.expectAvailableCallbacksUnvalidated(mWiFiAgent);
+        if (expectSetVpnDefaultForUids) {
+            // setVpnDefaultForUids() releases the original network request and creates a VPN
+            // request so LOST callback is received.
+            defaultCallback.expect(LOST, mWiFiAgent);
+        }
+        systemDefaultCallback.assertNoCallback();
+        b6.expectBroadcast();
 
         // While the VPN is reconnecting on the new network, everything is blocked.
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the mNoServiceNetwork
+            // as the network satisfier.
+            assertNull(mCm.getActiveNetworkInfo());
+        } else {
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.BLOCKED);
         assertNetworkInfo(TYPE_VPN, DetailedState.BLOCKED);
         assertExtraInfoFromCmBlocked(mWiFiAgent);
 
         // The VPN comes up again on wifi.
-        b1 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
-        b2 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTED);
+        final ExpectedBroadcast b7 = expectConnectivityAction(TYPE_VPN, DetailedState.CONNECTED);
+        final ExpectedBroadcast b8 = expectConnectivityAction(TYPE_WIFI, DetailedState.CONNECTED);
         establishLegacyLockdownVpn(mWiFiAgent.getNetwork());
         callback.expectAvailableThenValidatedCallbacks(mMockVpn);
         defaultCallback.expectAvailableThenValidatedCallbacks(mMockVpn);
         systemDefaultCallback.assertNoCallback();
-        b1.expectBroadcast();
-        b2.expectBroadcast();
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        b7.expectBroadcast();
+        b8.expectBroadcast();
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
         assertExtraInfoFromCmPresent(mWiFiAgent);
-        vpnNc = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
-        assertTrue(vpnNc.hasTransport(TRANSPORT_VPN));
-        assertTrue(vpnNc.hasTransport(TRANSPORT_WIFI));
-        assertFalse(vpnNc.hasTransport(TRANSPORT_CELLULAR));
-        assertTrue(vpnNc.hasCapability(NET_CAPABILITY_NOT_METERED));
+        final NetworkCapabilities vpnNc2 = mCm.getNetworkCapabilities(mMockVpn.getNetwork());
+        assertTrue(vpnNc2.hasTransport(TRANSPORT_VPN));
+        assertTrue(vpnNc2.hasTransport(TRANSPORT_WIFI));
+        assertFalse(vpnNc2.hasTransport(TRANSPORT_CELLULAR));
+        assertTrue(vpnNc2.hasCapability(NET_CAPABILITY_NOT_METERED));
 
         // Disconnect cell. Nothing much happens since it's not the default network.
         mCellAgent.disconnect();
@@ -10410,30 +10423,61 @@
         defaultCallback.assertNoCallback();
         systemDefaultCallback.assertNoCallback();
 
-        assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        if (expectSetVpnDefaultForUids) {
+            // Due to the VPN default request, getActiveNetworkInfo() gets the VPN network as the
+            // network satisfier which has TYPE_VPN.
+            assertActiveNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
+        } else {
+            // LegacyVpnRunner does not call setVpnDefaultsForUids(), which means
+            // getActiveNetworkInfo() can only return the info for the system-wide default instead.
+            // This should be fixed, but LegacyVpnRunner will be removed soon anyway.
+            assertActiveNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
+        }
         assertNetworkInfo(TYPE_MOBILE, DetailedState.DISCONNECTED);
         assertNetworkInfo(TYPE_WIFI, DetailedState.CONNECTED);
         assertNetworkInfo(TYPE_VPN, DetailedState.CONNECTED);
         assertExtraInfoFromCmPresent(mWiFiAgent);
 
-        b1 = expectConnectivityAction(TYPE_WIFI, DetailedState.DISCONNECTED);
-        b2 = expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b9 =
+                expectConnectivityAction(TYPE_WIFI, DetailedState.DISCONNECTED);
+        final ExpectedBroadcast b10 =
+                expectConnectivityAction(TYPE_VPN, DetailedState.DISCONNECTED);
         mWiFiAgent.disconnect();
         callback.expect(LOST, mWiFiAgent);
-        systemDefaultCallback.expect(LOST, mWiFiAgent);
-        b1.expectBroadcast();
         callback.expectCaps(mMockVpn, c -> !c.hasTransport(TRANSPORT_WIFI));
-        mMockVpn.expectStopVpnRunnerPrivileged();
+        defaultCallback.expectCaps(mMockVpn, c -> !c.hasTransport(TRANSPORT_WIFI));
+        systemDefaultCallback.expect(LOST, mWiFiAgent);
+        // TODO: There should only be one LOST callback. Since the WIFI network is underlying a VPN
+        // network, ConnectivityService#propagateUnderlyingNetworkCapabilities() causes a rematch to
+        // occur. Notably, this happens before setting the satisfiers of its network requests to
+        // null. Since the satisfiers are set to null in the rematch, an extra LOST callback is
+        // called.
+        systemDefaultCallback.expect(LOST, mWiFiAgent);
+        b9.expectBroadcast();
+        mMockVpn.stopVpnRunnerPrivileged();
         callback.expect(LOST, mMockVpn);
-        b2.expectBroadcast();
+        defaultCallback.expect(LOST, mMockVpn);
+        b10.expectBroadcast();
 
-        VMSHandlerThread.quitSafely();
-        VMSHandlerThread.join();
+        assertNoCallbacks(callback, defaultCallback, systemDefaultCallback);
+    }
+
+    @Test
+    public void testLockdownVpn_LegacyVpnRunner() throws Exception {
+        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IPSEC_XAUTH_PSK);
+        doTestLockdownVpn(profile, false /* expectSetVpnDefaultForUids */);
+    }
+
+    @Test
+    public void testLockdownVpn_Ikev2VpnRunner() throws Exception {
+        final VpnProfile profile = setupLockdownVpn(VpnProfile.TYPE_IKEV2_IPSEC_PSK);
+        doTestLockdownVpn(profile, true /* expectSetVpnDefaultForUids */);
     }
 
     @Test @IgnoreUpTo(Build.VERSION_CODES.S_V2)
     public void testLockdownSetFirewallUidRule() throws Exception {
-        final Set<Range<Integer>> lockdownRange = UidRange.toIntRanges(Set.of(PRIMARY_UIDRANGE));
+        final List<Range<Integer>> lockdownRange =
+                intRangesPrimaryExcludingUids(Collections.EMPTY_LIST /* excludedeUids */);
         // Enable Lockdown
         mCm.setRequireVpnForUids(true /* requireVpn */, lockdownRange);
         waitForIdle();
@@ -12798,7 +12842,8 @@
 
     private NetworkAgentInfo fakeNai(NetworkCapabilities nc, NetworkInfo networkInfo) {
         return new NetworkAgentInfo(null, new Network(NET_ID), networkInfo, new LinkProperties(),
-                nc, new NetworkScore.Builder().setLegacyInt(0).build(),
+                nc, null /* localNetworkConfig */,
+                new NetworkScore.Builder().setLegacyInt(0).build(),
                 mServiceContext, null, new NetworkAgentConfig(), mService, null, null, 0,
                 INVALID_UID, TEST_LINGER_DELAY_MS, mQosCallbackTracker,
                 new ConnectivityService.Dependencies());
diff --git a/tests/unit/java/com/android/server/HandlerUtilsTest.kt b/tests/unit/java/com/android/server/HandlerUtilsTest.kt
new file mode 100644
index 0000000..62bb651
--- /dev/null
+++ b/tests/unit/java/com/android/server/HandlerUtilsTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.os.HandlerThread
+import com.android.server.connectivity.HandlerUtils
+import com.android.testutils.DevSdkIgnoreRunner
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+
+const val THREAD_BLOCK_TIMEOUT_MS = 1000L
+const val TEST_REPEAT_COUNT = 100
+@RunWith(DevSdkIgnoreRunner::class)
+class HandlerUtilsTest {
+    val handlerThread = HandlerThread("HandlerUtilsTestHandlerThread").also {
+        it.start()
+    }
+    val handler = handlerThread.threadHandler
+
+    @Test
+    fun testRunWithScissors() {
+        // Repeat the test a fair amount of times to ensure that it does not pass by chance.
+        repeat(TEST_REPEAT_COUNT) {
+            var result = false
+            HandlerUtils.runWithScissors(handler, {
+                assertEquals(Thread.currentThread(), handlerThread)
+                result = true
+            }, THREAD_BLOCK_TIMEOUT_MS)
+            // Assert that the result is modified on the handler thread, but can also be seen from
+            // the current thread. The assertion should pass if the runWithScissors provides
+            // the guarantee where the assignment happens-before the assertion.
+            assertTrue(result)
+        }
+    }
+
+    @After
+    fun tearDown() {
+        handlerThread.quitSafely()
+        handlerThread.join()
+    }
+}
diff --git a/tests/unit/java/com/android/server/NsdServiceTest.java b/tests/unit/java/com/android/server/NsdServiceTest.java
index 71bd330..771edb2 100644
--- a/tests/unit/java/com/android/server/NsdServiceTest.java
+++ b/tests/unit/java/com/android/server/NsdServiceTest.java
@@ -35,14 +35,17 @@
 import static android.net.nsd.NsdManager.FAILURE_BAD_PARAMETERS;
 import static android.net.nsd.NsdManager.FAILURE_INTERNAL_ERROR;
 import static android.net.nsd.NsdManager.FAILURE_OPERATION_NOT_RUNNING;
+
 import static com.android.networkstack.apishim.api33.ConstantsShim.REGISTER_NSD_OFFLOAD_ENGINE;
 import static com.android.server.NsdService.DEFAULT_RUNNING_APP_ACTIVE_IMPORTANCE_CUTOFF;
 import static com.android.server.NsdService.MdnsListener;
 import static com.android.server.NsdService.NO_TRANSACTION;
 import static com.android.server.NsdService.parseTypeAndSubtype;
 import static com.android.testutils.ContextUtils.mockService;
+
 import static libcore.junit.util.compat.CoreCompatChangeRule.DisableCompatChanges;
 import static libcore.junit.util.compat.CoreCompatChangeRule.EnableCompatChanges;
+
 import static org.junit.Assert.assertArrayEquals;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
@@ -220,7 +223,7 @@
                 anyInt(), anyString(), anyString(), anyString(), anyInt());
         doReturn(false).when(mDeps).isMdnsDiscoveryManagerEnabled(any(Context.class));
         doReturn(mDiscoveryManager).when(mDeps)
-                .makeMdnsDiscoveryManager(any(), any(), any());
+                .makeMdnsDiscoveryManager(any(), any(), any(), any());
         doReturn(mMulticastLock).when(mWifiManager).createMulticastLock(any());
         doReturn(mSocketProvider).when(mDeps).makeMdnsSocketProvider(any(), any(), any(), any());
         doReturn(DEFAULT_RUNNING_APP_ACTIVE_IMPORTANCE_CUTOFF).when(mDeps).getDeviceConfigInt(
diff --git a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
index e6c0c83..07883ff 100644
--- a/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
+++ b/tests/unit/java/com/android/server/connectivity/LingerMonitorTest.java
@@ -372,9 +372,10 @@
         caps.addCapability(0);
         caps.addTransportType(transport);
         NetworkAgentInfo nai = new NetworkAgentInfo(null, new Network(netId), info,
-                new LinkProperties(), caps, new NetworkScore.Builder().setLegacyInt(50).build(),
-                mCtx, null, new NetworkAgentConfig.Builder().build(), mConnService, mNetd,
-                mDnsResolver, NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
+                new LinkProperties(), caps, null /* localNetworkConfiguration */,
+                new NetworkScore.Builder().setLegacyInt(50).build(), mCtx, null,
+                new NetworkAgentConfig.Builder().build(), mConnService, mNetd, mDnsResolver,
+                NetworkProvider.ID_NONE, Binder.getCallingUid(), TEST_LINGER_DELAY_MS,
                 mQosCallbackTracker, new ConnectivityService.Dependencies());
         if (setEverValidated) {
             // As tests in this class deal with testing lingering, most tests are interested
diff --git a/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
new file mode 100644
index 0000000..8adf309
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.connectivity
+
+import android.net.INetd
+import android.os.Build
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.mock
+import kotlin.test.assertFailsWith
+
+@RunWith(DevSdkIgnoreRunner::class)
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class RoutingCoordinatorServiceTest {
+    val mNetd = mock(INetd::class.java)
+    val mService = RoutingCoordinatorService(mNetd)
+
+    @Test
+    fun testInterfaceForward() {
+        val inOrder = inOrder(mNetd)
+
+        mService.addInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdEnableForwarding(any())
+        inOrder.verify(mNetd).tetherAddForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdAddInterfaceForward("from1", "to1")
+
+        mService.addInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).tetherAddForward("from2", "to1")
+        inOrder.verify(mNetd).ipfwdAddInterfaceForward("from2", "to1")
+
+        assertFailsWith<IllegalStateException> {
+            // Can't add the same pair again
+            mService.addInterfaceForward("from2", "to1")
+        }
+
+        mService.removeInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward("from1", "to1")
+        inOrder.verify(mNetd).tetherRemoveForward("from1", "to1")
+
+        mService.removeInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).ipfwdRemoveInterfaceForward("from2", "to1")
+        inOrder.verify(mNetd).tetherRemoveForward("from2", "to1")
+
+        inOrder.verify(mNetd).ipfwdDisableForwarding(any())
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/VpnTest.java b/tests/unit/java/com/android/server/connectivity/VpnTest.java
index 56346ad..48cfe77 100644
--- a/tests/unit/java/com/android/server/connectivity/VpnTest.java
+++ b/tests/unit/java/com/android/server/connectivity/VpnTest.java
@@ -579,6 +579,18 @@
     }
 
     @Test
+    public void testAlwaysOnWithoutLockdown() throws Exception {
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+        assertTrue(vpn.setAlwaysOnPackage(
+                PKGS[1], false /* lockdown */, null /* lockdownAllowlist */));
+        verify(mConnectivityManager, never()).setRequireVpnForUids(anyBoolean(), any());
+
+        assertTrue(vpn.setAlwaysOnPackage(
+                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
+        verify(mConnectivityManager, never()).setRequireVpnForUids(anyBoolean(), any());
+    }
+
+    @Test
     public void testLockdownChangingPackage() throws Exception {
         final Vpn vpn = createVpn(PRIMARY_USER.id);
         final Range<Integer> user = PRIMARY_USER_RANGE;
@@ -724,6 +736,37 @@
     }
 
     @Test
+    public void testLockdownSystemUser() throws Exception {
+        final Vpn vpn = createVpn(SYSTEM_USER_ID);
+
+        // Uid 0 is always excluded and PKG_UIDS[1] is the uid of the VPN.
+        final List<Integer> excludedUids = new ArrayList<>(List.of(0, PKG_UIDS[1]));
+        final List<Range<Integer>> ranges = makeVpnUidRange(SYSTEM_USER_ID, excludedUids);
+
+        // Set always-on with lockdown.
+        assertTrue(vpn.setAlwaysOnPackage(
+                PKGS[1], true /* lockdown */, null /* lockdownAllowlist */));
+        verify(mConnectivityManager).setRequireVpnForUids(true, ranges);
+
+        // Disable always-on with lockdown.
+        assertTrue(vpn.setAlwaysOnPackage(
+                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
+        verify(mConnectivityManager).setRequireVpnForUids(false, ranges);
+
+        // Set always-on with lockdown and allow the app PKGS[2].
+        excludedUids.add(PKG_UIDS[2]);
+        final List<Range<Integer>> ranges2 = makeVpnUidRange(SYSTEM_USER_ID, excludedUids);
+        assertTrue(vpn.setAlwaysOnPackage(
+                PKGS[1], true /* lockdown */, Collections.singletonList(PKGS[2])));
+        verify(mConnectivityManager).setRequireVpnForUids(true, ranges2);
+
+        // Disable always-on with lockdown.
+        assertTrue(vpn.setAlwaysOnPackage(
+                null /* packageName */, false /* lockdown */, null /* lockdownAllowlist */));
+        verify(mConnectivityManager).setRequireVpnForUids(false, ranges2);
+    }
+
+    @Test
     public void testLockdownRuleRepeatability() throws Exception {
         final Vpn vpn = createVpn(PRIMARY_USER.id);
         final UidRangeParcel[] primaryUserRangeParcel = new UidRangeParcel[] {
@@ -788,6 +831,101 @@
     }
 
     @Test
+    public void testOnUserAddedAndRemoved_restrictedUser() throws Exception {
+        final InOrder order = inOrder(mMockNetworkAgent);
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+        final Set<Range<Integer>> initialRange = rangeSet(PRIMARY_USER_RANGE);
+        // Note since mVpnProfile is a Ikev2VpnProfile, this starts an IkeV2VpnRunner.
+        startLegacyVpn(vpn, mVpnProfile);
+        // Set an initial Uid range and mock the network agent
+        vpn.mNetworkCapabilities.setUids(initialRange);
+        vpn.mNetworkAgent = mMockNetworkAgent;
+
+        // Add the restricted user
+        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
+        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
+        // Expect restricted user range to be added to the NetworkCapabilities.
+        final Set<Range<Integer>> expectRestrictedRange =
+                rangeSet(PRIMARY_USER_RANGE, uidRangeForUser(RESTRICTED_PROFILE_A.id));
+        assertEquals(expectRestrictedRange, vpn.mNetworkCapabilities.getUids());
+        order.verify(mMockNetworkAgent).doSendNetworkCapabilities(
+                argThat(nc -> expectRestrictedRange.equals(nc.getUids())));
+
+        // Remove the restricted user
+        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
+        // Expect restricted user range to be removed from the NetworkCapabilities.
+        assertEquals(initialRange, vpn.mNetworkCapabilities.getUids());
+        order.verify(mMockNetworkAgent).doSendNetworkCapabilities(
+                argThat(nc -> initialRange.equals(nc.getUids())));
+    }
+
+    @Test
+    public void testOnUserAddedAndRemoved_restrictedUserLockdown() throws Exception {
+        final UidRangeParcel[] primaryUserRangeParcel = new UidRangeParcel[] {
+                new UidRangeParcel(PRIMARY_USER_RANGE.getLower(), PRIMARY_USER_RANGE.getUpper())};
+        final Range<Integer> restrictedUserRange = uidRangeForUser(RESTRICTED_PROFILE_A.id);
+        final UidRangeParcel[] restrictedUserRangeParcel = new UidRangeParcel[] {
+                new UidRangeParcel(restrictedUserRange.getLower(), restrictedUserRange.getUpper())};
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+
+        // Set lockdown calls setRequireVpnForUids
+        vpn.setLockdown(true);
+        verify(mConnectivityManager).setRequireVpnForUids(true, toRanges(primaryUserRangeParcel));
+
+        // Add the restricted user
+        doReturn(true).when(mUserManager).canHaveRestrictedProfile();
+        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
+        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
+
+        // Expect restricted user range to be added.
+        verify(mConnectivityManager).setRequireVpnForUids(true,
+                toRanges(restrictedUserRangeParcel));
+
+        // Mark as partial indicates that the user is removed, mUserManager.getAliveUsers() does not
+        // return the restricted user but it is still returned in mUserManager.getUserInfo().
+        RESTRICTED_PROFILE_A.partial = true;
+        // Remove the restricted user
+        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
+        verify(mConnectivityManager).setRequireVpnForUids(false,
+                toRanges(restrictedUserRangeParcel));
+        // reset to avoid affecting other tests since RESTRICTED_PROFILE_A is static.
+        RESTRICTED_PROFILE_A.partial = false;
+    }
+
+    @Test
+    public void testOnUserAddedAndRemoved_restrictedUserAlwaysOn() throws Exception {
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+
+        // setAlwaysOnPackage() calls setRequireVpnForUids()
+        assertTrue(vpn.setAlwaysOnPackage(
+                PKGS[0], true /* lockdown */, null /* lockdownAllowlist */));
+        final List<Integer> excludedUids = List.of(PKG_UIDS[0]);
+        final List<Range<Integer>> primaryRanges =
+                makeVpnUidRange(PRIMARY_USER.id, excludedUids);
+        verify(mConnectivityManager).setRequireVpnForUids(true, primaryRanges);
+
+        // Add the restricted user
+        doReturn(true).when(mUserManager).canHaveRestrictedProfile();
+        setMockedUsers(PRIMARY_USER, RESTRICTED_PROFILE_A);
+        vpn.onUserAdded(RESTRICTED_PROFILE_A.id);
+
+        final List<Range<Integer>> restrictedRanges =
+                makeVpnUidRange(RESTRICTED_PROFILE_A.id, excludedUids);
+        // Expect restricted user range to be added.
+        verify(mConnectivityManager).setRequireVpnForUids(true, restrictedRanges);
+
+        // Mark as partial indicates that the user is removed, mUserManager.getAliveUsers() does not
+        // return the restricted user but it is still returned in mUserManager.getUserInfo().
+        RESTRICTED_PROFILE_A.partial = true;
+        // Remove the restricted user
+        vpn.onUserRemoved(RESTRICTED_PROFILE_A.id);
+        verify(mConnectivityManager).setRequireVpnForUids(false, restrictedRanges);
+
+        // reset to avoid affecting other tests since RESTRICTED_PROFILE_A is static.
+        RESTRICTED_PROFILE_A.partial = false;
+    }
+
+    @Test
     public void testPrepare_throwSecurityExceptionWhenGivenPackageDoesNotBelongToTheCaller()
             throws Exception {
         mTestDeps.mIgnoreCallingUidChecks = false;
@@ -1002,12 +1140,12 @@
 
         // List in keystore is not changed, but UID for the removed packages is no longer exempted.
         assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 vpn.mNetworkCapabilities.getUids());
         ArgumentCaptor<NetworkCapabilities> ncCaptor =
                 ArgumentCaptor.forClass(NetworkCapabilities.class);
         verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 ncCaptor.getValue().getUids());
 
         reset(mMockNetworkAgent);
@@ -1019,26 +1157,28 @@
 
         // List in keystore is not changed and the uid list should be updated in the net cap.
         assertEquals(Arrays.asList(PKGS), vpn.getAppExclusionList(TEST_VPN_PKG));
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 vpn.mNetworkCapabilities.getUids());
         verify(mMockNetworkAgent).doSendNetworkCapabilities(ncCaptor.capture());
-        assertEquals(makeVpnUidRange(PRIMARY_USER.id, newExcludedUids),
+        assertEquals(makeVpnUidRangeSet(PRIMARY_USER.id, newExcludedUids),
                 ncCaptor.getValue().getUids());
     }
 
-    private Set<Range<Integer>> makeVpnUidRange(int userId, List<Integer> excludedList) {
+    private List<Range<Integer>> makeVpnUidRange(int userId, List<Integer> excludedAppIdList) {
         final SortedSet<Integer> list = new TreeSet<>();
 
         final int userBase = userId * UserHandle.PER_USER_RANGE;
-        for (int uid : excludedList) {
-            final int applicationUid = UserHandle.getUid(userId, uid);
-            list.add(applicationUid);
-            list.add(Process.toSdkSandboxUid(applicationUid)); // Add Sdk Sandbox UID
+        for (int appId : excludedAppIdList) {
+            final int uid = UserHandle.getUid(userId, appId);
+            list.add(uid);
+            if (Process.isApplicationUid(uid)) {
+                list.add(Process.toSdkSandboxUid(uid)); // Add Sdk Sandbox UID
+            }
         }
 
         final int minUid = userBase;
         final int maxUid = userBase + UserHandle.PER_USER_RANGE - 1;
-        final Set<Range<Integer>> ranges = new ArraySet<>();
+        final List<Range<Integer>> ranges = new ArrayList<>();
 
         // Iterate the list to create the ranges between each uid.
         int start = minUid;
@@ -1059,6 +1199,10 @@
         return ranges;
     }
 
+    private Set<Range<Integer>> makeVpnUidRangeSet(int userId, List<Integer> excludedAppIdList) {
+        return new ArraySet<>(makeVpnUidRange(userId, excludedAppIdList));
+    }
+
     @Test
     public void testSetAndGetAppExclusionListRestrictedUser() throws Exception {
         final Vpn vpn = prepareVpnForVerifyAppExclusionList();
@@ -1839,6 +1983,22 @@
         // a subsequent CL.
     }
 
+    @Test
+    public void testStartLegacyVpnIpv6() throws Exception {
+        setMockedUsers(PRIMARY_USER);
+        final Vpn vpn = createVpn(PRIMARY_USER.id);
+        final LinkProperties lp = new LinkProperties();
+        lp.setInterfaceName(EGRESS_IFACE);
+        lp.addLinkAddress(new LinkAddress("2001:db8::1/64"));
+        final RouteInfo defaultRoute = new RouteInfo(
+                new IpPrefix(Inet6Address.ANY, 0), null, EGRESS_IFACE);
+        lp.addRoute(defaultRoute);
+
+        // IllegalStateException thrown since legacy VPN only supports IPv4.
+        assertThrows(IllegalStateException.class,
+                () -> vpn.startLegacyVpn(mVpnProfile, EGRESS_NETWORK, lp));
+    }
+
     private Vpn startLegacyVpn(final Vpn vpn, final VpnProfile vpnProfile) throws Exception {
         setMockedUsers(PRIMARY_USER);
 
@@ -2988,8 +3148,15 @@
         profile.mppe = useMppe;
 
         doReturn(new Network[] { new Network(101) }).when(mConnectivityManager).getAllNetworks();
-        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(any(), any(),
-                any(), any(), any(), any(), anyInt());
+        doReturn(new Network(102)).when(mConnectivityManager).registerNetworkAgent(
+                any(), // INetworkAgent
+                any(), // NetworkInfo
+                any(), // LinkProperties
+                any(), // NetworkCapabilities
+                any(), // LocalNetworkConfig
+                any(), // NetworkScore
+                any(), // NetworkAgentConfig
+                anyInt()); // provider ID
 
         final Vpn vpn = startLegacyVpn(createVpn(PRIMARY_USER.id), profile);
         final TestDeps deps = (TestDeps) vpn.mDeps;
@@ -3011,8 +3178,15 @@
                 assertEquals("nomppe", mtpdArgs[argsPrefix.length]);
             }
 
-            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    any(), any(), any(), any(), anyInt());
+            verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(
+                    any(), // INetworkAgent
+                    any(), // NetworkInfo
+                    any(), // LinkProperties
+                    any(), // NetworkCapabilities
+                    any(), // LocalNetworkConfig
+                    any(), // NetworkScore
+                    any(), // NetworkAgentConfig
+                    anyInt()); // provider ID
         }, () -> { // Cleanup
                 vpn.mVpnRunner.exitVpnRunner();
                 deps.getStateFile().delete(); // set to delete on exit, but this deletes it earlier
@@ -3037,7 +3211,7 @@
             .thenReturn(new Network[] { new Network(101) });
 
         when(mConnectivityManager.registerNetworkAgent(any(), any(), any(), any(),
-                any(), any(), anyInt())).thenAnswer(invocation -> {
+                any(), any(), any(), anyInt())).thenAnswer(invocation -> {
                     // The runner has registered an agent and is now ready.
                     legacyRunnerReady.open();
                     return new Network(102);
@@ -3063,7 +3237,7 @@
             ArgumentCaptor<NetworkCapabilities> ncCaptor =
                     ArgumentCaptor.forClass(NetworkCapabilities.class);
             verify(mConnectivityManager, timeout(10_000)).registerNetworkAgent(any(), any(),
-                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), anyInt());
+                    lpCaptor.capture(), ncCaptor.capture(), any(), any(), any(), anyInt());
 
             // In this test the expected address is always v4 so /32.
             // Note that the interface needs to be specified because RouteInfo objects stored in
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
index 8eace1c..a86f923 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsAdvertiserTest.kt
@@ -153,10 +153,10 @@
         thread.start()
         doReturn(TEST_HOSTNAME).`when`(mockDeps).generateHostname()
         doReturn(mockInterfaceAdvertiser1).`when`(mockDeps).makeAdvertiser(eq(mockSocket1),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any()
+                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
         )
         doReturn(mockInterfaceAdvertiser2).`when`(mockDeps).makeAdvertiser(eq(mockSocket2),
-                any(), any(), any(), any(), eq(TEST_HOSTNAME), any()
+                any(), any(), any(), any(), eq(TEST_HOSTNAME), any(), any()
         )
         doReturn(true).`when`(mockInterfaceAdvertiser1).isProbing(anyInt())
         doReturn(true).`when`(mockInterfaceAdvertiser2).isProbing(anyInt())
@@ -202,6 +202,7 @@
             any(),
             intAdvCbCaptor.capture(),
             eq(TEST_HOSTNAME),
+            any(),
             any()
         )
 
@@ -259,10 +260,10 @@
         val intAdvCbCaptor1 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         val intAdvCbCaptor2 = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor1.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockDeps).makeAdvertiser(eq(mockSocket2), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor2.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(
                 anyInt(), eq(ALL_NETWORKS_SERVICE), eq(TEST_SUBTYPE))
@@ -367,7 +368,7 @@
 
         val intAdvCbCaptor = ArgumentCaptor.forClass(MdnsInterfaceAdvertiser.Callback::class.java)
         verify(mockDeps).makeAdvertiser(eq(mockSocket1), eq(listOf(TEST_LINKADDR)),
-                eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any()
+                eq(thread.looper), any(), intAdvCbCaptor.capture(), eq(TEST_HOSTNAME), any(), any()
         )
         verify(mockInterfaceAdvertiser1).addService(eq(SERVICE_ID_1),
                 argThat { it.matches(SERVICE_1) }, eq(null))
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
index e869b91..331a5b6 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -106,7 +106,7 @@
         doReturn(thread.getLooper()).when(socketClient).getLooper();
         doReturn(true).when(socketClient).supportsRequestingSpecificNetworks();
         discoveryManager = new MdnsDiscoveryManager(executorProvider, socketClient,
-                sharedLog) {
+                sharedLog, MdnsFeatureFlags.newBuilder().build()) {
                     @Override
                     MdnsServiceTypeClient createServiceTypeClient(@NonNull String serviceType,
                             @NonNull SocketKey socketKey) {
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
index a67dc5e..db41a6a 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsInterfaceAdvertiserTest.kt
@@ -77,6 +77,7 @@
     private val announcer = mock(MdnsAnnouncer::class.java)
     private val prober = mock(MdnsProber::class.java)
     private val sharedlog = SharedLog("MdnsInterfaceAdvertiserTest")
+    private val flags = MdnsFeatureFlags.newBuilder().build()
     @Suppress("UNCHECKED_CAST")
     private val probeCbCaptor = ArgumentCaptor.forClass(PacketRepeaterCallback::class.java)
             as ArgumentCaptor<PacketRepeaterCallback<ProbingInfo>>
@@ -99,15 +100,14 @@
             cb,
             deps,
             TEST_HOSTNAME,
-            sharedlog
+            sharedlog,
+            flags
         )
     }
 
     @Before
     fun setUp() {
-        doReturn(repository).`when`(deps).makeRecordRepository(any(),
-            eq(TEST_HOSTNAME)
-        )
+        doReturn(repository).`when`(deps).makeRecordRepository(any(), eq(TEST_HOSTNAME), any())
         doReturn(replySender).`when`(deps).makeReplySender(anyString(), any(), any(), any(), any())
         doReturn(announcer).`when`(deps).makeMdnsAnnouncer(anyString(), any(), any(), any(), any())
         doReturn(prober).`when`(deps).makeMdnsProber(anyString(), any(), any(), any(), any())
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
index 3701b0c..8917ed3 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsMultinetworkSocketClientTest.java
@@ -42,6 +42,7 @@
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -69,6 +70,7 @@
     @Mock private SocketCreationCallback mSocketCreationCallback;
     @Mock private SharedLog mSharedLog;
     private MdnsMultinetworkSocketClient mSocketClient;
+    private HandlerThread mHandlerThread;
     private Handler mHandler;
     private SocketKey mSocketKey;
 
@@ -76,14 +78,23 @@
     public void setUp() throws SocketException {
         MockitoAnnotations.initMocks(this);
 
-        final HandlerThread thread = new HandlerThread("MdnsMultinetworkSocketClientTest");
-        thread.start();
-        mHandler = new Handler(thread.getLooper());
+        mHandlerThread = new HandlerThread("MdnsMultinetworkSocketClientTest");
+        mHandlerThread.start();
+        mHandler = new Handler(mHandlerThread.getLooper());
         mSocketKey = new SocketKey(1000 /* interfaceIndex */);
-        mSocketClient = new MdnsMultinetworkSocketClient(thread.getLooper(), mProvider, mSharedLog);
+        mSocketClient = new MdnsMultinetworkSocketClient(
+                mHandlerThread.getLooper(), mProvider, mSharedLog);
         mHandler.post(() -> mSocketClient.setCallback(mCallback));
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mHandlerThread != null) {
+            mHandlerThread.quitSafely();
+            mHandlerThread.join();
+        }
+    }
+
     private SocketCallback expectSocketCallback() {
         return expectSocketCallback(mListener, mNetwork);
     }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
index c9b502e..f26f7e1 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
@@ -78,6 +78,7 @@
         override fun getInterfaceInetAddresses(iface: NetworkInterface) =
                 Collections.enumeration(TEST_ADDRESSES.map { it.address })
     }
+    private val flags = MdnsFeatureFlags.newBuilder().build()
 
     @Before
     fun setUp() {
@@ -92,7 +93,7 @@
 
     @Test
     fun testAddServiceAndProbe() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         assertEquals(0, repository.servicesCount)
         assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
                 null /* subtype */))
@@ -127,7 +128,7 @@
 
     @Test
     fun testAddAndConflicts() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         assertFailsWith(NameConflictException::class) {
             repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_1, null /* subtype */)
@@ -139,7 +140,7 @@
 
     @Test
     fun testInvalidReuseOfServiceId() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         assertFailsWith(IllegalArgumentException::class) {
             repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_2, null /* subtype */)
@@ -148,7 +149,7 @@
 
     @Test
     fun testHasActiveService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         assertFalse(repository.hasActiveService(TEST_SERVICE_ID_1))
 
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
@@ -165,7 +166,7 @@
 
     @Test
     fun testExitAnnouncements() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
 
@@ -195,7 +196,7 @@
 
     @Test
     fun testExitAnnouncements_WithSubtype() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, TEST_SUBTYPE)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
 
@@ -231,7 +232,7 @@
 
     @Test
     fun testExitingServiceReAdded() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
         repository.exitService(TEST_SERVICE_ID_1)
@@ -246,7 +247,7 @@
 
     @Test
     fun testOnProbingSucceeded() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         val announcementInfo = repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
                 TEST_SUBTYPE)
         repository.onAdvertisementSent(TEST_SERVICE_ID_1, 2 /* sentPacketCount */)
@@ -371,7 +372,7 @@
 
     @Test
     fun testGetOffloadPacket() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
         val serviceType = arrayOf("_testservice", "_tcp", "local")
@@ -433,7 +434,7 @@
 
     @Test
     fun testGetReplyCaseInsensitive() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         val questionsCaseInSensitive =
             listOf(MdnsPointerRecord(arrayOf("_TESTSERVICE", "_TCP", "local"),
@@ -463,7 +464,7 @@
     }
 
     private fun doGetReplyTest(subtype: String?) {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, subtype)
         val queriedName = if (subtype == null) arrayOf("_testservice", "_tcp", "local")
         else arrayOf(subtype, "_sub", "_testservice", "_tcp", "local")
@@ -551,7 +552,7 @@
 
     @Test
     fun testGetConflictingServices() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -579,7 +580,7 @@
 
     @Test
     fun testGetConflictingServicesCaseInsensitive() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -607,7 +608,7 @@
 
     @Test
     fun testGetConflictingServices_IdenticalService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -636,7 +637,7 @@
 
     @Test
     fun testGetConflictingServicesCaseInsensitive_IdenticalService() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1, null /* subtype */)
         repository.addService(TEST_SERVICE_ID_2, TEST_SERVICE_2, null /* subtype */)
 
@@ -665,7 +666,7 @@
 
     @Test
     fun testGetServiceRepliedRequestsCount() {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME)
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
         repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1)
         // Verify that there is no packet replied.
         assertEquals(MdnsConstants.NO_PACKET,
@@ -690,6 +691,68 @@
         assertEquals(MdnsConstants.NO_PACKET,
                 repository.getServiceRepliedRequestsCount(TEST_SERVICE_ID_2))
     }
+
+    @Test
+    fun testIncludeInetAddressRecordsInProbing() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME,
+                MdnsFeatureFlags.newBuilder().setIncludeInetAddressRecordsInProbing(true).build())
+        repository.updateAddresses(TEST_ADDRESSES)
+        assertEquals(0, repository.servicesCount)
+        assertEquals(-1, repository.addService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
+                null /* subtype */))
+        assertEquals(1, repository.servicesCount)
+
+        val probingInfo = repository.setServiceProbing(TEST_SERVICE_ID_1)
+        assertNotNull(probingInfo)
+        assertTrue(repository.isProbing(TEST_SERVICE_ID_1))
+
+        assertEquals(TEST_SERVICE_ID_1, probingInfo.serviceId)
+        val packet = probingInfo.getPacket(0)
+
+        assertEquals(MdnsConstants.FLAGS_QUERY, packet.flags)
+        assertEquals(0, packet.answers.size)
+        assertEquals(0, packet.additionalRecords.size)
+
+        assertEquals(2, packet.questions.size)
+        val expectedName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+        assertContentEquals(listOf(
+            MdnsAnyRecord(expectedName, false /* unicast */),
+            MdnsAnyRecord(TEST_HOSTNAME, false /* unicast */),
+        ), packet.questions)
+
+        assertEquals(4, packet.authorityRecords.size)
+        assertContentEquals(listOf(
+            MdnsServiceRecord(
+                expectedName,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                0 /* servicePriority */,
+                0 /* serviceWeight */,
+                TEST_PORT,
+                TEST_HOSTNAME),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[0].address),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[1].address),
+            MdnsInetAddressRecord(
+                TEST_HOSTNAME,
+                0L /* receiptTimeMillis */,
+                false /* cacheFlush */,
+                120_000L /* ttlMillis */,
+                TEST_ADDRESSES[2].address)
+        ), packet.authorityRecords)
+
+        assertContentEquals(intArrayOf(TEST_SERVICE_ID_1), repository.clearServices())
+    }
 }
 
 private fun MdnsRecordRepository.initWithService(
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
index b43bcf7..2b3b834 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceCacheTest.kt
@@ -19,6 +19,7 @@
 import android.os.Build
 import android.os.Handler
 import android.os.HandlerThread
+import com.android.server.connectivity.mdns.MdnsServiceCache.CacheKey
 import com.android.testutils.DevSdkIgnoreRule
 import com.android.testutils.DevSdkIgnoreRunner
 import java.util.concurrent.CompletableFuture
@@ -43,13 +44,12 @@
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
 class MdnsServiceCacheTest {
     private val socketKey = SocketKey(null /* network */, INTERFACE_INDEX)
+    private val cacheKey1 = CacheKey(SERVICE_TYPE_1, socketKey)
+    private val cacheKey2 = CacheKey(SERVICE_TYPE_2, socketKey)
     private val thread = HandlerThread(MdnsServiceCacheTest::class.simpleName)
     private val handler by lazy {
         Handler(thread.looper)
     }
-    private val serviceCache by lazy {
-        MdnsServiceCache(thread.looper)
-    }
 
     @Before
     fun setUp() {
@@ -61,6 +61,11 @@
         thread.quitSafely()
     }
 
+    private fun makeFlags(isExpiredServicesRemovalEnabled: Boolean = false) =
+            MdnsFeatureFlags.Builder()
+                    .setIsExpiredServicesRemovalEnabled(isExpiredServicesRemovalEnabled)
+                    .build()
+
     private fun <T> runningOnHandlerAndReturn(functor: (() -> T)): T {
         val future = CompletableFuture<T>()
         handler.post {
@@ -70,46 +75,50 @@
     }
 
     private fun addOrUpdateService(
-            serviceType: String,
-            socketKey: SocketKey,
+            serviceCache: MdnsServiceCache,
+            cacheKey: CacheKey,
             service: MdnsResponse
-    ): Unit = runningOnHandlerAndReturn {
-        serviceCache.addOrUpdateService(serviceType, socketKey, service)
+    ): Unit = runningOnHandlerAndReturn { serviceCache.addOrUpdateService(cacheKey, service) }
+
+    private fun removeService(
+            serviceCache: MdnsServiceCache,
+            serviceName: String,
+            cacheKey: CacheKey
+    ): Unit = runningOnHandlerAndReturn { serviceCache.removeService(serviceName, cacheKey) }
+
+    private fun getService(
+            serviceCache: MdnsServiceCache,
+            serviceName: String,
+            cacheKey: CacheKey,
+    ): MdnsResponse? = runningOnHandlerAndReturn {
+        serviceCache.getCachedService(serviceName, cacheKey)
     }
 
-    private fun removeService(serviceName: String, serviceType: String, socketKey: SocketKey):
-            Unit = runningOnHandlerAndReturn {
-        serviceCache.removeService(serviceName, serviceType, socketKey) }
-
-    private fun getService(serviceName: String, serviceType: String, socketKey: SocketKey):
-            MdnsResponse? = runningOnHandlerAndReturn {
-        serviceCache.getCachedService(serviceName, serviceType, socketKey) }
-
-    private fun getServices(serviceType: String, socketKey: SocketKey): List<MdnsResponse> =
-        runningOnHandlerAndReturn { serviceCache.getCachedServices(serviceType, socketKey) }
+    private fun getServices(
+            serviceCache: MdnsServiceCache,
+            cacheKey: CacheKey,
+    ): List<MdnsResponse> = runningOnHandlerAndReturn { serviceCache.getCachedServices(cacheKey) }
 
     @Test
     fun testAddAndRemoveService() {
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
-        var response = getService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
+        var response = getService(serviceCache, SERVICE_NAME_1, cacheKey1)
         assertNotNull(response)
         assertEquals(SERVICE_NAME_1, response.serviceInstanceName)
-        removeService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
-        response = getService(SERVICE_NAME_1, SERVICE_TYPE_1, socketKey)
+        removeService(serviceCache, SERVICE_NAME_1, cacheKey1)
+        response = getService(serviceCache, SERVICE_NAME_1, cacheKey1)
         assertNull(response)
     }
 
     @Test
     fun testGetCachedServices_multipleServiceTypes() {
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
-        addOrUpdateService(
-                SERVICE_TYPE_1, socketKey, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1))
-        addOrUpdateService(
-                SERVICE_TYPE_2, socketKey, createResponse(SERVICE_NAME_2, SERVICE_TYPE_2))
+        val serviceCache = MdnsServiceCache(thread.looper, makeFlags())
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_1, SERVICE_TYPE_1))
+        addOrUpdateService(serviceCache, cacheKey1, createResponse(SERVICE_NAME_2, SERVICE_TYPE_1))
+        addOrUpdateService(serviceCache, cacheKey2, createResponse(SERVICE_NAME_2, SERVICE_TYPE_2))
 
-        val responses1 = getServices(SERVICE_TYPE_1, socketKey)
+        val responses1 = getServices(serviceCache, cacheKey1)
         assertEquals(2, responses1.size)
         assertTrue(responses1.stream().anyMatch { response ->
             response.serviceInstanceName == SERVICE_NAME_1
@@ -117,19 +126,19 @@
         assertTrue(responses1.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
         })
-        val responses2 = getServices(SERVICE_TYPE_2, socketKey)
+        val responses2 = getServices(serviceCache, cacheKey2)
         assertEquals(1, responses2.size)
         assertTrue(responses2.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
         })
 
-        removeService(SERVICE_NAME_2, SERVICE_TYPE_1, socketKey)
-        val responses3 = getServices(SERVICE_TYPE_1, socketKey)
+        removeService(serviceCache, SERVICE_NAME_2, cacheKey1)
+        val responses3 = getServices(serviceCache, cacheKey1)
         assertEquals(1, responses3.size)
         assertTrue(responses3.any { response ->
             response.serviceInstanceName == SERVICE_NAME_1
         })
-        val responses4 = getServices(SERVICE_TYPE_2, socketKey)
+        val responses4 = getServices(serviceCache, cacheKey2)
         assertEquals(1, responses4.size)
         assertTrue(responses4.any { response ->
             response.serviceInstanceName == SERVICE_NAME_2
@@ -137,6 +146,6 @@
     }
 
     private fun createResponse(serviceInstanceName: String, serviceType: String) = MdnsResponse(
-        0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
+            0 /* now */, "$serviceInstanceName.$serviceType".split(".").toTypedArray(),
             socketKey.interfaceIndex, socketKey.network)
 }
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
index fde5abd..ce154dd 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsServiceTypeClientTests.java
@@ -193,7 +193,8 @@
         thread = new HandlerThread("MdnsServiceTypeClientTests");
         thread.start();
         handler = new Handler(thread.getLooper());
-        serviceCache = new MdnsServiceCache(thread.getLooper());
+        serviceCache = new MdnsServiceCache(
+                thread.getLooper(), MdnsFeatureFlags.newBuilder().build());
 
         doAnswer(inv -> {
             latestDelayMs = 0;
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
new file mode 100644
index 0000000..6220e76
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSKeepConnectedTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.LocalNetworkConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_DOWNSTREAM_NETWORK
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class CSKeepConnectedTest : CSTest() {
+    @Test
+    fun testKeepConnectedLocalAgent() {
+        deps.setBuildSdk(VERSION_V)
+        val nc = NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_WIFI)
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build()
+        val keepConnectedAgent = Agent(nc = nc, score = FromS(NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_DOWNSTREAM_NETWORK)
+                .build()),
+                lnc = LocalNetworkConfig.Builder().build())
+        val dontKeepConnectedAgent = Agent(nc = nc, lnc = LocalNetworkConfig.Builder().build())
+        doTestKeepConnected(keepConnectedAgent, dontKeepConnectedAgent)
+    }
+
+    @Test
+    fun testKeepConnectedForTest() {
+        val keepAgent = Agent(score = FromS(NetworkScore.Builder()
+                .setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST)
+                .build()))
+        val dontKeepAgent = Agent()
+        doTestKeepConnected(keepAgent, dontKeepAgent)
+    }
+
+    fun doTestKeepConnected(keepAgent: CSAgentWrapper, dontKeepAgent: CSAgentWrapper) {
+        val cb = TestableNetworkCallback()
+        cm.registerNetworkCallback(NetworkRequest.Builder().clearCapabilities().build(), cb)
+
+        keepAgent.connect()
+        dontKeepAgent.connect()
+
+        cb.expectAvailableCallbacks(keepAgent.network, validated = false)
+        cb.expectAvailableCallbacks(dontKeepAgent.network, validated = false)
+
+        // After the nascent timer, the agent without keep connected gets lost.
+        cb.expect<Lost>(dontKeepAgent.network)
+        cb.assertNoCallback()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
new file mode 100644
index 0000000..cfc3a3d
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentCreationTests.kt
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.content.pm.PackageManager.FEATURE_LEANBACK
+import android.net.INetd
+import android.net.LocalNetworkConfig
+import android.net.NativeNetworkConfig
+import android.net.NativeNetworkType
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.net.VpnManager
+import android.os.Build
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mockito.Mockito.doReturn
+import org.mockito.Mockito.inOrder
+import org.mockito.Mockito.never
+import org.mockito.Mockito.timeout
+import kotlin.test.assertFailsWith
+
+private const val TIMEOUT_MS = 2_000L
+private const val NO_CALLBACK_TIMEOUT_MS = 200L
+
+private fun keepConnectedScore() =
+        FromS(NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build())
+
+private fun defaultLnc() = LocalNetworkConfig.Builder().build()
+
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.R)
+class CSLocalAgentCreationTests(
+        private val sdkLevel: Int,
+        private val isTv: Boolean,
+        private val addLocalNetCapToRequest: Boolean
+) : CSTest() {
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters
+        fun arguments() = listOf(
+                arrayOf(VERSION_V, false /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, true /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_V, true /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, false /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, true /* isTv */, true /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_U, true /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_T, false /* isTv */, false /* addLocalNetCapToRequest */),
+                arrayOf(VERSION_T, true /* isTv */, false /* addLocalNetCapToRequest */),
+        )
+    }
+
+    private fun makeNativeNetworkConfigLocal(netId: Int, permission: Int) =
+            NativeNetworkConfig(netId, NativeNetworkType.PHYSICAL_LOCAL, permission,
+                    false /* secure */, VpnManager.TYPE_VPN_NONE, false /* excludeLocalRoutes */)
+
+    @Test
+    fun testLocalAgents() {
+        val netdInOrder = inOrder(netd)
+        deps.setBuildSdk(sdkLevel)
+        doReturn(isTv).`when`(packageManager).hasSystemFeature(FEATURE_LEANBACK)
+        val allNetworksCb = TestableNetworkCallback()
+        val request = NetworkRequest.Builder()
+        if (addLocalNetCapToRequest) {
+            request.addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+        }
+        cm.registerNetworkCallback(request.build(), allNetworksCb)
+        val ncTemplate = NetworkCapabilities.Builder().run {
+            addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+            addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+        }.build()
+        val localAgent = if (sdkLevel >= VERSION_V || sdkLevel == VERSION_U && isTv) {
+            Agent(nc = ncTemplate, score = keepConnectedScore(), lnc = defaultLnc())
+        } else {
+            assertFailsWith<IllegalArgumentException> { Agent(nc = ncTemplate, lnc = defaultLnc()) }
+            netdInOrder.verify(netd, never()).networkCreate(any())
+            return
+        }
+        localAgent.connect()
+        netdInOrder.verify(netd).networkCreate(
+                makeNativeNetworkConfigLocal(localAgent.network.netId, INetd.PERMISSION_NONE))
+        if (addLocalNetCapToRequest) {
+            assertEquals(localAgent.network, allNetworksCb.expect<Available>().network)
+        } else {
+            allNetworksCb.assertNoCallback(NO_CALLBACK_TIMEOUT_MS)
+        }
+        cm.unregisterNetworkCallback(allNetworksCb)
+        localAgent.disconnect()
+        netdInOrder.verify(netd, timeout(TIMEOUT_MS)).networkDestroy(localAgent.network.netId)
+    }
+
+    @Test
+    fun testBadAgents() {
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
new file mode 100644
index 0000000..bd3efa9
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSLocalAgentTests.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server
+
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.LocalNetworkConfig
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkRequest
+import android.net.RouteInfo
+import android.os.Build
+import com.android.testutils.DevSdkIgnoreRule
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.RecorderCallback
+import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.RecorderCallback.CallbackEntry.BlockedStatus
+import com.android.testutils.RecorderCallback.CallbackEntry.CapabilitiesChanged
+import com.android.testutils.RecorderCallback.CallbackEntry.LinkPropertiesChanged
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+
+private fun nc(transport: Int, vararg caps: Int) = NetworkCapabilities.Builder().apply {
+    addTransportType(transport)
+    caps.forEach {
+        addCapability(it)
+    }
+    // Useful capabilities for everybody
+    addCapability(NET_CAPABILITY_NOT_RESTRICTED)
+    addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+    addCapability(NET_CAPABILITY_NOT_ROAMING)
+    addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+}.build()
+
+private fun lp(iface: String) = LinkProperties().apply {
+    interfaceName = iface
+    addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+    addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+}
+
+@RunWith(DevSdkIgnoreRunner::class)
+@DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
+class CSLocalAgentTests : CSTest() {
+    @Test
+    fun testBadAgents() {
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder()
+                    .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                    .build(),
+                    lnc = null)
+        }
+        assertFailsWith<IllegalArgumentException> {
+            Agent(nc = NetworkCapabilities.Builder().build(),
+                    lnc = LocalNetworkConfig.Builder().build())
+        }
+    }
+
+    @Test
+    fun testUpdateLocalAgentConfig() {
+        deps.setBuildSdk(VERSION_V)
+
+        val cb = TestableNetworkCallback()
+        cm.requestNetwork(NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_LOCAL_NETWORK)
+                .build(),
+                cb)
+
+        // Set up a local agent that should forward its traffic to the best DUN upstream.
+        val localAgent = Agent(nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_LOCAL_NETWORK),
+                lp = lp("local0"),
+                lnc = LocalNetworkConfig.Builder().build(),
+        )
+        localAgent.connect()
+
+        cb.expect<Available>(localAgent.network)
+        cb.expect<CapabilitiesChanged>(localAgent.network)
+        cb.expect<LinkPropertiesChanged>(localAgent.network)
+        cb.expect<BlockedStatus>(localAgent.network)
+
+        val newLnc = LocalNetworkConfig.Builder()
+                .setUpstreamSelector(NetworkRequest.Builder()
+                        .addTransportType(TRANSPORT_WIFI)
+                        .build())
+                .build()
+        localAgent.sendLocalNetworkConfig(newLnc)
+
+        localAgent.disconnect()
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index c558586..094ded3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -21,6 +21,7 @@
 import android.net.INetworkMonitor
 import android.net.INetworkMonitorCallbacks
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.Network
 import android.net.NetworkAgent
 import android.net.NetworkAgentConfig
@@ -35,6 +36,7 @@
 import android.os.HandlerThread
 import com.android.modules.utils.build.SdkLevel
 import com.android.testutils.RecorderCallback.CallbackEntry.Available
+import com.android.testutils.RecorderCallback.CallbackEntry.Lost
 import com.android.testutils.TestableNetworkCallback
 import org.mockito.ArgumentCaptor
 import org.mockito.ArgumentMatchers.any
@@ -47,6 +49,8 @@
 import kotlin.test.assertEquals
 import kotlin.test.fail
 
+const val SHORT_TIMEOUT_MS = 200L
+
 private inline fun <reified T> ArgumentCaptor() = ArgumentCaptor.forClass(T::class.java)
 
 private val agentCounter = AtomicInteger(1)
@@ -60,11 +64,13 @@
  */
 class CSAgentWrapper(
         val context: Context,
+        val deps: ConnectivityService.Dependencies,
         csHandlerThread: HandlerThread,
         networkStack: NetworkStackClientBase,
         nac: NetworkAgentConfig,
         val nc: NetworkCapabilities,
         val lp: LinkProperties,
+        val lnc: LocalNetworkConfig?,
         val score: FromS<NetworkScore>,
         val provider: NetworkProvider?
 ) : TestableNetworkCallback.HasNetwork {
@@ -94,9 +100,9 @@
                 nmCbCaptor.capture())
 
         // Create the actual agent. NetworkAgent is abstract, so make an anonymous subclass.
-        if (SdkLevel.isAtLeastS()) {
+        if (deps.isAtLeastS()) {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
-                    nc, lp, score.value, nac, provider) {}
+                    nc, lp, lnc, score.value, nac, provider) {}
         } else {
             agent = object : NetworkAgent(context, csHandlerThread.looper, TAG,
                     nc, lp, 50 /* score */, nac, provider) {}
@@ -108,7 +114,7 @@
     }
 
     private fun onValidationRequested() {
-        if (SdkLevel.isAtLeastT()) {
+        if (deps.isAtLeastT()) {
             verify(networkMonitor).notifyNetworkConnectedParcel(any())
         } else {
             verify(networkMonitor).notifyNetworkConnected(any(), any())
@@ -125,9 +131,10 @@
 
     fun connect() {
         val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-        val request = NetworkRequest.Builder().clearCapabilities()
-                .addTransportType(nc.transportTypes[0])
-                .build()
+        val request = NetworkRequest.Builder().apply {
+            clearCapabilities()
+            if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+        }.build()
         val cb = TestableNetworkCallback()
         mgr.registerNetworkCallback(request, cb)
         agent.markConnected()
@@ -149,6 +156,17 @@
     }
 
     fun disconnect() {
+        val mgr = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+        val request = NetworkRequest.Builder().apply {
+            clearCapabilities()
+            if (nc.transportTypes.isNotEmpty()) addTransportType(nc.transportTypes[0])
+        }.build()
+        val cb = TestableNetworkCallback(timeoutMs = SHORT_TIMEOUT_MS)
+        mgr.registerNetworkCallback(request, cb)
+        cb.eventuallyExpect<Available> { it.network == agent.network }
         agent.unregister()
+        cb.eventuallyExpect<Lost> { it.network == agent.network }
     }
+
+    fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index 9a1e509..0ccbfc3 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -27,9 +27,16 @@
 import android.net.INetd
 import android.net.InetAddresses
 import android.net.LinkProperties
+import android.net.LocalNetworkConfig
 import android.net.NetworkAgentConfig
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_ETHERNET
+import android.net.NetworkCapabilities.TRANSPORT_TEST
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
 import android.net.NetworkPolicyManager
 import android.net.NetworkProvider
 import android.net.NetworkScore
@@ -79,6 +86,17 @@
 internal const val VERSION_V = 5
 internal const val VERSION_MAX = VERSION_V
 
+private fun NetworkCapabilities.getLegacyType() =
+        when (transportTypes.getOrElse(0) { TRANSPORT_WIFI }) {
+            TRANSPORT_BLUETOOTH -> ConnectivityManager.TYPE_BLUETOOTH
+            TRANSPORT_CELLULAR -> ConnectivityManager.TYPE_MOBILE
+            TRANSPORT_ETHERNET -> ConnectivityManager.TYPE_ETHERNET
+            TRANSPORT_TEST -> ConnectivityManager.TYPE_TEST
+            TRANSPORT_VPN -> ConnectivityManager.TYPE_VPN
+            TRANSPORT_WIFI -> ConnectivityManager.TYPE_WIFI
+            else -> ConnectivityManager.TYPE_NONE
+        }
+
 /**
  * Base class for tests testing ConnectivityService and its satellites.
  *
@@ -127,7 +145,7 @@
     val networkStack = mock<NetworkStackClientBase>()
     val csHandlerThread = HandlerThread("CSTestHandler")
     val sysResources = mock<Resources>().also { initMockedResources(it) }
-    val packageManager = makeMockPackageManager()
+    val packageManager = makeMockPackageManager(instrumentationContext)
     val connResources = makeMockConnResources(sysResources, packageManager)
 
     val netd = mock<INetd>()
@@ -272,13 +290,14 @@
     // Network agents. See CSAgentWrapper. This class contains utility methods to simplify
     // creation.
     fun Agent(
-            nac: NetworkAgentConfig = emptyAgentConfig(),
             nc: NetworkCapabilities = defaultNc(),
+            nac: NetworkAgentConfig = emptyAgentConfig(nc.getLegacyType()),
             lp: LinkProperties = defaultLp(),
+            lnc: LocalNetworkConfig? = null,
             score: FromS<NetworkScore> = defaultScore(),
             provider: NetworkProvider? = null
-    ) = CSAgentWrapper(context, csHandlerThread, networkStack, nac, nc, lp, score, provider)
-
+    ) = CSAgentWrapper(context, deps, csHandlerThread, networkStack,
+            nac, nc, lp, lnc, score, provider)
     fun Agent(vararg transports: Int, lp: LinkProperties = defaultLp()): CSAgentWrapper {
         val nc = NetworkCapabilities.Builder().apply {
             transports.forEach {
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
index 2d8607b..c1828b2 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTestHelpers.kt
@@ -38,6 +38,7 @@
 import android.net.NetworkScore
 import android.net.RouteInfo
 import android.net.metrics.IpConnectivityLog
+import android.os.Binder
 import android.os.Handler
 import android.os.HandlerThread
 import android.os.SystemClock
@@ -54,20 +55,23 @@
 import com.android.server.connectivity.ConnectivityResources
 import org.mockito.ArgumentMatchers
 import org.mockito.ArgumentMatchers.any
+import org.mockito.ArgumentMatchers.anyInt
 import org.mockito.ArgumentMatchers.anyLong
 import org.mockito.ArgumentMatchers.anyString
 import org.mockito.ArgumentMatchers.argThat
 import org.mockito.ArgumentMatchers.eq
 import org.mockito.Mockito
 import org.mockito.Mockito.doAnswer
-import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.doNothing
+import org.mockito.Mockito.doReturn
 import kotlin.test.fail
 
 internal inline fun <reified T> mock() = Mockito.mock(T::class.java)
 internal inline fun <reified T> any() = any(T::class.java)
 
-internal fun emptyAgentConfig() = NetworkAgentConfig.Builder().build()
+internal fun emptyAgentConfig(legacyType: Int) = NetworkAgentConfig.Builder()
+        .setLegacyType(legacyType)
+        .build()
 
 internal fun defaultNc() = NetworkCapabilities.Builder()
         // Add sensible defaults for agents that don't want to care
@@ -98,9 +102,22 @@
     }
 }
 
-internal fun makeMockPackageManager() = mock<PackageManager>().also { pm ->
+internal fun makeMockPackageManager(realContext: Context) = mock<PackageManager>().also { pm ->
     val supported = listOf(FEATURE_WIFI, FEATURE_WIFI_DIRECT, FEATURE_BLUETOOTH, FEATURE_ETHERNET)
     doReturn(true).`when`(pm).hasSystemFeature(argThat { supported.contains(it) })
+    val myPackageName = realContext.packageName
+    val myPackageInfo = realContext.packageManager.getPackageInfo(myPackageName,
+            PackageManager.GET_PERMISSIONS)
+    // Very high version code so that the checks for the module version will always
+    // say that it is recent enough. This is the most sensible default, but if some
+    // test needs to test with different version codes they can re-mock this with a
+    // different value.
+    myPackageInfo.longVersionCode = 9999999L
+    doReturn(arrayOf(myPackageName)).`when`(pm).getPackagesForUid(Binder.getCallingUid())
+    doReturn(myPackageInfo).`when`(pm).getPackageInfoAsUser(
+            eq(myPackageName), anyInt(), eq(UserHandle.getCallingUserId()))
+    doReturn(listOf(myPackageInfo)).`when`(pm)
+            .getInstalledPackagesAsUser(eq(PackageManager.GET_PERMISSIONS), anyInt())
 }
 
 internal fun makeMockConnResources(resources: Resources, pm: PackageManager) = mock<Context>().let {
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt b/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt
new file mode 100644
index 0000000..9f2d4d3
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/NetworkStatsEventLoggerTest.kt
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.net
+
+import android.util.IndentingPrintWriter
+import com.android.server.net.NetworkStatsEventLogger.MAX_POLL_REASON
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_DUMPSYS
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_FORCE_UPDATE
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_GLOBAL_ALERT
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_NETWORK_STATUS_CHANGED
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_OPEN_SESSION
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_PERIODIC
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED
+import com.android.server.net.NetworkStatsEventLogger.POLL_REASON_REG_CALLBACK
+import com.android.server.net.NetworkStatsEventLogger.PollEvent
+import com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf
+import com.android.testutils.DevSdkIgnoreRunner
+import java.io.StringWriter
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+const val TEST_PERSIST_FLAG = 0x101
+
+@RunWith(DevSdkIgnoreRunner::class)
+class NetworkStatsEventLoggerTest {
+    val logger = NetworkStatsEventLogger()
+    val stringWriter = TestStringWriter()
+    val pw = IndentingPrintWriter(stringWriter)
+
+    @Test
+    fun testDump_invalid() {
+        // Verify it won't crash.
+        logger.dump(pw)
+        // Clear output buffer.
+        stringWriter.getOutputAndClear()
+
+        // Verify log invalid event throws. And nothing output in the dump.
+        val invalidReasons = listOf(-1, MAX_POLL_REASON + 1)
+        invalidReasons.forEach {
+            assertFailsWith<IllegalArgumentException> {
+                logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(it))
+            }
+            logger.dumpRecentPollEvents(pw)
+            val output = stringWriter.getOutputAndClear()
+            assertStringNotContains(output, pollReasonNameOf(it))
+        }
+    }
+
+    @Test
+    fun testDump_valid() {
+        // Choose arbitrary set of reasons for testing.
+        val loggedReasons = listOf(
+            POLL_REASON_GLOBAL_ALERT,
+            POLL_REASON_FORCE_UPDATE,
+            POLL_REASON_DUMPSYS,
+            POLL_REASON_PERIODIC,
+            POLL_REASON_RAT_CHANGED
+        )
+        val nonLoggedReasons = listOf(
+            POLL_REASON_NETWORK_STATUS_CHANGED,
+            POLL_REASON_OPEN_SESSION,
+            POLL_REASON_REG_CALLBACK)
+
+        // Add some valid records.
+        loggedReasons.forEach {
+            logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(it))
+        }
+
+        // Collect dumps.
+        logger.dumpRecentPollEvents(pw)
+        val outputRecentEvents = stringWriter.getOutputAndClear()
+        logger.dumpPollCountsPerReason(pw)
+        val outputCountsPerReason = stringWriter.getOutputAndClear()
+
+        // Verify the output contains at least necessary information.
+        loggedReasons.forEach {
+            // Verify all events are shown in the recent event dump.
+            val eventString = PollEvent(it).toString()
+            assertStringContains(outputRecentEvents, TEST_PERSIST_FLAG.toString())
+            assertStringContains(eventString, pollReasonNameOf(it))
+            assertStringContains(outputRecentEvents, eventString)
+            // Verify counts are 1 for each reason.
+            assertCountForReason(outputCountsPerReason, it, 1)
+        }
+
+        // Verify the output remains untouched for other reasons.
+        nonLoggedReasons.forEach {
+            assertStringNotContains(outputRecentEvents, PollEvent(it).toString())
+            assertCountForReason(outputCountsPerReason, it, 0)
+        }
+    }
+
+    @Test
+    fun testDump_maxEventLogs() {
+        // Choose arbitrary reason.
+        val reasonToBeTested = POLL_REASON_PERIODIC
+        val repeatCount = NetworkStatsEventLogger.MAX_EVENTS_LOGS * 2
+
+        // Collect baseline.
+        logger.dumpRecentPollEvents(pw)
+        val lineCountBaseLine = getLineCount(stringWriter.getOutputAndClear())
+
+        repeat(repeatCount) {
+            logger.logPollEvent(TEST_PERSIST_FLAG, PollEvent(reasonToBeTested))
+        }
+
+        // Collect dump.
+        logger.dumpRecentPollEvents(pw)
+        val lineCountAfterTest = getLineCount(stringWriter.getOutputAndClear())
+
+        // Verify line count increment is limited.
+        assertEquals(
+            NetworkStatsEventLogger.MAX_EVENTS_LOGS,
+            lineCountAfterTest - lineCountBaseLine
+        )
+
+        // Verify count per reason increased for the testing reason.
+        logger.dumpPollCountsPerReason(pw)
+        val outputCountsPerReason = stringWriter.getOutputAndClear()
+        for (reason in 0..MAX_POLL_REASON) {
+            assertCountForReason(
+                outputCountsPerReason,
+                reason,
+                if (reason == reasonToBeTested) repeatCount else 0
+            )
+        }
+    }
+
+    private fun getLineCount(multilineString: String) = multilineString.lines().size
+
+    private fun assertStringContains(got: String, want: String) {
+        assertTrue(got.contains(want), "Wanted: $want, but got: $got")
+    }
+
+    private fun assertStringNotContains(got: String, unwant: String) {
+        assertFalse(got.contains(unwant), "Unwanted: $unwant, but got: $got")
+    }
+
+    /**
+     * Assert the reason and the expected count are at the same line.
+     */
+    private fun assertCountForReason(dump: String, reason: Int, expectedCount: Int) {
+        // Matches strings like "GLOBAL_ALERT: 50" but not too strict since the format might change.
+        val regex = Regex(pollReasonNameOf(reason) + "[^0-9]+" + expectedCount)
+        assertEquals(
+            1,
+            regex.findAll(dump).count(),
+            "Unexpected output: $dump " + " for reason: " + pollReasonNameOf(reason)
+        )
+    }
+
+    class TestStringWriter : StringWriter() {
+        fun getOutputAndClear() = toString().also { buffer.setLength(0) }
+    }
+}
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
index 292f77e..c477b2c 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsObserversTest.java
@@ -59,6 +59,7 @@
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.HandlerUtils;
 
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -139,6 +140,14 @@
         mUsageCallback = new TestableUsageCallback(mUsageCallbackBinder);
     }
 
+    @After
+    public void tearDown() throws Exception {
+        if (mObserverHandlerThread != null) {
+            mObserverHandlerThread.quitSafely();
+            mObserverHandlerThread.join();
+        }
+    }
+
     @Test
     public void testRegister_thresholdTooLow_setsDefaultThreshold() throws Exception {
         final long thresholdTooLowBytes = 1L;
diff --git a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
index 9453617..e8d5c66 100644
--- a/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
+++ b/tests/unit/java/com/android/server/net/NetworkStatsServiceTest.java
@@ -64,6 +64,8 @@
 import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
 import static android.text.format.DateUtils.WEEK_IN_MILLIS;
 
+import static com.android.server.net.NetworkStatsEventLogger.POLL_REASON_RAT_CHANGED;
+import static com.android.server.net.NetworkStatsEventLogger.PollEvent.pollReasonNameOf;
 import static com.android.server.net.NetworkStatsService.ACTION_NETWORK_STATS_POLL;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_ATTEMPTS_COUNTER_NAME;
 import static com.android.server.net.NetworkStatsService.NETSTATS_IMPORT_FALLBACKS_COUNTER_NAME;
@@ -525,6 +527,11 @@
                     IBpfMap<CookieTagMapKey, CookieTagMapValue> cookieTagMap, Handler handler) {
                 return mSkDestroyListener;
             }
+
+            @Override
+            public boolean supportEventLogger(@NonNull Context cts) {
+                return true;
+            }
         };
     }
 
@@ -2674,4 +2681,14 @@
         doReturn(null).when(mBpfInterfaceMapUpdater).getIfNameByIndex(10 /* index */);
         doTestDumpIfaceStatsMap("unknown");
     }
+
+    // Basic test to ensure event logger dump is called.
+    // Note that tests to ensure detailed correctness is done in the dedicated tests.
+    // See NetworkStatsEventLoggerTest.
+    @Test
+    public void testDumpEventLogger() {
+        setMobileRatTypeAndWaitForIdle(TelephonyManager.NETWORK_TYPE_UMTS);
+        final String dump = getDump();
+        assertDumpContains(dump, pollReasonNameOf(POLL_REASON_RAT_CHANGED));
+    }
 }
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index 17a74f6..3eaebfa 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -1,7 +1,5 @@
 {
-  // TODO (b/297729075): graduate this test to presubmit once it meets the SLO requirements.
-  // See go/test-mapping-slo-guide
-  "postsubmit": [
+  "presubmit": [
     {
       "name": "CtsThreadNetworkTestCases"
     }
diff --git a/thread/flags/thread_base.aconfig b/thread/flags/thread_base.aconfig
new file mode 100644
index 0000000..bf1f288
--- /dev/null
+++ b/thread/flags/thread_base.aconfig
@@ -0,0 +1,8 @@
+package: "com.android.net.thread.flags"
+
+flag {
+    name: "thread_enabled"
+    namespace: "thread_network"
+    description: "Controls whether the Android Thread feature is enabled"
+    bug: "301473012"
+}
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
new file mode 100644
index 0000000..8bf12a4
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+parcelable ActiveOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
new file mode 100644
index 0000000..c9b047a
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -0,0 +1,1165 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+import static com.android.internal.util.Preconditions.checkState;
+import static com.android.net.module.util.HexDump.dumpHexString;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.Size;
+import android.annotation.SystemApi;
+import android.net.IpPrefix;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.ByteArrayOutputStream;
+import java.net.Inet6Address;
+import java.net.UnknownHostException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Random;
+
+/**
+ * Data interface for managing a Thread Active Operational Dataset.
+ *
+ * <p>An example usage of creating an Active Operational Dataset with random parameters:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.createRandomDataset();
+ * }</pre>
+ *
+ * <p>or random Dataset with customized Network Name:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset =
+ *         new ActiveOperationalDataset.Builder(ActiveOperationalDataset.createRandomDataset())
+ *                 .setNetworkName("MyThreadNet").build();
+ * }</pre>
+ *
+ * <p>If the Active Operational Dataset is already known as <a
+ * href="https://www.threadgroup.org">Thread TLVs</a>, you can simply use:
+ *
+ * <pre>{@code
+ * ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlvs);
+ * }</pre>
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class ActiveOperationalDataset implements Parcelable {
+    /** The maximum length of the Active Operational Dataset TLV array in bytes. */
+    public static final int LENGTH_MAX_DATASET_TLVS = 254;
+    /** The length of Extended PAN ID in bytes. */
+    public static final int LENGTH_EXTENDED_PAN_ID = 8;
+    /** The minimum length of Network Name as UTF-8 bytes. */
+    public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1;
+    /** The maximum length of Network Name as UTF-8 bytes. */
+    public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16;
+    /** The length of Network Key in bytes. */
+    public static final int LENGTH_NETWORK_KEY = 16;
+    /** The length of Mesh-Local Prefix in bits. */
+    public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64;
+    /** The length of PSKc in bytes. */
+    public static final int LENGTH_PSKC = 16;
+    /** The 2.4 GHz channel page. */
+    public static final int CHANNEL_PAGE_24_GHZ = 0;
+    /** The minimum 2.4GHz channel. */
+    public static final int CHANNEL_MIN_24_GHZ = 11;
+    /** The maximum 2.4GHz channel. */
+    public static final int CHANNEL_MAX_24_GHZ = 26;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_CHANNEL = 0;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_PAN_ID = 1;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_EXTENDED_PAN_ID = 2;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_NETWORK_NAME = 3;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_PSKC = 4;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_NETWORK_KEY = 5;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_MESH_LOCAL_PREFIX = 7;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_SECURITY_POLICY = 12;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_ACTIVE_TIMESTAMP = 14;
+    /** @hide */
+    @VisibleForTesting public static final int TYPE_CHANNEL_MASK = 53;
+
+    private static final byte MESH_LOCAL_PREFIX_FIRST_BYTE = (byte) 0xfd;
+    private static final int LENGTH_CHANNEL = 3;
+    private static final int LENGTH_PAN_ID = 2;
+
+    @NonNull
+    public static final Creator<ActiveOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public ActiveOperationalDataset createFromParcel(Parcel in) {
+                    return ActiveOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public ActiveOperationalDataset[] newArray(int size) {
+                    return new ActiveOperationalDataset[size];
+                }
+            };
+
+    private final OperationalDatasetTimestamp mActiveTimestamp;
+    private final String mNetworkName;
+    private final byte[] mExtendedPanId;
+    private final int mPanId;
+    private final int mChannel;
+    private final int mChannelPage;
+    private final SparseArray<byte[]> mChannelMask;
+    private final byte[] mPskc;
+    private final byte[] mNetworkKey;
+    private final IpPrefix mMeshLocalPrefix;
+    private final SecurityPolicy mSecurityPolicy;
+    private final SparseArray<byte[]> mUnknownTlvs;
+
+    private ActiveOperationalDataset(Builder builder) {
+        this(
+                requireNonNull(builder.mActiveTimestamp),
+                requireNonNull(builder.mNetworkName),
+                requireNonNull(builder.mExtendedPanId),
+                requireNonNull(builder.mPanId),
+                requireNonNull(builder.mChannelPage),
+                requireNonNull(builder.mChannel),
+                requireNonNull(builder.mChannelMask),
+                requireNonNull(builder.mPskc),
+                requireNonNull(builder.mNetworkKey),
+                requireNonNull(builder.mMeshLocalPrefix),
+                requireNonNull(builder.mSecurityPolicy),
+                requireNonNull(builder.mUnknownTlvs));
+    }
+
+    private ActiveOperationalDataset(
+            OperationalDatasetTimestamp activeTimestamp,
+            String networkName,
+            byte[] extendedPanId,
+            int panId,
+            int channelPage,
+            int channel,
+            SparseArray<byte[]> channelMask,
+            byte[] pskc,
+            byte[] networkKey,
+            IpPrefix meshLocalPrefix,
+            SecurityPolicy securityPolicy,
+            SparseArray<byte[]> unknownTlvs) {
+        this.mActiveTimestamp = activeTimestamp;
+        this.mNetworkName = networkName;
+        this.mExtendedPanId = extendedPanId.clone();
+        this.mPanId = panId;
+        this.mChannel = channel;
+        this.mChannelPage = channelPage;
+        this.mChannelMask = deepCloneSparseArray(channelMask);
+        this.mPskc = pskc.clone();
+        this.mNetworkKey = networkKey.clone();
+        this.mMeshLocalPrefix = meshLocalPrefix;
+        this.mSecurityPolicy = securityPolicy;
+        this.mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Active Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @param tlvs a series of Thread TLVs which contain the Active Operational Dataset
+     * @return the decoded Active Operational Dataset
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or the length is larger than
+     *     {@link LENGTH_MAX_DATASET_TLVS}
+     */
+    @NonNull
+    public static ActiveOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+        if (tlvs.length > LENGTH_MAX_DATASET_TLVS) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "tlvs length exceeds max length %d (actual is %d)",
+                            LENGTH_MAX_DATASET_TLVS, tlvs.length));
+        }
+
+        Builder builder = new Builder();
+        int i = 0;
+        while (i < tlvs.length) {
+            int type = tlvs[i++] & 0xff;
+            if (i >= tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d at end of operational dataset with length %d",
+                                type, tlvs.length));
+            }
+
+            int length = tlvs[i++] & 0xff;
+            if (i + length > tlvs.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Found TLV type %d with length %d which exceeds the remaining data"
+                                        + " in the operational dataset with length %d",
+                                type, length, tlvs.length));
+            }
+
+            initWithTlv(builder, type, Arrays.copyOfRange(tlvs, i, i + length));
+            i += length;
+        }
+        try {
+            return builder.build();
+        } catch (IllegalStateException e) {
+            throw new IllegalArgumentException(
+                    "Failed to build the ActiveOperationalDataset object", e);
+        }
+    }
+
+    private static void initWithTlv(Builder builder, int type, byte[] value) {
+        // The max length of the dataset is 254 bytes, so the max length of a single TLV value is
+        // 252 (254 - 1 - 1)
+        if (value.length > LENGTH_MAX_DATASET_TLVS - 2) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Length of TLV %d exceeds %d (actualLength = %d)",
+                            (type & 0xff), LENGTH_MAX_DATASET_TLVS - 2, value.length));
+        }
+
+        switch (type) {
+            case TYPE_CHANNEL:
+                checkArgument(
+                        value.length == LENGTH_CHANNEL,
+                        "Invalid channel (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_CHANNEL);
+                builder.setChannel((value[0] & 0xff), ((value[1] & 0xff) << 8) | (value[2] & 0xff));
+                break;
+            case TYPE_PAN_ID:
+                checkArgument(
+                        value.length == LENGTH_PAN_ID,
+                        "Invalid PAN ID (length = %d, expectedLength = %d)",
+                        value.length,
+                        LENGTH_PAN_ID);
+                builder.setPanId(((value[0] & 0xff) << 8) | (value[1] & 0xff));
+                break;
+            case TYPE_EXTENDED_PAN_ID:
+                builder.setExtendedPanId(value);
+                break;
+            case TYPE_NETWORK_NAME:
+                builder.setNetworkName(new String(value, UTF_8));
+                break;
+            case TYPE_PSKC:
+                builder.setPskc(value);
+                break;
+            case TYPE_NETWORK_KEY:
+                builder.setNetworkKey(value);
+                break;
+            case TYPE_MESH_LOCAL_PREFIX:
+                builder.setMeshLocalPrefix(value);
+                break;
+            case TYPE_SECURITY_POLICY:
+                builder.setSecurityPolicy(SecurityPolicy.fromTlvValue(value));
+                break;
+            case TYPE_ACTIVE_TIMESTAMP:
+                builder.setActiveTimestamp(OperationalDatasetTimestamp.fromTlvValue(value));
+                break;
+            case TYPE_CHANNEL_MASK:
+                builder.setChannelMask(decodeChannelMask(value));
+                break;
+            default:
+                builder.addUnknownTlv(type & 0xff, value);
+                break;
+        }
+    }
+
+    private static SparseArray<byte[]> decodeChannelMask(byte[] tlvValue) {
+        SparseArray<byte[]> channelMask = new SparseArray<>();
+        int i = 0;
+        while (i < tlvValue.length) {
+            int channelPage = tlvValue[i++] & 0xff;
+            if (i >= tlvValue.length) {
+                throw new IllegalArgumentException(
+                        "Invalid channel mask - channel mask length is missing");
+            }
+
+            int maskLength = tlvValue[i++] & 0xff;
+            if (i + maskLength > tlvValue.length) {
+                throw new IllegalArgumentException(
+                        String.format(
+                                "Invalid channel mask - channel mask is incomplete "
+                                        + "(offset = %d, length = %d, totalLength = %d)",
+                                i, maskLength, tlvValue.length));
+            }
+
+            channelMask.put(channelPage, Arrays.copyOfRange(tlvValue, i, i + maskLength));
+            i += maskLength;
+        }
+        return channelMask;
+    }
+
+    private static void encodeChannelMask(
+            SparseArray<byte[]> channelMask, ByteArrayOutputStream outputStream) {
+        ByteArrayOutputStream entryStream = new ByteArrayOutputStream();
+
+        for (int i = 0; i < channelMask.size(); i++) {
+            int key = channelMask.keyAt(i);
+            byte[] value = channelMask.get(key);
+            entryStream.write(key);
+            entryStream.write(value.length);
+            entryStream.write(value, 0, value.length);
+        }
+
+        byte[] entries = entryStream.toByteArray();
+
+        outputStream.write(TYPE_CHANNEL_MASK);
+        outputStream.write(entries.length);
+        outputStream.write(entries, 0, entries.length);
+    }
+
+    /**
+     * Creates a new {@link ActiveOperationalDataset} object with randomized or default parameters.
+     *
+     * <p>The randomized (or default) value for each parameter:
+     *
+     * <ul>
+     *   <li>{@code Active Timestamp} defaults to {@code new OperationalDatasetTimestamp(1, 0,
+     *       false)}
+     *   <li>{@code Network Name} defaults to "THREAD-PAN-<PAN ID decimal>", for example
+     *       "THREAD-PAN-12345"
+     *   <li>{@code Extended PAN ID} filled with randomly generated bytes
+     *   <li>{@code PAN ID} randomly generated integer in range of [0, 0xfffe]
+     *   <li>{@code Channel Page} defaults to {@link #CHANNEL_PAGE_24_GHZ}
+     *   <li>{@code Channel} randomly selected channel in range of [{@link #CHANNEL_MIN_24_GHZ},
+     *       {@link #CHANNEL_MAX_24_GHZ}]
+     *   <li>{@code Channel Mask} all bits from {@link #CHANNEL_MIN_24_GHZ} to {@link
+     *       #CHANNEL_MAX_24_GHZ} are set to {@code true}
+     *   <li>{@code PSKc} filled with bytes generated by secure random generator
+     *   <li>{@code Network Key} filled with bytes generated by secure random generator
+     *   <li>{@code Mesh-local Prefix} filled with randomly generated bytes except that the first
+     *       byte is always set to {@code 0xfd}
+     *   <li>{@code Security Policy} defaults to {@code new SecurityPolicy(
+     *       DEFAULT_ROTATION_TIME_HOURS, new byte[]{(byte)0xff, (byte)0xf8})}. This is the default
+     *       values required by the Thread 1.2 specification
+     * </ul>
+     *
+     * <p>This method is the recommended way to create a randomized operational dataset for a new
+     * Thread network. It may be desired to change one or more of the generated value(s). For
+     * example, to use a more meaningful Network Name. To do that, create a new {@link Builder}
+     * object from this dataset with {@link Builder#Builder(ActiveOperationalDataset)} and override
+     * the value with the setters of {@link Builder}.
+     *
+     * <p>Note that it's highly discouraged to change the randomly generated Extended PAN ID,
+     * Network Key or PSKc, as it will compromise the security of a Thread network.
+     */
+    @NonNull
+    public static ActiveOperationalDataset createRandomDataset() {
+        return createRandomDataset(new Random(Instant.now().toEpochMilli()), new SecureRandom());
+    }
+
+    /** @hide */
+    @VisibleForTesting
+    public static ActiveOperationalDataset createRandomDataset(
+            Random random, SecureRandom secureRandom) {
+        int panId = random.nextInt(/* bound= */ 0xffff);
+        byte[] meshLocalPrefix = newRandomBytes(random, LENGTH_MESH_LOCAL_PREFIX_BITS / 8);
+        meshLocalPrefix[0] = MESH_LOCAL_PREFIX_FIRST_BYTE;
+
+        SparseArray<byte[]> channelMask = new SparseArray<>(1);
+        channelMask.put(CHANNEL_PAGE_24_GHZ, new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+
+        return new Builder()
+                .setActiveTimestamp(
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 1,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ false))
+                .setExtendedPanId(newRandomBytes(random, LENGTH_EXTENDED_PAN_ID))
+                .setPanId(panId)
+                .setNetworkName("THREAD-PAN-" + panId)
+                .setChannel(
+                        CHANNEL_PAGE_24_GHZ,
+                        random.nextInt(CHANNEL_MAX_24_GHZ - CHANNEL_MIN_24_GHZ + 1)
+                                + CHANNEL_MIN_24_GHZ)
+                .setChannelMask(channelMask)
+                .setPskc(newRandomBytes(secureRandom, LENGTH_PSKC))
+                .setNetworkKey(newRandomBytes(secureRandom, LENGTH_NETWORK_KEY))
+                .setMeshLocalPrefix(meshLocalPrefix)
+                .setSecurityPolicy(
+                        new SecurityPolicy(
+                                DEFAULT_ROTATION_TIME_HOURS, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .build();
+    }
+
+    private static byte[] newRandomBytes(Random random, int length) {
+        byte[] result = new byte[length];
+        random.nextBytes(result);
+        return result;
+    }
+
+    private static boolean areByteSparseArraysEqual(
+            @NonNull SparseArray<byte[]> first, @NonNull SparseArray<byte[]> second) {
+        if (first == second) {
+            return true;
+        } else if (first == null || second == null) {
+            return false;
+        } else if (first.size() != second.size()) {
+            return false;
+        } else {
+            for (int i = 0; i < first.size(); i++) {
+                int firstKey = first.keyAt(i);
+                int secondKey = second.keyAt(i);
+                if (firstKey != secondKey) {
+                    return false;
+                }
+
+                byte[] firstValue = first.valueAt(i);
+                byte[] secondValue = second.valueAt(i);
+                if (!Arrays.equals(firstValue, secondValue)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+    }
+
+    /** An easy-to-use wrapper of {@link Arrays#deepHashCode}. */
+    private static int deepHashCode(Object... values) {
+        return Arrays.deepHashCode(values);
+    }
+
+    /**
+     * Converts this {@link ActiveOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     *
+     * @return a series of Thread TLVs which contain this Active Operational Dataset
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        dataset.write(TYPE_ACTIVE_TIMESTAMP);
+        byte[] activeTimestampBytes = mActiveTimestamp.toTlvValue();
+        dataset.write(activeTimestampBytes.length);
+        dataset.write(activeTimestampBytes, 0, activeTimestampBytes.length);
+
+        dataset.write(TYPE_NETWORK_NAME);
+        byte[] networkNameBytes = mNetworkName.getBytes(UTF_8);
+        dataset.write(networkNameBytes.length);
+        dataset.write(networkNameBytes, 0, networkNameBytes.length);
+
+        dataset.write(TYPE_EXTENDED_PAN_ID);
+        dataset.write(mExtendedPanId.length);
+        dataset.write(mExtendedPanId, 0, mExtendedPanId.length);
+
+        dataset.write(TYPE_PAN_ID);
+        dataset.write(LENGTH_PAN_ID);
+        dataset.write(mPanId >> 8);
+        dataset.write(mPanId);
+
+        dataset.write(TYPE_CHANNEL);
+        dataset.write(LENGTH_CHANNEL);
+        dataset.write(mChannelPage);
+        dataset.write(mChannel >> 8);
+        dataset.write(mChannel);
+
+        encodeChannelMask(mChannelMask, dataset);
+
+        dataset.write(TYPE_PSKC);
+        dataset.write(mPskc.length);
+        dataset.write(mPskc, 0, mPskc.length);
+
+        dataset.write(TYPE_NETWORK_KEY);
+        dataset.write(mNetworkKey.length);
+        dataset.write(mNetworkKey, 0, mNetworkKey.length);
+
+        dataset.write(TYPE_MESH_LOCAL_PREFIX);
+        dataset.write(mMeshLocalPrefix.getPrefixLength() / 8);
+        dataset.write(mMeshLocalPrefix.getRawAddress(), 0, mMeshLocalPrefix.getPrefixLength() / 8);
+
+        dataset.write(TYPE_SECURITY_POLICY);
+        byte[] securityPolicyBytes = mSecurityPolicy.toTlvValue();
+        dataset.write(securityPolicyBytes.length);
+        dataset.write(securityPolicyBytes, 0, securityPolicyBytes.length);
+
+        for (int i = 0; i < mUnknownTlvs.size(); i++) {
+            byte[] value = mUnknownTlvs.valueAt(i);
+            dataset.write(mUnknownTlvs.keyAt(i));
+            dataset.write(value.length);
+            dataset.write(value, 0, value.length);
+        }
+
+        return dataset.toByteArray();
+    }
+
+    /** Returns the Active Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getActiveTimestamp() {
+        return mActiveTimestamp;
+    }
+
+    /** Returns the Network Name. */
+    @NonNull
+    @Size(min = LENGTH_MIN_NETWORK_NAME_BYTES, max = LENGTH_MAX_NETWORK_NAME_BYTES)
+    public String getNetworkName() {
+        return mNetworkName;
+    }
+
+    /** Returns the Extended PAN ID. */
+    @NonNull
+    @Size(LENGTH_EXTENDED_PAN_ID)
+    public byte[] getExtendedPanId() {
+        return mExtendedPanId.clone();
+    }
+
+    /** Returns the PAN ID. */
+    @IntRange(from = 0, to = 0xfffe)
+    public int getPanId() {
+        return mPanId;
+    }
+
+    /** Returns the Channel. */
+    @IntRange(from = 0, to = 65535)
+    public int getChannel() {
+        return mChannel;
+    }
+
+    /** Returns the Channel Page. */
+    @IntRange(from = 0, to = 255)
+    public int getChannelPage() {
+        return mChannelPage;
+    }
+
+    /**
+     * Returns the Channel masks. For the returned {@link SparseArray}, the key is the Channel Page
+     * and the value is the Channel Mask.
+     */
+    @NonNull
+    @Size(min = 1)
+    public SparseArray<byte[]> getChannelMask() {
+        return deepCloneSparseArray(mChannelMask);
+    }
+
+    private static SparseArray<byte[]> deepCloneSparseArray(SparseArray<byte[]> src) {
+        SparseArray<byte[]> dst = new SparseArray<>(src.size());
+        for (int i = 0; i < src.size(); i++) {
+            dst.put(src.keyAt(i), src.valueAt(i).clone());
+        }
+        return dst;
+    }
+
+    /** Returns the PSKc. */
+    @NonNull
+    @Size(LENGTH_PSKC)
+    public byte[] getPskc() {
+        return mPskc.clone();
+    }
+
+    /** Returns the Network Key. */
+    @NonNull
+    @Size(LENGTH_NETWORK_KEY)
+    public byte[] getNetworkKey() {
+        return mNetworkKey.clone();
+    }
+
+    /**
+     * Returns the Mesh-local Prefix. The length of the returned prefix is always {@link
+     * #LENGTH_MESH_LOCAL_PREFIX_BITS}.
+     */
+    @NonNull
+    public IpPrefix getMeshLocalPrefix() {
+        return mMeshLocalPrefix;
+    }
+
+    /** Returns the Security Policy. */
+    @NonNull
+    public SecurityPolicy getSecurityPolicy() {
+        return mSecurityPolicy;
+    }
+
+    /**
+     * Returns Thread TLVs which are not recognized by this device. The returned {@link SparseArray}
+     * associates TLV values to their keys.
+     *
+     * @hide
+     */
+    @NonNull
+    public SparseArray<byte[]> getUnknownTlvs() {
+        return deepCloneSparseArray(mUnknownTlvs);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (other == this) {
+            return true;
+        } else if (!(other instanceof ActiveOperationalDataset)) {
+            return false;
+        } else {
+            ActiveOperationalDataset otherDataset = (ActiveOperationalDataset) other;
+            return mActiveTimestamp.equals(otherDataset.mActiveTimestamp)
+                    && mNetworkName.equals(otherDataset.mNetworkName)
+                    && Arrays.equals(mExtendedPanId, otherDataset.mExtendedPanId)
+                    && mPanId == otherDataset.mPanId
+                    && mChannelPage == otherDataset.mChannelPage
+                    && mChannel == otherDataset.mChannel
+                    && areByteSparseArraysEqual(mChannelMask, otherDataset.mChannelMask)
+                    && Arrays.equals(mPskc, otherDataset.mPskc)
+                    && Arrays.equals(mNetworkKey, otherDataset.mNetworkKey)
+                    && mMeshLocalPrefix.equals(otherDataset.mMeshLocalPrefix)
+                    && mSecurityPolicy.equals(otherDataset.mSecurityPolicy)
+                    && areByteSparseArraysEqual(mUnknownTlvs, otherDataset.mUnknownTlvs);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return deepHashCode(
+                mActiveTimestamp,
+                mNetworkName,
+                mExtendedPanId,
+                mPanId,
+                mChannel,
+                mChannelPage,
+                mChannelMask,
+                mPskc,
+                mNetworkKey,
+                mMeshLocalPrefix,
+                mSecurityPolicy);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{networkName=")
+                .append(getNetworkName())
+                .append(", extendedPanId=")
+                .append(dumpHexString(getExtendedPanId()))
+                .append(", panId=")
+                .append(getPanId())
+                .append(", channel=")
+                .append(getChannel())
+                .append(", activeTimestamp=")
+                .append(getActiveTimestamp())
+                .append("}");
+        return sb.toString();
+    }
+
+    /** The builder for creating {@link ActiveOperationalDataset} objects. */
+    public static final class Builder {
+        private OperationalDatasetTimestamp mActiveTimestamp;
+        private String mNetworkName;
+        private byte[] mExtendedPanId;
+        private Integer mPanId;
+        private Integer mChannel;
+        private Integer mChannelPage;
+        private SparseArray<byte[]> mChannelMask;
+        private byte[] mPskc;
+        private byte[] mNetworkKey;
+        private IpPrefix mMeshLocalPrefix;
+        private SecurityPolicy mSecurityPolicy;
+        private SparseArray<byte[]> mUnknownTlvs;
+
+        /**
+         * Creates a {@link Builder} object with values from an {@link ActiveOperationalDataset}
+         * object.
+         */
+        public Builder(@NonNull ActiveOperationalDataset activeOpDataset) {
+            requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+
+            this.mActiveTimestamp = activeOpDataset.mActiveTimestamp;
+            this.mNetworkName = activeOpDataset.mNetworkName;
+            this.mExtendedPanId = activeOpDataset.mExtendedPanId.clone();
+            this.mPanId = activeOpDataset.mPanId;
+            this.mChannel = activeOpDataset.mChannel;
+            this.mChannelPage = activeOpDataset.mChannelPage;
+            this.mChannelMask = deepCloneSparseArray(activeOpDataset.mChannelMask);
+            this.mPskc = activeOpDataset.mPskc.clone();
+            this.mNetworkKey = activeOpDataset.mNetworkKey.clone();
+            this.mMeshLocalPrefix = activeOpDataset.mMeshLocalPrefix;
+            this.mSecurityPolicy = activeOpDataset.mSecurityPolicy;
+            this.mUnknownTlvs = deepCloneSparseArray(activeOpDataset.mUnknownTlvs);
+        }
+
+        /**
+         * Creates an empty {@link Builder} object.
+         *
+         * <p>An empty builder cannot build a new {@link ActiveOperationalDataset} object. The
+         * Active Operational Dataset parameters must be set with setters of this builder.
+         */
+        public Builder() {
+            mChannelMask = new SparseArray<>();
+            mUnknownTlvs = new SparseArray<>();
+        }
+
+        /**
+         * Sets the Active Timestamp.
+         *
+         * @param activeTimestamp Active Timestamp of the Operational Dataset
+         */
+        @NonNull
+        public Builder setActiveTimestamp(@NonNull OperationalDatasetTimestamp activeTimestamp) {
+            requireNonNull(activeTimestamp, "activeTimestamp cannot be null");
+            this.mActiveTimestamp = activeTimestamp;
+            return this;
+        }
+
+        /**
+         * Sets the Network Name.
+         *
+         * @param networkName the name of the Thread network
+         * @throws IllegalArgumentException if length of the UTF-8 representation of {@code
+         *     networkName} isn't in range of [{@link #LENGTH_MIN_NETWORK_NAME_BYTES}, {@link
+         *     #LENGTH_MAX_NETWORK_NAME_BYTES}].
+         */
+        @NonNull
+        public Builder setNetworkName(
+                @NonNull
+                        @Size(
+                                min = LENGTH_MIN_NETWORK_NAME_BYTES,
+                                max = LENGTH_MAX_NETWORK_NAME_BYTES)
+                        String networkName) {
+            requireNonNull(networkName, "networkName cannot be null");
+
+            int nameLength = networkName.getBytes(UTF_8).length;
+            checkArgument(
+                    nameLength >= LENGTH_MIN_NETWORK_NAME_BYTES
+                            && nameLength <= LENGTH_MAX_NETWORK_NAME_BYTES,
+                    "Invalid network name (length = %d, expectedLengthRange = [%d, %d])",
+                    nameLength,
+                    LENGTH_MIN_NETWORK_NAME_BYTES,
+                    LENGTH_MAX_NETWORK_NAME_BYTES);
+            this.mNetworkName = networkName;
+            return this;
+        }
+
+        /**
+         * Sets the Extended PAN ID.
+         *
+         * <p>Use with caution. A randomly generated Extended PAN ID should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @throws IllegalArgumentException if length of {@code extendedPanId} is not {@link
+         *     #LENGTH_EXTENDED_PAN_ID}.
+         */
+        @NonNull
+        public Builder setExtendedPanId(
+                @NonNull @Size(LENGTH_EXTENDED_PAN_ID) byte[] extendedPanId) {
+            requireNonNull(extendedPanId, "extendedPanId cannot be null");
+            checkArgument(
+                    extendedPanId.length == LENGTH_EXTENDED_PAN_ID,
+                    "Invalid extended PAN ID (length = %d, expectedLength = %d)",
+                    extendedPanId.length,
+                    LENGTH_EXTENDED_PAN_ID);
+            this.mExtendedPanId = extendedPanId.clone();
+            return this;
+        }
+
+        /**
+         * Sets the PAN ID.
+         *
+         * @throws IllegalArgumentException if {@code panId} is not in range of 0x0-0xfffe
+         */
+        @NonNull
+        public Builder setPanId(@IntRange(from = 0, to = 0xfffe) int panId) {
+            checkArgument(
+                    panId >= 0 && panId <= 0xfffe,
+                    "PAN ID exceeds allowed range (panid = %d, allowedRange = [0x0, 0xffff])",
+                    panId);
+            this.mPanId = panId;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Page and Channel.
+         *
+         * <p>Channel Pages other than {@link #CHANNEL_PAGE_24_GHZ} are undefined and may lead to
+         * unexpected behavior if it's applied to Thread devices.
+         *
+         * @throws IllegalArgumentException if invalid channel is specified for the {@code
+         *     channelPage}
+         */
+        @NonNull
+        public Builder setChannel(
+                @IntRange(from = 0, to = 255) int page,
+                @IntRange(from = 0, to = 65535) int channel) {
+            checkArgument(
+                    page >= 0 && page <= 255,
+                    "Invalid channel page (page = %d, allowedRange = [0, 255])",
+                    page);
+            if (page == CHANNEL_PAGE_24_GHZ) {
+                checkArgument(
+                        channel >= CHANNEL_MIN_24_GHZ && channel <= CHANNEL_MAX_24_GHZ,
+                        "Invalid channel %d in page %d (allowedChannelRange = [%d, %d])",
+                        channel,
+                        page,
+                        CHANNEL_MIN_24_GHZ,
+                        CHANNEL_MAX_24_GHZ);
+            } else {
+                checkArgument(
+                        channel >= 0 && channel <= 65535,
+                        "Invalid channel %d in page %d "
+                                + "(channel = %d, allowedChannelRange = [0, 65535])",
+                        channel,
+                        page,
+                        channel);
+            }
+
+            this.mChannelPage = page;
+            this.mChannel = channel;
+            return this;
+        }
+
+        /**
+         * Sets the Channel Mask.
+         *
+         * @throws IllegalArgumentException if {@code channelMask} is empty
+         */
+        @NonNull
+        public Builder setChannelMask(@NonNull @Size(min = 1) SparseArray<byte[]> channelMask) {
+            requireNonNull(channelMask, "channelMask cannot be null");
+            checkArgument(channelMask.size() > 0, "channelMask is empty");
+            this.mChannelMask = deepCloneSparseArray(channelMask);
+            return this;
+        }
+
+        /**
+         * Sets the PSKc.
+         *
+         * <p>Use with caution. A randomly generated PSKc should be used for real Thread networks.
+         * It's discouraged to call this method to override the default value created by {@link
+         * ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param pskc the key stretched version of the Commissioning Credential for the network
+         * @throws IllegalArgumentException if length of {@code pskc} is not {@link #LENGTH_PSKC}
+         */
+        @NonNull
+        public Builder setPskc(@NonNull @Size(LENGTH_PSKC) byte[] pskc) {
+            requireNonNull(pskc, "pskc cannot be null");
+            checkArgument(
+                    pskc.length == LENGTH_PSKC,
+                    "Invalid PSKc length (length = %d, expectedLength = %d)",
+                    pskc.length,
+                    LENGTH_PSKC);
+            this.mPskc = pskc.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Network Key.
+         *
+         * <p>Use with caution, randomly generated Network Key should be used for real Thread
+         * networks. It's discouraged to call this method to override the default value created by
+         * {@link ActiveOperationalDataset#createRandomDataset} in production.
+         *
+         * @param networkKey a 128-bit security key-derivation key for the Thread Network
+         * @throws IllegalArgumentException if length of {@code networkKey} is not {@link
+         *     #LENGTH_NETWORK_KEY}
+         */
+        @NonNull
+        public Builder setNetworkKey(@NonNull @Size(LENGTH_NETWORK_KEY) byte[] networkKey) {
+            requireNonNull(networkKey, "networkKey cannot be null");
+            checkArgument(
+                    networkKey.length == LENGTH_NETWORK_KEY,
+                    "Invalid network key length (length = %d, expectedLength = %d)",
+                    networkKey.length,
+                    LENGTH_NETWORK_KEY);
+            this.mNetworkKey = networkKey.clone();
+            return this;
+        }
+
+        /**
+         * Sets the Mesh-Local Prefix.
+         *
+         * @param meshLocalPrefix the prefix used for realm-local traffic within the mesh
+         * @throws IllegalArgumentException if prefix length of {@code meshLocalPrefix} isn't {@link
+         *     #LENGTH_MESH_LOCAL_PREFIX_BITS} or {@code meshLocalPrefix} doesn't start with {@code
+         *     0xfd}
+         */
+        @NonNull
+        public Builder setMeshLocalPrefix(@NonNull IpPrefix meshLocalPrefix) {
+            requireNonNull(meshLocalPrefix, "meshLocalPrefix cannot be null");
+            checkArgument(
+                    meshLocalPrefix.getPrefixLength() == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    meshLocalPrefix.getPrefixLength(),
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            checkArgument(
+                    meshLocalPrefix.getRawAddress()[0] == MESH_LOCAL_PREFIX_FIRST_BYTE,
+                    "Mesh-local prefix must start with 0xfd: " + meshLocalPrefix);
+            this.mMeshLocalPrefix = meshLocalPrefix;
+            return this;
+        }
+
+        @NonNull
+        private Builder setMeshLocalPrefix(byte[] meshLocalPrefix) {
+            final int prefixLength = meshLocalPrefix.length * 8;
+            checkArgument(
+                    prefixLength == LENGTH_MESH_LOCAL_PREFIX_BITS,
+                    "Invalid mesh-local prefix length (length = %d, expectedLength = %d)",
+                    prefixLength,
+                    LENGTH_MESH_LOCAL_PREFIX_BITS);
+            byte[] ip6RawAddress = new byte[16];
+            System.arraycopy(meshLocalPrefix, 0, ip6RawAddress, 0, meshLocalPrefix.length);
+            try {
+                return setMeshLocalPrefix(
+                        new IpPrefix(Inet6Address.getByAddress(ip6RawAddress), prefixLength));
+            } catch (UnknownHostException e) {
+                // Can't happen because numeric address is provided
+                throw new AssertionError(e);
+            }
+        }
+
+        /** Sets the Security Policy. */
+        @NonNull
+        public Builder setSecurityPolicy(@NonNull SecurityPolicy securityPolicy) {
+            requireNonNull(securityPolicy, "securityPolicy cannot be null");
+            this.mSecurityPolicy = securityPolicy;
+            return this;
+        }
+
+        /**
+         * Sets additional unknown TLVs.
+         *
+         * @hide
+         */
+        @NonNull
+        public Builder setUnknownTlvs(@NonNull SparseArray<byte[]> unknownTlvs) {
+            requireNonNull(unknownTlvs, "unknownTlvs cannot be null");
+            mUnknownTlvs = deepCloneSparseArray(unknownTlvs);
+            return this;
+        }
+
+        /** Adds one more unknown TLV. @hide */
+        @VisibleForTesting
+        @NonNull
+        public Builder addUnknownTlv(int type, byte[] value) {
+            mUnknownTlvs.put(type, value);
+            return this;
+        }
+
+        /**
+         * Creates a new {@link ActiveOperationalDataset} object.
+         *
+         * @throws IllegalStateException if any of the fields isn't set or the total length exceeds
+         *     {@link #LENGTH_MAX_DATASET_TLVS} bytes
+         */
+        @NonNull
+        public ActiveOperationalDataset build() {
+            checkState(mActiveTimestamp != null, "Active Timestamp is missing");
+            checkState(mNetworkName != null, "Network Name is missing");
+            checkState(mExtendedPanId != null, "Extended PAN ID is missing");
+            checkState(mPanId != null, "PAN ID is missing");
+            checkState(mChannel != null, "Channel is missing");
+            checkState(mChannelPage != null, "Channel Page is missing");
+            checkState(mChannelMask.size() != 0, "Channel Mask is missing");
+            checkState(mPskc != null, "PSKc is missing");
+            checkState(mNetworkKey != null, "Network Key is missing");
+            checkState(mMeshLocalPrefix != null, "Mesh Local Prefix is missing");
+            checkState(mSecurityPolicy != null, "Security Policy is missing");
+
+            int length = getTotalDatasetLength();
+            if (length > LENGTH_MAX_DATASET_TLVS) {
+                throw new IllegalStateException(
+                        String.format(
+                                "Total dataset length exceeds max length %d (actual is %d)",
+                                LENGTH_MAX_DATASET_TLVS, length));
+            }
+
+            return new ActiveOperationalDataset(this);
+        }
+
+        private int getTotalDatasetLength() {
+            int length =
+                    2 * 9 // 9 fields with 1 byte of type and 1 byte of length
+                            + OperationalDatasetTimestamp.LENGTH_TIMESTAMP
+                            + mNetworkName.getBytes(UTF_8).length
+                            + LENGTH_EXTENDED_PAN_ID
+                            + LENGTH_PAN_ID
+                            + LENGTH_CHANNEL
+                            + LENGTH_PSKC
+                            + LENGTH_NETWORK_KEY
+                            + LENGTH_MESH_LOCAL_PREFIX_BITS / 8
+                            + mSecurityPolicy.toTlvValue().length;
+
+            for (int i = 0; i < mChannelMask.size(); i++) {
+                length += 2 + mChannelMask.valueAt(i).length;
+            }
+
+            // For the type and length bytes of the Channel Mask TLV because the masks are encoded
+            // as TLVs in TLV.
+            length += 2;
+
+            for (int i = 0; i < mUnknownTlvs.size(); i++) {
+                length += 2 + mUnknownTlvs.valueAt(i).length;
+            }
+
+            return length;
+        }
+    }
+
+    /**
+     * The Security Policy of Thread Operational Dataset which provides an administrator with a way
+     * to enable or disable certain security related behaviors.
+     */
+    public static final class SecurityPolicy {
+        /** The default Rotation Time in hours. */
+        public static final int DEFAULT_ROTATION_TIME_HOURS = 672;
+        /** The minimum length of Security Policy flags in bytes. */
+        public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1;
+        /** The length of Rotation Time TLV value in bytes. */
+        private static final int LENGTH_SECURITY_POLICY_ROTATION_TIME = 2;
+
+        private final int mRotationTimeHours;
+        private final byte[] mFlags;
+
+        /**
+         * Creates a new {@link SecurityPolicy} object.
+         *
+         * @param rotationTimeHours the value for Thread key rotation in hours. Must be in range of
+         *     0x1-0xffff.
+         * @param flags security policy flags with length of either 1 byte for Thread 1.1 or 2 bytes
+         *     for Thread 1.2 or higher.
+         * @throws IllegalArgumentException if {@code rotationTimeHours} is not in range of
+         *     0x1-0xffff or length of {@code flags} is smaller than {@link
+         *     #LENGTH_MIN_SECURITY_POLICY_FLAGS}.
+         */
+        public SecurityPolicy(
+                @IntRange(from = 0x1, to = 0xffff) int rotationTimeHours,
+                @NonNull @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS) byte[] flags) {
+            requireNonNull(flags, "flags cannot be null");
+            checkArgument(
+                    rotationTimeHours >= 1 && rotationTimeHours <= 0xffff,
+                    "Rotation time exceeds allowed range (rotationTimeHours = %d, allowedRange ="
+                            + " [0x1, 0xffff])",
+                    rotationTimeHours);
+            checkArgument(
+                    flags.length >= LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid security policy flags length (length = %d, minimumLength = %d)",
+                    flags.length,
+                    LENGTH_MIN_SECURITY_POLICY_FLAGS);
+            this.mRotationTimeHours = rotationTimeHours;
+            this.mFlags = flags.clone();
+        }
+
+        /**
+         * Creates a new {@link SecurityPolicy} object from the Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public static SecurityPolicy fromTlvValue(byte[] encodedSecurityPolicy) {
+            checkArgument(
+                    encodedSecurityPolicy.length
+                            >= LENGTH_SECURITY_POLICY_ROTATION_TIME
+                                    + LENGTH_MIN_SECURITY_POLICY_FLAGS,
+                    "Invalid Security Policy TLV length (length = %d, minimumLength = %d)",
+                    encodedSecurityPolicy.length,
+                    LENGTH_SECURITY_POLICY_ROTATION_TIME + LENGTH_MIN_SECURITY_POLICY_FLAGS);
+
+            return new SecurityPolicy(
+                    ((encodedSecurityPolicy[0] & 0xff) << 8) | (encodedSecurityPolicy[1] & 0xff),
+                    Arrays.copyOfRange(
+                            encodedSecurityPolicy,
+                            LENGTH_SECURITY_POLICY_ROTATION_TIME,
+                            encodedSecurityPolicy.length));
+        }
+
+        /**
+         * Converts this {@link SecurityPolicy} object to Security Policy TLV value.
+         *
+         * @hide
+         */
+        @VisibleForTesting
+        @NonNull
+        public byte[] toTlvValue() {
+            ByteArrayOutputStream result = new ByteArrayOutputStream();
+            result.write(mRotationTimeHours >> 8);
+            result.write(mRotationTimeHours);
+            result.write(mFlags, 0, mFlags.length);
+            return result.toByteArray();
+        }
+
+        /** Returns the Security Policy Rotation Time in hours. */
+        @IntRange(from = 0x1, to = 0xffff)
+        public int getRotationTimeHours() {
+            return mRotationTimeHours;
+        }
+
+        /** Returns 1 byte flags for Thread 1.1 or 2 bytes flags for Thread 1.2. */
+        @NonNull
+        @Size(min = LENGTH_MIN_SECURITY_POLICY_FLAGS)
+        public byte[] getFlags() {
+            return mFlags.clone();
+        }
+
+        @Override
+        public boolean equals(@Nullable Object other) {
+            if (this == other) {
+                return true;
+            } else if (!(other instanceof SecurityPolicy)) {
+                return false;
+            } else {
+                SecurityPolicy otherSecurityPolicy = (SecurityPolicy) other;
+                return mRotationTimeHours == otherSecurityPolicy.mRotationTimeHours
+                        && Arrays.equals(mFlags, otherSecurityPolicy.mFlags);
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return deepHashCode(mRotationTimeHours, mFlags);
+        }
+
+        @Override
+        public String toString() {
+            StringBuilder sb = new StringBuilder();
+            sb.append("{rotation=")
+                    .append(mRotationTimeHours)
+                    .append(", flags=")
+                    .append(dumpHexString(mFlags))
+                    .append("}");
+            return sb.toString();
+        }
+    }
+}
diff --git a/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
new file mode 100644
index 0000000..bda9373
--- /dev/null
+++ b/thread/framework/java/android/net/thread/OperationalDatasetTimestamp.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+
+import java.nio.ByteBuffer;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * The timestamp of Thread Operational Dataset.
+ *
+ * @see ActiveOperationalDataset
+ * @see PendingOperationalDataset
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class OperationalDatasetTimestamp {
+    /** @hide */
+    public static final int LENGTH_TIMESTAMP = Long.BYTES;
+
+    private static final long TICKS_UPPER_BOUND = 0x8000;
+
+    private final Instant mInstant;
+    private final boolean mIsAuthoritativeSource;
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from an {@link Instant}.
+     *
+     * <p>The {@code seconds} is set to {@code instant.getEpochSecond()}, {@code ticks} is set to
+     * {@link instant#getNano()} based on frequency of 32768 Hz, and {@code isAuthoritativeSource}
+     * is set to {@code true}.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromInstant(@NonNull Instant instant) {
+        return new OperationalDatasetTimestamp(instant, /* isAuthoritativeSource= */ true);
+    }
+
+    /** Converts this {@link OperationalDatasetTimestamp} object to an {@link Instant}. */
+    @NonNull
+    public Instant toInstant() {
+        return mInstant;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object from the OperationalDatasetTimestamp
+     * TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public static OperationalDatasetTimestamp fromTlvValue(@NonNull byte[] encodedTimestamp) {
+        requireNonNull(encodedTimestamp, "encodedTimestamp cannot be null");
+        checkArgument(
+                encodedTimestamp.length == LENGTH_TIMESTAMP,
+                "Invalid Thread OperationalDatasetTimestamp length (length = %d,"
+                        + " expectedLength=%d)",
+                encodedTimestamp.length,
+                LENGTH_TIMESTAMP);
+        long longTimestamp = ByteBuffer.wrap(encodedTimestamp).getLong();
+        return new OperationalDatasetTimestamp(
+                (longTimestamp >> 16) & 0x0000ffffffffffffL,
+                (int) ((longTimestamp >> 1) & 0x7fffL),
+                (longTimestamp & 0x01) != 0);
+    }
+
+    /**
+     * Converts this {@link OperationalDatasetTimestamp} object to Thread TLV value.
+     *
+     * @hide
+     */
+    @NonNull
+    public byte[] toTlvValue() {
+        byte[] tlv = new byte[LENGTH_TIMESTAMP];
+        ByteBuffer buffer = ByteBuffer.wrap(tlv);
+        long encodedValue =
+                (mInstant.getEpochSecond() << 16)
+                        | ((mInstant.getNano() * TICKS_UPPER_BOUND / 1000000000L) << 1)
+                        | (mIsAuthoritativeSource ? 1 : 0);
+        buffer.putLong(encodedValue);
+        return tlv;
+    }
+
+    /**
+     * Creates a new {@link OperationalDatasetTimestamp} object.
+     *
+     * @param seconds the value encodes a Unix Time value. Must be in the range of
+     *     0x0-0xffffffffffffL
+     * @param ticks the value encodes the fractional Unix Time value in 32.768 kHz resolution. Must
+     *     be in the range of 0x0-0x7fff
+     * @param isAuthoritativeSource the flag indicates the time was obtained from an authoritative
+     *     source: either NTP (Network Time Protocol), GPS (Global Positioning System), cell
+     *     network, or other method
+     * @throws IllegalArgumentException if the {@code seconds} is not in range of
+     *     0x0-0xffffffffffffL or {@code ticks} is not in range of 0x0-0x7fff
+     */
+    public OperationalDatasetTimestamp(
+            @IntRange(from = 0x0, to = 0xffffffffffffL) long seconds,
+            @IntRange(from = 0x0, to = 0x7fff) int ticks,
+            boolean isAuthoritativeSource) {
+        this(makeInstant(seconds, ticks), isAuthoritativeSource);
+    }
+
+    private static Instant makeInstant(long seconds, int ticks) {
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "seconds exceeds allowed range (seconds = %d,"
+                        + " allowedRange = [0x0, 0xffffffffffffL])",
+                seconds);
+        checkArgument(
+                ticks >= 0 && ticks <= 0x7fff,
+                "ticks exceeds allowed ranged (ticks = %d, allowedRange" + " = [0x0, 0x7fff])",
+                ticks);
+        long nanos = Math.round((double) ticks * 1000000000L / TICKS_UPPER_BOUND);
+        return Instant.ofEpochSecond(seconds, nanos);
+    }
+
+    /**
+     * Creates new {@link OperationalDatasetTimestamp} object.
+     *
+     * @throws IllegalArgumentException if {@code instant.getEpochSecond()} is larger than {@code
+     *     0xffffffffffffL}
+     */
+    private OperationalDatasetTimestamp(@NonNull Instant instant, boolean isAuthoritativeSource) {
+        requireNonNull(instant, "instant cannot be null");
+        long seconds = instant.getEpochSecond();
+        checkArgument(
+                seconds >= 0 && seconds <= 0xffffffffffffL,
+                "instant seconds exceeds allowed range (seconds = %d, allowedRange = [0x0,"
+                        + " 0xffffffffffffL])",
+                seconds);
+        mInstant = instant;
+        mIsAuthoritativeSource = isAuthoritativeSource;
+    }
+
+    /**
+     * Returns the rounded ticks converted from the nano seconds.
+     *
+     * <p>Note that rhe return value can be as large as {@code TICKS_UPPER_BOUND}.
+     */
+    private static int getRoundedTicks(long nanos) {
+        return (int) Math.round((double) nanos * TICKS_UPPER_BOUND / 1000000000L);
+    }
+
+    /** Returns the seconds portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0xffffffffffffL) long getSeconds() {
+        return mInstant.getEpochSecond() + getRoundedTicks(mInstant.getNano()) / TICKS_UPPER_BOUND;
+    }
+
+    /** Returns the ticks portion of the timestamp. */
+    public @IntRange(from = 0x0, to = 0x7fff) int getTicks() {
+        // the rounded ticks can be 0x8000 if mInstant.getNano() >= 999984742
+        return (int) (getRoundedTicks(mInstant.getNano()) % TICKS_UPPER_BOUND);
+    }
+
+    /** Returns {@code true} if the timestamp comes from an authoritative source. */
+    public boolean isAuthoritativeSource() {
+        return mIsAuthoritativeSource;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{seconds=")
+                .append(getSeconds())
+                .append(", ticks=")
+                .append(getTicks())
+                .append(", isAuthoritativeSource=")
+                .append(isAuthoritativeSource())
+                .append(", instant=")
+                .append(toInstant())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public boolean equals(@Nullable Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof OperationalDatasetTimestamp)) {
+            return false;
+        } else {
+            OperationalDatasetTimestamp otherTimestamp = (OperationalDatasetTimestamp) other;
+            return mInstant.equals(otherTimestamp.mInstant)
+                    && mIsAuthoritativeSource == otherTimestamp.mIsAuthoritativeSource;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mInstant, mIsAuthoritativeSource);
+    }
+}
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
new file mode 100644
index 0000000..e5bc05e
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+parcelable PendingOperationalDataset;
diff --git a/thread/framework/java/android/net/thread/PendingOperationalDataset.java b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
new file mode 100644
index 0000000..4762d7f
--- /dev/null
+++ b/thread/framework/java/android/net/thread/PendingOperationalDataset.java
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.SparseArray;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.Objects;
+
+/**
+ * Data interface for managing a Thread Pending Operational Dataset.
+ *
+ * <p>The Pending Operational Dataset represents an Operational Dataset which will become Active in
+ * a given delay. This is typically used to deploy new network parameters (e.g. Network Key or
+ * Channel) to all devices in the network.
+ *
+ * @hide
+ */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
+@SystemApi
+public final class PendingOperationalDataset implements Parcelable {
+    // Value defined in Thread spec 8.10.1.16
+    private static final int TYPE_PENDING_TIMESTAMP = 51;
+
+    // Values defined in Thread spec 8.10.1.17
+    private static final int TYPE_DELAY_TIMER = 52;
+    private static final int LENGTH_DELAY_TIMER_BYTES = 4;
+
+    @NonNull
+    public static final Creator<PendingOperationalDataset> CREATOR =
+            new Creator<>() {
+                @Override
+                public PendingOperationalDataset createFromParcel(Parcel in) {
+                    return PendingOperationalDataset.fromThreadTlvs(in.createByteArray());
+                }
+
+                @Override
+                public PendingOperationalDataset[] newArray(int size) {
+                    return new PendingOperationalDataset[size];
+                }
+            };
+
+    @NonNull private final ActiveOperationalDataset mActiveOpDataset;
+    @NonNull private final OperationalDatasetTimestamp mPendingTimestamp;
+    @NonNull private final Duration mDelayTimer;
+
+    /** Creates a new {@link PendingOperationalDataset} object. */
+    public PendingOperationalDataset(
+            @NonNull ActiveOperationalDataset activeOpDataset,
+            @NonNull OperationalDatasetTimestamp pendingTimestamp,
+            @NonNull Duration delayTimer) {
+        requireNonNull(activeOpDataset, "activeOpDataset cannot be null");
+        requireNonNull(pendingTimestamp, "pendingTimestamp cannot be null");
+        requireNonNull(delayTimer, "delayTimer cannot be null");
+        this.mActiveOpDataset = activeOpDataset;
+        this.mPendingTimestamp = pendingTimestamp;
+        this.mDelayTimer = delayTimer;
+    }
+
+    /**
+     * Creates a new {@link PendingOperationalDataset} object from a series of Thread TLVs.
+     *
+     * <p>{@code tlvs} can be obtained from the value of a Thread Pending Operational Dataset TLV
+     * (see the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition) or the return value of {@link #toThreadTlvs}.
+     *
+     * @throws IllegalArgumentException if {@code tlvs} is malformed or contains an invalid Thread
+     *     TLV
+     */
+    @NonNull
+    public static PendingOperationalDataset fromThreadTlvs(@NonNull byte[] tlvs) {
+        requireNonNull(tlvs, "tlvs cannot be null");
+
+        SparseArray<byte[]> newUnknownTlvs = new SparseArray<>();
+        OperationalDatasetTimestamp pendingTimestamp = null;
+        Duration delayTimer = null;
+        ActiveOperationalDataset activeDataset = ActiveOperationalDataset.fromThreadTlvs(tlvs);
+        SparseArray<byte[]> unknownTlvs = activeDataset.getUnknownTlvs();
+        for (int i = 0; i < unknownTlvs.size(); i++) {
+            int key = unknownTlvs.keyAt(i);
+            byte[] value = unknownTlvs.valueAt(i);
+            switch (key) {
+                case TYPE_PENDING_TIMESTAMP:
+                    pendingTimestamp = OperationalDatasetTimestamp.fromTlvValue(value);
+                    break;
+                case TYPE_DELAY_TIMER:
+                    checkArgument(
+                            value.length == LENGTH_DELAY_TIMER_BYTES,
+                            "Invalid delay timer (length = %d, expectedLength = %d)",
+                            value.length,
+                            LENGTH_DELAY_TIMER_BYTES);
+                    int millis = ByteBuffer.wrap(value).getInt();
+                    delayTimer = Duration.ofMillis(Integer.toUnsignedLong(millis));
+                    break;
+                default:
+                    newUnknownTlvs.put(key, value);
+                    break;
+            }
+        }
+
+        if (pendingTimestamp == null) {
+            throw new IllegalArgumentException("Pending Timestamp is missing");
+        }
+        if (delayTimer == null) {
+            throw new IllegalArgumentException("Delay Timer is missing");
+        }
+
+        activeDataset =
+                new ActiveOperationalDataset.Builder(activeDataset)
+                        .setUnknownTlvs(newUnknownTlvs)
+                        .build();
+        return new PendingOperationalDataset(activeDataset, pendingTimestamp, delayTimer);
+    }
+
+    /** Returns the Active Operational Dataset. */
+    @NonNull
+    public ActiveOperationalDataset getActiveOperationalDataset() {
+        return mActiveOpDataset;
+    }
+
+    /** Returns the Pending Timestamp. */
+    @NonNull
+    public OperationalDatasetTimestamp getPendingTimestamp() {
+        return mPendingTimestamp;
+    }
+
+    /** Returns the Delay Timer. */
+    @NonNull
+    public Duration getDelayTimer() {
+        return mDelayTimer;
+    }
+
+    /**
+     * Converts this {@link PendingOperationalDataset} object to a series of Thread TLVs.
+     *
+     * <p>See the <a href="https://www.threadgroup.org/support#specifications">Thread
+     * specification</a> for the definition of the Thread TLV format.
+     */
+    @NonNull
+    public byte[] toThreadTlvs() {
+        ByteArrayOutputStream dataset = new ByteArrayOutputStream();
+
+        byte[] activeDatasetBytes = mActiveOpDataset.toThreadTlvs();
+        dataset.write(activeDatasetBytes, 0, activeDatasetBytes.length);
+
+        dataset.write(TYPE_PENDING_TIMESTAMP);
+        byte[] pendingTimestampBytes = mPendingTimestamp.toTlvValue();
+        dataset.write(pendingTimestampBytes.length);
+        dataset.write(pendingTimestampBytes, 0, pendingTimestampBytes.length);
+
+        dataset.write(TYPE_DELAY_TIMER);
+        byte[] delayTimerBytes = new byte[LENGTH_DELAY_TIMER_BYTES];
+        ByteBuffer.wrap(delayTimerBytes).putInt((int) mDelayTimer.toMillis());
+        dataset.write(delayTimerBytes.length);
+        dataset.write(delayTimerBytes, 0, delayTimerBytes.length);
+
+        return dataset.toByteArray();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof PendingOperationalDataset)) {
+            return false;
+        } else {
+            PendingOperationalDataset otherDataset = (PendingOperationalDataset) other;
+            return mActiveOpDataset.equals(otherDataset.mActiveOpDataset)
+                    && mPendingTimestamp.equals(otherDataset.mPendingTimestamp)
+                    && mDelayTimer.equals(otherDataset.mDelayTimer);
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mActiveOpDataset, mPendingTimestamp, mDelayTimer);
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("{activeDataset=")
+                .append(getActiveOperationalDataset())
+                .append(", pendingTimestamp=")
+                .append(getPendingTimestamp())
+                .append(", delayTimer=")
+                .append(getDelayTimer())
+                .append("}");
+        return sb.toString();
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeByteArray(toThreadTlvs());
+    }
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index fe189c2..7575757 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -18,6 +18,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import android.annotation.FlaggedApi;
 import android.annotation.IntDef;
 import android.annotation.NonNull;
 import android.annotation.SystemApi;
@@ -30,9 +31,10 @@
  * Provides the primary API for controlling all aspects of a Thread network.
  *
  * @hide
- */
+*/
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
 @SystemApi
-public class ThreadNetworkController {
+public final class ThreadNetworkController {
 
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkFlags.java b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
new file mode 100644
index 0000000..e6ab988
--- /dev/null
+++ b/thread/framework/java/android/net/thread/ThreadNetworkFlags.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+/**
+ * Container for flag constants defined in the "thread_network" namespace.
+ *
+ * @hide
+ */
+// TODO: replace this class with auto-generated "com.android.net.thread.flags.Flags" once the
+// flagging infra is fully supported for mainline modules.
+public final class ThreadNetworkFlags {
+    /** @hide */
+    public static final String FLAG_THREAD_ENABLED = "com.android.net.thread.flags.thread_enabled";
+
+    private ThreadNetworkFlags() {}
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index 2a253a1..c3bdbd7 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -18,6 +18,7 @@
 
 import static java.util.Objects.requireNonNull;
 
+import android.annotation.FlaggedApi;
 import android.annotation.NonNull;
 import android.annotation.SystemApi;
 import android.annotation.SystemService;
@@ -34,9 +35,10 @@
  *
  * @hide
  */
+@FlaggedApi(ThreadNetworkFlags.FLAG_THREAD_ENABLED)
 @SystemApi
 @SystemService(ThreadNetworkManager.SERVICE_NAME)
-public class ThreadNetworkManager {
+public final class ThreadNetworkManager {
     /**
      * This value tracks {@link Context#THREAD_NETWORK_SERVICE}.
      *
diff --git a/thread/tests/cts/Android.bp b/thread/tests/cts/Android.bp
index 278798e..ce770e0 100644
--- a/thread/tests/cts/Android.bp
+++ b/thread/tests/cts/Android.bp
@@ -37,6 +37,7 @@
         "androidx.test.ext.junit",
         "compatibility-device-util-axt",
         "ctstestrunner-axt",
+        "guava-android-testlib",
         "net-tests-utils",
         "truth",
     ],
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 5ba605f..ffc181c 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -47,5 +47,7 @@
 
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="package" value="android.net.thread.cts" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..39df21b
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/ActiveOperationalDatasetTest.java
@@ -0,0 +1,737 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.cts;
+
+import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+
+/** CTS tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class ActiveOperationalDatasetTest {
+    private static final int TYPE_ACTIVE_TIMESTAMP = 14;
+    private static final int TYPE_CHANNEL = 0;
+    private static final int TYPE_CHANNEL_MASK = 53;
+    private static final int TYPE_EXTENDED_PAN_ID = 2;
+    private static final int TYPE_MESH_LOCAL_PREFIX = 7;
+    private static final int TYPE_NETWORK_KEY = 5;
+    private static final int TYPE_NETWORK_NAME = 3;
+    private static final int TYPE_PAN_ID = 1;
+    private static final int TYPE_PSKC = 4;
+    private static final int TYPE_SECURITY_POLICY = 12;
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    private static byte[] removeTlv(byte[] dataset, int type) {
+        ByteArrayOutputStream os = new ByteArrayOutputStream(dataset.length);
+        int i = 0;
+        while (i < dataset.length) {
+            int ty = dataset[i++] & 0xff;
+            byte length = dataset[i++];
+            if (ty != type) {
+                byte[] value = Arrays.copyOfRange(dataset, i, i + length);
+                os.write(ty);
+                os.write(length);
+                os.writeBytes(value);
+            }
+            i += length;
+        }
+        return os.toByteArray();
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    private static byte[] replaceTlv(byte[] dataset, int type, String newTlvHex) {
+        return addTlv(removeTlv(dataset, type), newTlvHex);
+    }
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void fromThreadTlvs_tooLongTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = new byte[255];
+        invalidTlv[0] = (byte) 0xff;
+
+        // This is invalid because the TLV has max total length of 254 bytes and the value length
+        // can't exceeds 252 ( = 254 - 1 - 1)
+        invalidTlv[1] = (byte) 253;
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_KEY, "05080000000000000000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkKeyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_KEY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP, "0E0700000000010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noActiveTimestampTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_ACTIVE_TIMESTAMP);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_emptyName_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_NETWORK_NAME, "0300");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidNetworkNameTlv_tooLongName_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(
+                        VALID_DATASET, TYPE_NETWORK_NAME, "03114142434445464748494A4B4C4D4E4F5051");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noNetworkNameTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_NETWORK_NAME);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidChannelTlv_channelMissing_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_undefinedChannelPage_success() {
+        byte[] datasetTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003010020");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(datasetTlv);
+
+        assertThat(dataset.getChannelPage()).isEqualTo(0x01);
+        assertThat(dataset.getChannel()).isEqualTo(0x20);
+    }
+
+    @Test
+    public void fromThreadTlvs_invalid2P4GhzChannel_throwsIllegalArgument() {
+        byte[] invalidTlv1 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300000A");
+        byte[] invalidTlv2 = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "000300001B");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv1));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv2));
+    }
+
+    @Test
+    public void fromThreadTlvs_valid2P4GhzChannelTlv_success() {
+        byte[] validTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL, "0003000010");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validTlv);
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfChannelMaskEntry_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350100");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_inconsistentChannelMaskLength_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "3506000500010000");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_unsupportedChannelMaskLength_success() {
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(
+                        replaceTlv(VALID_DATASET, TYPE_CHANNEL_MASK, "350700050001000000"));
+
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x01, 0x00, 0x00, 0x00});
+    }
+
+    @Test
+    public void fromThreadTlvs_noChannelMaskTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_CHANNEL_MASK);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_PAN_ID, "010101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID, "020700010203040506");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noExtendedPanIdTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_EXTENDED_PAN_ID);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_PSKC, "0411000102030405060708090A0B0C0D0E0F10");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noPskcTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_PSKC);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_invalidMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv =
+                replaceTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX, "0709FD0001020304050607");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noMeshLocalPrefixTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_MESH_LOCAL_PREFIX);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_tooShortSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = replaceTlv(VALID_DATASET, TYPE_SECURITY_POLICY, "0C0101");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_noSecurityPolicyTlv_throwsIllegalArgument() {
+        byte[] invalidTlv = removeTlv(VALID_DATASET, TYPE_SECURITY_POLICY);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(invalidTlv));
+    }
+
+    @Test
+    public void fromThreadTlvs_lengthAndDataMissing_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {(byte) 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_prematureEndOfData_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActiveOperationalDataset.fromThreadTlvs(new byte[] {0x00, 0x03, 0x00, 0x00}));
+    }
+
+    @Test
+    public void fromThreadTlvs_validFullDataset_success() {
+        // A valid Thread active operational dataset:
+        // Active Timestamp: 1
+        // Channel: 19
+        // Channel Mask: 0x07FFF800
+        // Ext PAN ID: ACC214689BC40BDF
+        // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+        // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+        // Network Name: OpenThread-d9a0
+        // PAN ID: 0xD9A0
+        // PSKc: A245479C836D551B9CA557F7B9D351B4
+        // Security Policy: 672 onrcb
+        byte[] validDatasetTlv =
+                base16().decode(
+                                "0E080000000000010000000300001335060004001FFFE002"
+                                        + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                        + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                        + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                        + "B9D351B40C0402A0FFF8");
+
+        ActiveOperationalDataset dataset = ActiveOperationalDataset.fromThreadTlvs(validDatasetTlv);
+
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(base16().decode("F26B3153760F519A63BAFDDFFC80D2AF"));
+        assertThat(dataset.getPanId()).isEqualTo(0xd9a0);
+        assertThat(dataset.getExtendedPanId()).isEqualTo(base16().decode("ACC214689BC40BDF"));
+        assertThat(dataset.getChannel()).isEqualTo(19);
+        assertThat(dataset.getNetworkName()).isEqualTo("OpenThread-d9a0");
+        assertThat(dataset.getPskc())
+                .isEqualTo(base16().decode("A245479C836D551B9CA557F7B9D351B4"));
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        SparseArray<byte[]> channelMask = dataset.getChannelMask();
+        assertThat(channelMask.size()).isEqualTo(1);
+        assertThat(channelMask.get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd64:db12:25f4:7e0b::/64"));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        final byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+
+        byte[] newDatasetTlvs = dataset.toThreadTlvs();
+        String newDatasetTlvsHex = base16().encode(newDatasetTlvs);
+        assertThat(newDatasetTlvs.length).isEqualTo(datasetWithUnknownTlvs.length);
+        assertThat(newDatasetTlvsHex).contains("AA01FF");
+        assertThat(newDatasetTlvsHex).contains("BB020102");
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.createRandomDataset();
+
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void builder_buildWithdefaultValues_throwsIllegalState() {
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setValidNetworkKey_success() {
+        final byte[] networkKey =
+                new byte[] {
+                    0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
+                    0x0d, 0x0e, 0x0f
+                };
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkKey(networkKey)
+                        .build();
+
+        assertThat(dataset.getNetworkKey()).isEqualTo(networkKey);
+    }
+
+    @Test
+    public void builder_setInvalidNetworkKey_throwsIllegalArgument() {
+        byte[] invalidNetworkKey = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkKey(invalidNetworkKey));
+    }
+
+    @Test
+    public void builder_setValidExtendedPanId_success() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setExtendedPanId(extendedPanId)
+                        .build();
+
+        assertThat(dataset.getExtendedPanId()).isEqualTo(extendedPanId);
+    }
+
+    @Test
+    public void builder_setInvalidExtendedPanId_throwsIllegalArgument() {
+        byte[] extendedPanId = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06};
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setExtendedPanId(extendedPanId));
+    }
+
+    @Test
+    public void builder_setValidPanId_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setPanId(0xfffe)
+                        .build();
+
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe);
+    }
+
+    @Test
+    public void builder_setInvalidPanId_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPanId(0xffff));
+    }
+
+    @Test
+    public void builder_setInvalidChannel_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 0));
+        assertThrows(IllegalArgumentException.class, () -> builder.setChannel(0, 27));
+    }
+
+    @Test
+    public void builder_setValid2P4GhzChannel_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setChannel(CHANNEL_PAGE_24_GHZ, 16)
+                        .build();
+
+        assertThat(dataset.getChannel()).isEqualTo(16);
+        assertThat(dataset.getChannelPage()).isEqualTo(CHANNEL_PAGE_24_GHZ);
+    }
+
+    @Test
+    public void builder_setValidNetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("ot-network")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("ot-network");
+    }
+
+    @Test
+    public void builder_setEmptyNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName(""));
+    }
+
+    @Test
+    public void builder_setTooLongNetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> builder.setNetworkName("openthread-network"));
+    }
+
+    @Test
+    public void builder_setTooLongUtf8NetworkName_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // UTF-8 encoded length of "我的线程网络" is 18 bytes which exceeds the max length
+        assertThrows(IllegalArgumentException.class, () -> builder.setNetworkName("我的线程网络"));
+    }
+
+    @Test
+    public void builder_setValidUtf8NetworkName_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setNetworkName("我的网络")
+                        .build();
+
+        assertThat(dataset.getNetworkName()).isEqualTo("我的网络");
+    }
+
+    @Test
+    public void builder_setValidPskc_success() {
+        byte[] pskc = base16().decode("A245479C836D551B9CA557F7B9D351B4");
+
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset()).setPskc(pskc).build();
+
+        assertThat(dataset.getPskc()).isEqualTo(pskc);
+    }
+
+    @Test
+    public void builder_setTooLongPskc_throwsIllegalArgument() {
+        byte[] tooLongPskc = base16().decode("A245479C836D551B9CA557F7B9D351B400");
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(IllegalArgumentException.class, () -> builder.setPskc(tooLongPskc));
+    }
+
+    @Test
+    public void builder_setValidChannelMask_success() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        SparseArray<byte[]> channelMask = new SparseArray<byte[]>(1);
+        channelMask.put(0, new byte[] {0x00, 0x00, 0x01, 0x00});
+
+        ActiveOperationalDataset dataset = builder.setChannelMask(channelMask).build();
+
+        SparseArray<byte[]> resultChannelMask = dataset.getChannelMask();
+        assertThat(resultChannelMask.size()).isEqualTo(1);
+        assertThat(resultChannelMask.get(0)).isEqualTo(new byte[] {0x00, 0x00, 0x01, 0x00});
+    }
+
+    @Test
+    public void builder_setEmptyChannelMask_throwsIllegalArgument() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setChannelMask(new SparseArray<byte[]>()));
+    }
+
+    @Test
+    public void builder_setValidActiveTimestamp_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setActiveTimestamp(
+                                new OperationalDatasetTimestamp(
+                                        /* seconds= */ 1,
+                                        /* ticks= */ 0,
+                                        /* isAuthoritativeSource= */ true))
+                        .build();
+
+        assertThat(dataset.getActiveTimestamp().getSeconds()).isEqualTo(1);
+        assertThat(dataset.getActiveTimestamp().getTicks()).isEqualTo(0);
+        assertThat(dataset.getActiveTimestamp().isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void builder_wrongMeshLocalPrefixLength_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        // The Mesh-Local Prefix length must be 64 bits
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/32")));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fd00::/96")));
+
+        // The Mesh-Local Prefix must start with 0xfd
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_meshLocalPrefixNotStartWith0xfd_throwsIllegalArguments() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> builder.setMeshLocalPrefix(new IpPrefix("fc00::/64")));
+    }
+
+    @Test
+    public void builder_setValidMeshLocalPrefix_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setMeshLocalPrefix(new IpPrefix("fd00::/64"))
+                        .build();
+
+        assertThat(dataset.getMeshLocalPrefix()).isEqualTo(new IpPrefix("fd00::/64"));
+    }
+
+    @Test
+    public void builder_setValid1P2SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(
+                                new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xff, (byte) 0xf8});
+    }
+
+    @Test
+    public void builder_setValid1P1SecurityPolicy_success() {
+        ActiveOperationalDataset dataset =
+                new Builder(ActiveOperationalDataset.createRandomDataset())
+                        .setSecurityPolicy(new SecurityPolicy(672, new byte[] {(byte) 0xff}))
+                        .build();
+
+        assertThat(dataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(dataset.getSecurityPolicy().getFlags()).isEqualTo(new byte[] {(byte) 0xff});
+    }
+
+    @Test
+    public void securityPolicy_invalidRotationTime_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0, new byte[] {(byte) 0xff, (byte) 0xf8}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new SecurityPolicy(0x1ffff, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void securityPolicy_emptyFlags_throwsIllegalArguments() {
+        assertThrows(IllegalArgumentException.class, () -> new SecurityPolicy(672, new byte[] {}));
+    }
+
+    @Test
+    public void securityPolicy_tooLongFlags_success() {
+        SecurityPolicy securityPolicy =
+                new SecurityPolicy(672, new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+
+        assertThat(securityPolicy.getFlags()).isEqualTo(new byte[] {0, 1, 2, 3, 4, 5, 6, 7});
+    }
+
+    @Test
+    public void securityPolicy_equals() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff}))
+                .addEqualityGroup(
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}),
+                        new SecurityPolicy(1, new byte[] {(byte) 0xff, (byte) 0xf8}))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..9be3d56
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/OperationalDatasetTimestampTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.cts;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.thread.OperationalDatasetTimestamp;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Instant;
+
+/** Tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromInstant_tooLargeInstant_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        OperationalDatasetTimestamp.fromInstant(
+                                Instant.ofEpochSecond(0xffffffffffffL + 1L)));
+    }
+
+    @Test
+    public void fromInstant_ticksIsRounded() {
+        Instant instant = Instant.ofEpochSecond(100L);
+
+        // 32767.5 / 32768 * 1000000000 = 999984741.2109375 and given the `ticks` is rounded, so
+        // the `ticks` should be 32767 for 999984741 and 0 (carried over to seconds) for 999984742.
+        OperationalDatasetTimestamp timestampTicks32767 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984741));
+        OperationalDatasetTimestamp timestampTicks0 =
+                OperationalDatasetTimestamp.fromInstant(instant.plusNanos(999984742));
+
+        assertThat(timestampTicks32767.getSeconds()).isEqualTo(100L);
+        assertThat(timestampTicks0.getSeconds()).isEqualTo(101L);
+        assertThat(timestampTicks32767.getTicks()).isEqualTo(32767);
+        assertThat(timestampTicks0.getTicks()).isEqualTo(0);
+        assertThat(timestampTicks32767.isAuthoritativeSource()).isTrue();
+        assertThat(timestampTicks0.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toInstant_nanosIsRounded() {
+        // 32767 / 32768 * 1000000000 = 999969482.421875
+        assertThat(new OperationalDatasetTimestamp(100L, 32767, false).toInstant().getNano())
+                .isEqualTo(999969482);
+
+        // 32766 / 32768 * 1000000000 = 999938964.84375
+        assertThat(new OperationalDatasetTimestamp(100L, 32766, false).toInstant().getNano())
+                .isEqualTo(999938965);
+    }
+
+    @Test
+    public void toInstant_onlyAuthoritativeSourceDiscarded() {
+        OperationalDatasetTimestamp timestamp1 =
+                new OperationalDatasetTimestamp(100L, 0x7fff, false);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromInstant(timestamp1.toInstant());
+
+        assertThat(timestamp2.getSeconds()).isEqualTo(100L);
+        assertThat(timestamp2.getTicks()).isEqualTo(0x7fff);
+        assertThat(timestamp2.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void constructor_tooLargeSeconds_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x0001112233445566L,
+                                /* ticks= */ 0,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void constructor_tooLargeTicks_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new OperationalDatasetTimestamp(
+                                /* seconds= */ 0x01L,
+                                /* ticks= */ 0x8000,
+                                /* isAuthoritativeSource= */ true));
+    }
+
+    @Test
+    public void equalityTests() {
+        new EqualsTester()
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(100, 100, false),
+                        new OperationalDatasetTimestamp(100, 100, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0, 0, false),
+                        new OperationalDatasetTimestamp(0, 0, false))
+                .addEqualityGroup(
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true),
+                        new OperationalDatasetTimestamp(0xffffffffffffL, 0x7fff, true))
+                .testEquals();
+    }
+}
diff --git a/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
new file mode 100644
index 0000000..7a49957
--- /dev/null
+++ b/thread/tests/cts/src/android/net/thread/cts/PendingOperationalDatasetTest.java
@@ -0,0 +1,247 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread.cts;
+
+import static com.android.testutils.ParcelUtils.assertParcelingIsLossless;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.OperationalDatasetTimestamp;
+import android.net.thread.PendingOperationalDataset;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+import com.google.common.testing.EqualsTester;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Duration;
+
+/** Tests for {@link PendingOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class PendingOperationalDatasetTest {
+    private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET =
+            ActiveOperationalDataset.createRandomDataset();
+
+    @Test
+    public void parcelable_parcelingIsLossLess() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertParcelingIsLossless(dataset);
+    }
+
+    @Test
+    public void equalityTests() {
+        ActiveOperationalDataset activeDataset1 = ActiveOperationalDataset.createRandomDataset();
+        ActiveOperationalDataset activeDataset2 = ActiveOperationalDataset.createRandomDataset();
+
+        new EqualsTester()
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset1,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(31536000, 100, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(0)))
+                .addEqualityGroup(
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)),
+                        new PendingOperationalDataset(
+                                activeDataset2,
+                                new OperationalDatasetTimestamp(15768000, 0, false),
+                                Duration.ofMillis(100)))
+                .testEquals();
+    }
+
+    @Test
+    public void constructor_correctValuesAreSet() {
+        PendingOperationalDataset dataset =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(31536000, 200, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofHours(100));
+    }
+
+    @Test
+    public void fromThreadTlvs_openthreadTlvs_success() {
+        // An example Pending Operational Dataset which is generated with OpenThread CLI:
+        // Pending Timestamp: 2
+        // Active Timestamp: 1
+        // Channel: 26
+        // Channel Mask: 0x07fff800
+        // Delay: 46354
+        // Ext PAN ID: a74182f4d3f4de41
+        // Mesh Local Prefix: fd46:c1b9:e159:5574::/64
+        // Network Key: ed916e454d96fd00184f10a6f5c9e1d3
+        // Network Name: OpenThread-bff8
+        // PAN ID: 0xbff8
+        // PSKc: 264f78414adc683191863d968f72d1b7
+        // Security Policy: 672 onrc
+        final byte[] OPENTHREAD_PENDING_DATASET_TLVS =
+                base16().lowerCase()
+                        .decode(
+                                "0e0800000000000100003308000000000002000034040000b51200030000"
+                                        + "1a35060004001fffe00208a74182f4d3f4de410708fd46c1b9"
+                                        + "e15955740510ed916e454d96fd00184f10a6f5c9e1d3030f4f"
+                                        + "70656e5468726561642d626666380102bff80410264f78414a"
+                                        + "dc683191863d968f72d1b70c0402a0f7f8");
+
+        PendingOperationalDataset pendingDataset =
+                PendingOperationalDataset.fromThreadTlvs(OPENTHREAD_PENDING_DATASET_TLVS);
+
+        ActiveOperationalDataset activeDataset = pendingDataset.getActiveOperationalDataset();
+        assertThat(pendingDataset.getPendingTimestamp().getSeconds()).isEqualTo(2L);
+        assertThat(activeDataset.getActiveTimestamp().getSeconds()).isEqualTo(1L);
+        assertThat(activeDataset.getChannel()).isEqualTo(26);
+        assertThat(activeDataset.getChannelMask().get(0))
+                .isEqualTo(new byte[] {0x00, 0x1f, (byte) 0xff, (byte) 0xe0});
+        assertThat(pendingDataset.getDelayTimer().toMillis()).isEqualTo(46354);
+        assertThat(activeDataset.getExtendedPanId())
+                .isEqualTo(base16().lowerCase().decode("a74182f4d3f4de41"));
+        assertThat(activeDataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd46:c1b9:e159:5574::/64"));
+        assertThat(activeDataset.getNetworkKey())
+                .isEqualTo(base16().lowerCase().decode("ed916e454d96fd00184f10a6f5c9e1d3"));
+        assertThat(activeDataset.getNetworkName()).isEqualTo("OpenThread-bff8");
+        assertThat(activeDataset.getPanId()).isEqualTo(0xbff8);
+        assertThat(activeDataset.getPskc())
+                .isEqualTo(base16().lowerCase().decode("264f78414adc683191863d968f72d1b7"));
+        assertThat(activeDataset.getSecurityPolicy().getRotationTimeHours()).isEqualTo(672);
+        assertThat(activeDataset.getSecurityPolicy().getFlags())
+                .isEqualTo(new byte[] {(byte) 0xf7, (byte) 0xf8});
+    }
+
+    @Test
+    public void fromThreadTlvs_completePendingDatasetTlvs_success() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        // 0x34 0x04 0x0000012C (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs =
+                base16().decode("3308000000000001000034040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        PendingOperationalDataset dataset =
+                PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs);
+
+        assertThat(dataset.getActiveOperationalDataset()).isEqualTo(DEFAULT_ACTIVE_DATASET);
+        assertThat(dataset.getPendingTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getDelayTimer()).isEqualTo(Duration.ofMillis(300));
+    }
+
+    @Test
+    public void fromThreadTlvs_PendingTimestampTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x34 0x04 0x00000064 (Delay Timer TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("34040000012C");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_delayTimerTlvIsMissing_throwsIllegalArgument() {
+        // Type Length Value
+        // 0x33 0x08 0x0000000000010000 (Pending Timestamp TLV)
+        final byte[] pendingTimestampAndDelayTimerTlvs = base16().decode("33080000000000010000");
+        final byte[] pendingDatasetTlvs =
+                Bytes.concat(
+                        pendingTimestampAndDelayTimerTlvs, DEFAULT_ACTIVE_DATASET.toThreadTlvs());
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(pendingDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_activeDatasetTlvs_throwsIllegalArgument() {
+        final byte[] activeDatasetTlvs = DEFAULT_ACTIVE_DATASET.toThreadTlvs();
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(activeDatasetTlvs));
+    }
+
+    @Test
+    public void fromThreadTlvs_malformedTlvs_throwsIllegalArgument() {
+        final byte[] invalidTlvs = new byte[] {0x00};
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> PendingOperationalDataset.fromThreadTlvs(invalidTlvs));
+    }
+
+    @Test
+    public void toThreadTlvs_conversionIsLossLess() {
+        PendingOperationalDataset dataset1 =
+                new PendingOperationalDataset(
+                        DEFAULT_ACTIVE_DATASET,
+                        new OperationalDatasetTimestamp(31536000, 200, false),
+                        Duration.ofHours(100));
+
+        PendingOperationalDataset dataset2 =
+                PendingOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+}
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
new file mode 100644
index 0000000..3a087c7
--- /dev/null
+++ b/thread/tests/unit/Android.bp
@@ -0,0 +1,50 @@
+//
+// Copyright (C) 2023 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_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_test {
+    name: "ThreadNetworkUnitTests",
+    min_sdk_version: "33",
+    sdk_version: "module_current",
+    manifest: "AndroidManifest.xml",
+    test_config: "AndroidTest.xml",
+    srcs: [
+        "src/**/*.java",
+    ],
+    test_suites: [
+        "general-tests",
+    ],
+    static_libs: [
+        "androidx.test.ext.junit",
+        "compatibility-device-util-axt",
+        "ctstestrunner-axt",
+        "framework-connectivity-pre-jarjar",
+        "framework-connectivity-t-pre-jarjar",
+        "guava-android-testlib",
+        "net-tests-utils",
+        "truth",
+    ],
+    libs: [
+        "android.test.base",
+        "android.test.runner",
+    ],
+    // Test coverage system runs on different devices. Need to
+    // compile for all architectures.
+    compile_multilib: "both",
+}
diff --git a/thread/tests/unit/AndroidManifest.xml b/thread/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..ace7c52
--- /dev/null
+++ b/thread/tests/unit/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 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.
+ -->
+
+<manifest
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    package="android.net.thread.unittests">
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+        android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.net.thread.unittests"
+        android:label="Unit tests for android.net.thread" />
+</manifest>
diff --git a/thread/tests/unit/AndroidTest.xml b/thread/tests/unit/AndroidTest.xml
new file mode 100644
index 0000000..663ff74
--- /dev/null
+++ b/thread/tests/unit/AndroidTest.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright (C) 2023 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+ -->
+
+<configuration description="Config for Thread network unit test cases">
+    <option name="test-tag" value="ThreadNetworkUnitTests" />
+    <option name="test-suite-tag" value="apct" />
+
+    <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+        <option name="test-file-name" value="ThreadNetworkUnitTests.apk" />
+        <option name="check-min-sdk" value="true" />
+        <option name="cleanup-apks" value="true" />
+    </target_preparer>
+
+    <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
+        <option name="package" value="android.net.thread.unittests" />
+        <!-- Ignores tests introduced by guava-android-testlib -->
+        <option name="exclude-annotation" value="org.junit.Ignore"/>
+    </test>
+</configuration>
diff --git a/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
new file mode 100644
index 0000000..78eb3d0
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.net.IpPrefix;
+import android.net.thread.ActiveOperationalDataset.Builder;
+import android.net.thread.ActiveOperationalDataset.SecurityPolicy;
+import android.util.SparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.security.SecureRandom;
+import java.util.Random;
+
+/** Unit tests for {@link ActiveOperationalDataset}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class ActiveOperationalDatasetTest {
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] VALID_DATASET =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+
+    @Mock private Random mockRandom;
+    @Mock private SecureRandom mockSecureRandom;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    private static byte[] addTlv(byte[] dataset, String tlvHex) {
+        return Bytes.concat(dataset, base16().decode(tlvHex));
+    }
+
+    @Test
+    public void fromThreadTlvs_containsUnknownTlvs_unknownTlvsRetained() {
+        byte[] datasetWithUnknownTlvs = addTlv(VALID_DATASET, "AA01FFBB020102");
+
+        ActiveOperationalDataset dataset1 =
+                ActiveOperationalDataset.fromThreadTlvs(datasetWithUnknownTlvs);
+        ActiveOperationalDataset dataset2 =
+                ActiveOperationalDataset.fromThreadTlvs(dataset1.toThreadTlvs());
+
+        SparseArray<byte[]> unknownTlvs = dataset2.getUnknownTlvs();
+        assertThat(unknownTlvs.size()).isEqualTo(2);
+        assertThat(unknownTlvs.get(0xAA)).isEqualTo(new byte[] {(byte) 0xFF});
+        assertThat(unknownTlvs.get(0xBB)).isEqualTo(new byte[] {0x01, 0x02});
+        assertThat(dataset2).isEqualTo(dataset1);
+    }
+
+    @Test
+    public void createRandomDataset_fieldsAreRandomized() {
+        // Always return the max bounded value
+        doAnswer(invocation -> (int) invocation.getArgument(0) - 1)
+                .when(mockRandom)
+                .nextInt(anyInt());
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 10);
+                            }
+                            return null;
+                        })
+                .when(mockRandom)
+                .nextBytes(any(byte[].class));
+        doAnswer(
+                        invocation -> {
+                            byte[] output = invocation.getArgument(0);
+                            for (int i = 0; i < output.length; ++i) {
+                                output[i] = (byte) (i + 30);
+                            }
+                            return null;
+                        })
+                .when(mockSecureRandom)
+                .nextBytes(any(byte[].class));
+
+        ActiveOperationalDataset dataset =
+                ActiveOperationalDataset.createRandomDataset(mockRandom, mockSecureRandom);
+
+        assertThat(dataset.getActiveTimestamp())
+                .isEqualTo(new OperationalDatasetTimestamp(1, 0, false));
+        assertThat(dataset.getExtendedPanId())
+                .isEqualTo(new byte[] {10, 11, 12, 13, 14, 15, 16, 17});
+        assertThat(dataset.getMeshLocalPrefix())
+                .isEqualTo(new IpPrefix("fd0b:0c0d:0e0f:1011::/64"));
+        verify(mockRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getPanId()).isEqualTo(0xfffe); // PAN ID <= 0xfffe
+        verify(mockRandom, times(1)).nextInt(eq(0xffff));
+        assertThat(dataset.getChannel()).isEqualTo(26);
+        verify(mockRandom, times(1)).nextInt(eq(16));
+        assertThat(dataset.getChannelPage()).isEqualTo(0);
+        assertThat(dataset.getChannelMask().size()).isEqualTo(1);
+        assertThat(dataset.getPskc())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        assertThat(dataset.getNetworkKey())
+                .isEqualTo(
+                        new byte[] {
+                            30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45
+                        });
+        verify(mockSecureRandom, times(2)).nextBytes(any(byte[].class));
+        assertThat(dataset.getSecurityPolicy())
+                .isEqualTo(new SecurityPolicy(672, new byte[] {(byte) 0xff, (byte) 0xf8}));
+    }
+
+    @Test
+    public void builder_buildWithTooLongTlvs_throwsIllegalState() {
+        Builder builder = new Builder(ActiveOperationalDataset.createRandomDataset());
+        for (int i = 0; i < 10; i++) {
+            builder.addUnknownTlv(i, new byte[20]);
+        }
+
+        assertThrows(IllegalStateException.class, () -> new Builder().build());
+    }
+
+    @Test
+    public void builder_setUnknownTlvs_success() {
+        ActiveOperationalDataset dataset1 = ActiveOperationalDataset.fromThreadTlvs(VALID_DATASET);
+        SparseArray<byte[]> unknownTlvs = new SparseArray<>(2);
+        unknownTlvs.put(0x33, new byte[] {1, 2, 3});
+        unknownTlvs.put(0x44, new byte[] {1, 2, 3, 4});
+
+        ActiveOperationalDataset dataset2 =
+                new ActiveOperationalDataset.Builder(dataset1).setUnknownTlvs(unknownTlvs).build();
+
+        assertThat(dataset1.getUnknownTlvs().size()).isEqualTo(0);
+        assertThat(dataset2.getUnknownTlvs().size()).isEqualTo(2);
+        assertThat(dataset2.getUnknownTlvs().get(0x33)).isEqualTo(new byte[] {1, 2, 3});
+        assertThat(dataset2.getUnknownTlvs().get(0x44)).isEqualTo(new byte[] {1, 2, 3, 4});
+    }
+
+    @Test
+    public void securityPolicy_fromTooShortTlvValue_throwsIllegalArgument() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01}));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> SecurityPolicy.fromTlvValue(new byte[] {0x01, 0x02}));
+    }
+
+    @Test
+    public void securityPolicy_toTlvValue_conversionIsLossLess() {
+        SecurityPolicy policy1 = new SecurityPolicy(200, new byte[] {(byte) 0xFF, (byte) 0xF8});
+
+        SecurityPolicy policy2 = SecurityPolicy.fromTlvValue(policy1.toTlvValue());
+
+        assertThat(policy2).isEqualTo(policy1);
+    }
+}
diff --git a/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
new file mode 100644
index 0000000..32063fc
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/OperationalDatasetTimestampTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link OperationalDatasetTimestamp}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class OperationalDatasetTimestampTest {
+    @Test
+    public void fromTlvValue_invalidTimestamp_throwsIllegalArguments() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> OperationalDatasetTimestamp.fromTlvValue(new byte[7]));
+    }
+
+    @Test
+    public void fromTlvValue_goodValue_success() {
+        OperationalDatasetTimestamp timestamp =
+                OperationalDatasetTimestamp.fromTlvValue(base16().decode("FFEEDDCCBBAA9989"));
+
+        assertThat(timestamp.getSeconds()).isEqualTo(0xFFEEDDCCBBAAL);
+        // 0x9989 is 0x4CC4 << 1 + 1
+        assertThat(timestamp.getTicks()).isEqualTo(0x4CC4);
+        assertThat(timestamp.isAuthoritativeSource()).isTrue();
+    }
+
+    @Test
+    public void toTlvValue_conversionIsLossLess() {
+        OperationalDatasetTimestamp timestamp1 = new OperationalDatasetTimestamp(100L, 10, true);
+
+        OperationalDatasetTimestamp timestamp2 =
+                OperationalDatasetTimestamp.fromTlvValue(timestamp1.toTlvValue());
+
+        assertThat(timestamp2).isEqualTo(timestamp1);
+    }
+}