Merge "Update the usage data when upstream is changed" into main
diff --git a/Cronet/OWNERS b/Cronet/OWNERS
deleted file mode 100644
index c24680e..0000000
--- a/Cronet/OWNERS
+++ /dev/null
@@ -1,2 +0,0 @@
-set noparent
-file:platform/packages/modules/Connectivity:main:/OWNERS_core_networking
diff --git a/OWNERS_core_networking b/OWNERS_core_networking
index 6d8ed4a..078ccde 100644
--- a/OWNERS_core_networking
+++ b/OWNERS_core_networking
@@ -1,12 +1,13 @@
 jchalard@google.com
 junyulai@google.com
 lorenzo@google.com
-martinwu@google.com
 maze@google.com
 motomuman@google.com
 paulhu@google.com
 prohr@google.com
 reminv@google.com
-satk@google.com
 xiaom@google.com
 yuyanghuang@google.com
+
+martinwu@google.com #{LAST_RESORT_SUGGESTION}
+satk@google.com #{LAST_RESORT_SUGGESTION}
\ No newline at end of file
diff --git a/Tethering/Android.bp b/Tethering/Android.bp
index 70b38a4..15ad226 100644
--- a/Tethering/Android.bp
+++ b/Tethering/Android.bp
@@ -79,6 +79,7 @@
     ],
     defaults: ["TetheringExternalLibs"],
     libs: [
+        "framework-annotations-lib",
         "framework-tethering.impl",
     ],
     manifest: "AndroidManifestBase.xml",
diff --git a/Tethering/apex/Android.bp b/Tethering/apex/Android.bp
index 3b197fc..0c05354 100644
--- a/Tethering/apex/Android.bp
+++ b/Tethering/apex/Android.bp
@@ -98,7 +98,6 @@
     ],
     canned_fs_config: "canned_fs_config",
     bpfs: [
-        "block.o",
         "clatd.o",
         "dscpPolicy.o",
         "netd.o",
diff --git a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java b/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
index 528991f..1d5df61 100644
--- a/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
+++ b/Tethering/src/com/android/networkstack/tethering/PrivateAddressCoordinator.java
@@ -28,11 +28,11 @@
 
 import static java.util.Arrays.asList;
 
-import android.content.Context;
-import android.net.ConnectivityManager;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
+import android.net.LinkProperties;
 import android.net.Network;
+import android.net.NetworkCapabilities;
 import android.net.ip.IpServer;
 import android.util.ArrayMap;
 import android.util.ArraySet;
@@ -51,6 +51,7 @@
 import java.util.List;
 import java.util.Random;
 import java.util.Set;
+import java.util.function.Supplier;
 
 /**
  * This class coordinate IP addresses conflict problem.
@@ -63,6 +64,7 @@
  * @hide
  */
 public class PrivateAddressCoordinator {
+    // WARNING: Keep in sync with chooseDownstreamAddress
     public static final int PREFIX_LENGTH = 24;
 
     // Upstream monitor would be stopped when tethering is down. When tethering restart, downstream
@@ -75,18 +77,22 @@
     private static final String LEGACY_WIFI_P2P_IFACE_ADDRESS = "192.168.49.1/24";
     private static final String LEGACY_BLUETOOTH_IFACE_ADDRESS = "192.168.44.1/24";
     private final List<IpPrefix> mTetheringPrefixes;
-    private final ConnectivityManager mConnectivityMgr;
-    private final TetheringConfiguration mConfig;
+    // A supplier that returns ConnectivityManager#getAllNetworks.
+    private final Supplier<Network[]> mGetAllNetworksSupplier;
+    private final boolean mIsRandomPrefixBaseEnabled;
+    private final boolean mShouldEnableWifiP2pDedicatedIp;
     // keyed by downstream type(TetheringManager.TETHERING_*).
     private final ArrayMap<AddressKey, LinkAddress> mCachedAddresses;
     private final Random mRandom;
 
-    public PrivateAddressCoordinator(Context context, TetheringConfiguration config) {
+    public PrivateAddressCoordinator(Supplier<Network[]> getAllNetworksSupplier,
+            boolean isRandomPrefixBase,
+            boolean shouldEnableWifiP2pDedicatedIp) {
         mDownstreams = new ArraySet<>();
         mUpstreamPrefixMap = new ArrayMap<>();
-        mConnectivityMgr = (ConnectivityManager) context.getSystemService(
-                Context.CONNECTIVITY_SERVICE);
-        mConfig = config;
+        mGetAllNetworksSupplier = getAllNetworksSupplier;
+        mIsRandomPrefixBaseEnabled = isRandomPrefixBase;
+        mShouldEnableWifiP2pDedicatedIp = shouldEnableWifiP2pDedicatedIp;
         mCachedAddresses = new ArrayMap<AddressKey, LinkAddress>();
         // Reserved static addresses for bluetooth and wifi p2p.
         mCachedAddresses.put(new AddressKey(TETHERING_BLUETOOTH, CONNECTIVITY_SCOPE_GLOBAL),
@@ -100,26 +106,26 @@
     }
 
     /**
-     * Record a new upstream IpPrefix which may conflict with tethering downstreams.
-     * The downstreams will be notified if a conflict is found. When updateUpstreamPrefix is called,
+     * Record a new upstream IpPrefix which may conflict with tethering downstreams. The downstreams
+     * will be notified if a conflict is found. When updateUpstreamPrefix is called,
      * UpstreamNetworkState must have an already populated LinkProperties.
      */
-    public void updateUpstreamPrefix(final UpstreamNetworkState ns) {
+    public void updateUpstreamPrefix(
+            final LinkProperties lp, final NetworkCapabilities nc, final Network network) {
         // Do not support VPN as upstream. Normally, networkCapabilities is not expected to be null,
         // but just checking to be sure.
-        if (ns.networkCapabilities != null && ns.networkCapabilities.hasTransport(TRANSPORT_VPN)) {
-            removeUpstreamPrefix(ns.network);
+        if (nc != null && nc.hasTransport(TRANSPORT_VPN)) {
+            removeUpstreamPrefix(network);
             return;
         }
 
-        final ArrayList<IpPrefix> ipv4Prefixes = getIpv4Prefixes(
-                ns.linkProperties.getAllLinkAddresses());
+        final ArrayList<IpPrefix> ipv4Prefixes = getIpv4Prefixes(lp.getAllLinkAddresses());
         if (ipv4Prefixes.isEmpty()) {
-            removeUpstreamPrefix(ns.network);
+            removeUpstreamPrefix(network);
             return;
         }
 
-        mUpstreamPrefixMap.put(ns.network, ipv4Prefixes);
+        mUpstreamPrefixMap.put(network, ipv4Prefixes);
         handleMaybePrefixConflict(ipv4Prefixes);
     }
 
@@ -161,7 +167,7 @@
 
         // Remove all upstreams that are no longer valid networks
         final Set<Network> toBeRemoved = new HashSet<>(mUpstreamPrefixMap.keySet());
-        toBeRemoved.removeAll(asList(mConnectivityMgr.getAllNetworks()));
+        toBeRemoved.removeAll(asList(mGetAllNetworksSupplier.get()));
 
         mUpstreamPrefixMap.removeAll(toBeRemoved);
     }
@@ -173,7 +179,7 @@
     @Nullable
     public LinkAddress requestDownstreamAddress(final IpServer ipServer, final int scope,
             boolean useLastAddress) {
-        if (mConfig.shouldEnableWifiP2pDedicatedIp()
+        if (mShouldEnableWifiP2pDedicatedIp
                 && ipServer.interfaceType() == TETHERING_WIFI_P2P) {
             return new LinkAddress(LEGACY_WIFI_P2P_IFACE_ADDRESS);
         }
@@ -189,7 +195,7 @@
             return cachedAddress;
         }
 
-        final int prefixIndex = getStartedPrefixIndex();
+        final int prefixIndex = getRandomPrefixIndex();
         for (int i = 0; i < mTetheringPrefixes.size(); i++) {
             final IpPrefix prefixRange = mTetheringPrefixes.get(
                     (prefixIndex + i) % mTetheringPrefixes.size());
@@ -205,8 +211,8 @@
         return null;
     }
 
-    private int getStartedPrefixIndex() {
-        if (!mConfig.isRandomPrefixBaseEnabled()) return 0;
+    private int getRandomPrefixIndex() {
+        if (!mIsRandomPrefixBaseEnabled) return 0;
 
         final int random = getRandomInt() & 0xffffff;
         // This is to select the starting prefix range (/8, /12, or /16) instead of the actual
@@ -242,123 +248,62 @@
         return getInUseDownstreamPrefix(prefix);
     }
 
-    // Get the next non-conflict sub prefix. E.g: To get next sub prefix from 10.0.0.0/8, if the
-    // previously selected prefix is 10.20.42.0/24(subPrefix: 0.20.42.0) and the conflicting prefix
-    // is 10.16.0.0/20 (10.16.0.0 ~ 10.16.15.255), then the max address under subPrefix is
-    // 0.16.15.255 and the next subPrefix is 0.16.16.255/24 (0.16.15.255 + 0.0.1.0).
-    // Note: the sub address 0.0.0.255 here is fine to be any value that it will be replaced as
-    // selected random sub address later.
-    private int getNextSubPrefix(final IpPrefix conflictPrefix, final int prefixRangeMask) {
-        final int suffixMask = ~prefixLengthToV4NetmaskIntHTH(conflictPrefix.getPrefixLength());
-        // The largest offset within the prefix assignment block that still conflicts with
-        // conflictPrefix.
-        final int maxConflict =
-                (getPrefixBaseAddress(conflictPrefix) | suffixMask) & ~prefixRangeMask;
-
-        final int prefixMask = prefixLengthToV4NetmaskIntHTH(PREFIX_LENGTH);
-        // Pick a sub prefix a full prefix (1 << (32 - PREFIX_LENGTH) addresses) greater than
-        // maxConflict. This ensures that the selected prefix never overlaps with conflictPrefix.
-        // There is no need to mask the result with PREFIX_LENGTH bits because this is done by
-        // findAvailablePrefixFromRange when it constructs the prefix.
-        return maxConflict + (1 << (32 - PREFIX_LENGTH));
-    }
-
-    private LinkAddress chooseDownstreamAddress(final IpPrefix prefixRange) {
+    @VisibleForTesting
+    public LinkAddress chooseDownstreamAddress(final IpPrefix prefixRange) {
         // The netmask of the prefix assignment block (e.g., 0xfff00000 for 172.16.0.0/12).
         final int prefixRangeMask = prefixLengthToV4NetmaskIntHTH(prefixRange.getPrefixLength());
 
         // The zero address in the block (e.g., 0xac100000 for 172.16.0.0/12).
         final int baseAddress = getPrefixBaseAddress(prefixRange);
 
-        // The subnet mask corresponding to PREFIX_LENGTH.
-        final int prefixMask = prefixLengthToV4NetmaskIntHTH(PREFIX_LENGTH);
+        // Try to get an address within the given prefix that does not conflict with any other
+        // prefix in the system.
+        for (int i = 0; i < 20; ++i) {
+            final int randomSuffix = mRandom.nextInt() & ~prefixRangeMask;
+            final int randomAddress = baseAddress | randomSuffix;
 
-        // The offset within prefixRange of a randomly-selected prefix of length PREFIX_LENGTH.
-        // This may not be the prefix of the address returned by this method:
-        // - If it is already in use, the method will return an address in another prefix.
-        // - If all prefixes within prefixRange are in use, the method will return null. For
-        // example, for a /24 prefix within 172.26.0.0/12, this will be a multiple of 256 in
-        // [0, 1048576). In other words, a random 32-bit number with mask 0x000fff00.
-        //
-        // prefixRangeMask is required to ensure no wrapping. For example, consider:
-        // - prefixRange 127.0.0.0/8
-        // - randomPrefixStart 127.255.255.0
-        // - A conflicting prefix of 127.255.254.0/23
-        // In this case without prefixRangeMask, getNextSubPrefix would return 128.0.0.0, which
-        // means the "start < end" check in findAvailablePrefixFromRange would not reject the prefix
-        // because Java doesn't have unsigned integers, so 128.0.0.0 = 0x80000000 = -2147483648
-        // is less than 127.0.0.0 = 0x7f000000 = 2130706432.
-        //
-        // Additionally, it makes debug output easier to read by making the numbers smaller.
-        final int randomInt = getRandomInt();
-        final int randomPrefixStart = randomInt & ~prefixRangeMask & prefixMask;
+            // Avoid selecting x.x.x.[0, 1, 255] addresses.
+            switch (randomAddress & 0xFF) {
+                case 0:
+                case 1:
+                case 255:
+                    // Try selecting a different address
+                    continue;
+            }
 
-        // A random offset within the prefix. Used to determine the local address once the prefix
-        // is selected. It does not result in an IPv4 address ending in .0, .1, or .255
-        // For a PREFIX_LENGTH of 24, this is a number between 2 and 254.
-        final int subAddress = getSanitizedSubAddr(randomInt, ~prefixMask);
+            // Avoid selecting commonly used subnets.
+            switch (randomAddress & 0xFFFFFF00) {
+                case 0xC0A80000: // 192.168.0.0/24
+                case 0xC0A80100: // 192.168.1.0/24
+                case 0xC0A85800: // 192.168.88.0/24
+                case 0xC0A86400: // 192.168.100.0/24
+                    continue;
+            }
 
-        // Find a prefix length PREFIX_LENGTH between randomPrefixStart and the end of the block,
-        // such that the prefix does not conflict with any upstream.
-        IpPrefix downstreamPrefix = findAvailablePrefixFromRange(
-                 randomPrefixStart, (~prefixRangeMask) + 1, baseAddress, prefixRangeMask);
-        if (downstreamPrefix != null) return getLinkAddress(downstreamPrefix, subAddress);
+            // Avoid 10.0.0.0 - 10.10.255.255
+            if (randomAddress >= 0x0A000000 && randomAddress <= 0x0A0AFFFF) {
+                continue;
+            }
 
-        // If that failed, do the same, but between 0 and randomPrefixStart.
-        downstreamPrefix = findAvailablePrefixFromRange(
-                0, randomPrefixStart, baseAddress, prefixRangeMask);
-
-        return getLinkAddress(downstreamPrefix, subAddress);
-    }
-
-    private LinkAddress getLinkAddress(final IpPrefix prefix, final int subAddress) {
-        if (prefix == null) return null;
-
-        final InetAddress address = intToInet4AddressHTH(getPrefixBaseAddress(prefix) | subAddress);
-        return new LinkAddress(address, PREFIX_LENGTH);
-    }
-
-    private IpPrefix findAvailablePrefixFromRange(final int start, final int end,
-            final int baseAddress, final int prefixRangeMask) {
-        int newSubPrefix = start;
-        while (newSubPrefix < end) {
-            final InetAddress address = intToInet4AddressHTH(baseAddress | newSubPrefix);
+            final InetAddress address = intToInet4AddressHTH(randomAddress);
             final IpPrefix prefix = new IpPrefix(address, PREFIX_LENGTH);
-
-            final IpPrefix conflictPrefix = getConflictPrefix(prefix);
-
-            if (conflictPrefix == null) return prefix;
-
-            newSubPrefix = getNextSubPrefix(conflictPrefix, prefixRangeMask);
+            if (getConflictPrefix(prefix) != null) {
+                // Prefix is conflicting with another prefix used in the system, find another one.
+                continue;
+            }
+            return new LinkAddress(address, PREFIX_LENGTH);
         }
-
+        // Could not find a prefix, return null and let caller try another range.
         return null;
     }
 
     /** Get random int which could be used to generate random address. */
+    // TODO: get rid of this function and mock getRandomPrefixIndex in tests.
     @VisibleForTesting
     public int getRandomInt() {
         return mRandom.nextInt();
     }
 
-    /** Get random subAddress and avoid selecting x.x.x.0, x.x.x.1 and x.x.x.255 address. */
-    private int getSanitizedSubAddr(final int randomInt, final int subAddrMask) {
-        final int randomSubAddr = randomInt & subAddrMask;
-        // If prefix length > 30, the selecting speace would be less than 4 which may be hard to
-        // avoid 3 consecutive address.
-        if (PREFIX_LENGTH > 30) return randomSubAddr;
-
-        // TODO: maybe it is not necessary to avoid .0, .1 and .255 address because tethering
-        // address would not be conflicted. This code only works because PREFIX_LENGTH is not longer
-        // than 24
-        final int candidate = randomSubAddr & 0xff;
-        if (candidate == 0 || candidate == 1 || candidate == 255) {
-            return (randomSubAddr & 0xfffffffc) + 2;
-        }
-
-        return randomSubAddr;
-    }
-
     /** Release downstream record for IpServer. */
     public void releaseDownstream(final IpServer ipServer) {
         mDownstreams.remove(ipServer);
diff --git a/Tethering/src/com/android/networkstack/tethering/Tethering.java b/Tethering/src/com/android/networkstack/tethering/Tethering.java
index 1938a08..49bc86e 100644
--- a/Tethering/src/com/android/networkstack/tethering/Tethering.java
+++ b/Tethering/src/com/android/networkstack/tethering/Tethering.java
@@ -2004,7 +2004,8 @@
             final UpstreamNetworkState ns = (UpstreamNetworkState) o;
             switch (arg1) {
                 case UpstreamNetworkMonitor.EVENT_ON_LINKPROPERTIES:
-                    mPrivateAddressCoordinator.updateUpstreamPrefix(ns);
+                    mPrivateAddressCoordinator.updateUpstreamPrefix(
+                            ns.linkProperties, ns.networkCapabilities, ns.network);
                     break;
                 case UpstreamNetworkMonitor.EVENT_ON_LOST:
                     mPrivateAddressCoordinator.removeUpstreamPrefix(ns.network);
diff --git a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
index 5d9d349..81f057c 100644
--- a/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
+++ b/Tethering/src/com/android/networkstack/tethering/TetheringDependencies.java
@@ -21,6 +21,7 @@
 import android.bluetooth.BluetoothAdapter;
 import android.bluetooth.BluetoothPan;
 import android.content.Context;
+import android.net.ConnectivityManager;
 import android.net.INetd;
 import android.net.connectivity.ConnectivityInternalApiUtil;
 import android.net.ip.IpServer;
@@ -176,9 +177,13 @@
     /**
      * Make PrivateAddressCoordinator to be used by Tethering.
      */
-    public PrivateAddressCoordinator makePrivateAddressCoordinator(Context ctx,
-            TetheringConfiguration cfg) {
-        return new PrivateAddressCoordinator(ctx, cfg);
+    public PrivateAddressCoordinator makePrivateAddressCoordinator(
+            Context ctx, TetheringConfiguration cfg) {
+        final ConnectivityManager cm = ctx.getSystemService(ConnectivityManager.class);
+        return new PrivateAddressCoordinator(
+                cm::getAllNetworks,
+                cfg.isRandomPrefixBaseEnabled(),
+                cfg.shouldEnableWifiP2pDedicatedIp());
     }
 
     /**
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 2298a1a..a5c06f3 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/PrivateAddressCoordinatorTest.java
@@ -16,7 +16,6 @@
 package com.android.networkstack.tethering;
 
 import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VPN;
-import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_WIFI;
 import static android.net.TetheringManager.CONNECTIVITY_SCOPE_GLOBAL;
@@ -29,9 +28,11 @@
 import static com.android.networkstack.tethering.util.PrefixUtils.asIpPrefix;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.reset;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
@@ -103,11 +104,17 @@
         MockitoAnnotations.initMocks(this);
 
         when(mContext.getSystemService(Context.CONNECTIVITY_SERVICE)).thenReturn(mConnectivityMgr);
+        when(mContext.getSystemService(ConnectivityManager.class)).thenReturn(mConnectivityMgr);
         when(mConnectivityMgr.getAllNetworks()).thenReturn(mAllNetworks);
         when(mConfig.shouldEnableWifiP2pDedicatedIp()).thenReturn(false);
         when(mConfig.isRandomPrefixBaseEnabled()).thenReturn(false);
         setUpIpServers();
-        mPrivateAddressCoordinator = spy(new PrivateAddressCoordinator(mContext, mConfig));
+        mPrivateAddressCoordinator =
+                spy(
+                        new PrivateAddressCoordinator(
+                                mConnectivityMgr::getAllNetworks,
+                                mConfig.isRandomPrefixBaseEnabled(),
+                                mConfig.shouldEnableWifiP2pDedicatedIp()));
     }
 
     private LinkAddress requestDownstreamAddress(final IpServer ipServer, int scope,
@@ -118,6 +125,11 @@
         return address;
     }
 
+    private void updateUpstreamPrefix(UpstreamNetworkState ns) {
+        mPrivateAddressCoordinator.updateUpstreamPrefix(
+                ns.linkProperties, ns.networkCapabilities, ns.network);
+    }
+
     @Test
     public void testRequestDownstreamAddressWithoutUsingLastAddress() throws Exception {
         final IpPrefix bluetoothPrefix = asIpPrefix(mBluetoothAddress);
@@ -143,37 +155,6 @@
     }
 
     @Test
-    public void testSanitizedAddress() throws Exception {
-        int fakeSubAddr = 0x2b00; // 43.0.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        LinkAddress actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.43.2/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2d01; // 45.1.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.45.2/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2eff; // 46.255.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.46.254/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-
-        fakeSubAddr = 0x2f05; // 47.5.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeSubAddr);
-        actualAddress = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
-        assertEquals(new LinkAddress("192.168.47.5/24"), actualAddress);
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-    }
-
-    @Test
     public void testReservedPrefix() throws Exception {
         // - Test bluetooth prefix is reserved.
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(
@@ -208,22 +189,15 @@
 
     @Test
     public void testRequestLastDownstreamAddress() throws Exception {
-        final int fakeHotspotSubAddr = 0x2b05; // 43.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
         final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong wifi prefix: ", new LinkAddress("192.168.43.5/24"), hotspotAddress);
 
         final LinkAddress usbAddress = requestDownstreamAddress(mUsbIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong wifi prefix: ", new LinkAddress("192.168.45.5/24"), usbAddress);
 
         mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
         mPrivateAddressCoordinator.releaseDownstream(mUsbIpServer);
 
-        final int newFakeSubAddr = 0x3c05;
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
-
         final LinkAddress newHotspotAddress = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
         assertEquals(hotspotAddress, newHotspotAddress);
@@ -234,7 +208,7 @@
         final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
                 new LinkAddress("192.168.88.23/16"), null,
                 makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
+        updateUpstreamPrefix(wifiUpstream);
         verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
         verify(mUsbIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
     }
@@ -261,262 +235,27 @@
     }
 
     @Test
-    public void testNoConflictUpstreamPrefix() throws Exception {
-        final int fakeHotspotSubAddr = 0x2b05; // 43.5
-        final IpPrefix predefinedPrefix = new IpPrefix("192.168.43.0/24");
-        // Force always get subAddress "43.5" for conflict testing.
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(fakeHotspotSubAddr);
-        // - Enable hotspot with prefix 192.168.43.0/24
-        final LinkAddress hotspotAddr = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddr);
-        assertEquals("Wrong wifi prefix: ", predefinedPrefix, hotspotPrefix);
-        // - test mobile network with null NetworkCapabilities. Ideally this should not happen
-        // because NetworkCapabilities update should always happen before LinkProperties update
-        // and the UpstreamNetworkState update, just make sure no crash in this case.
-        final UpstreamNetworkState noCapUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), null, null);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(noCapUpstream);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - test mobile upstream with no address.
-        final UpstreamNetworkState noAddress = buildUpstreamNetworkState(mMobileNetwork,
-                null, null, makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(noCapUpstream);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v6 only mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v6OnlyMobile = buildUpstreamNetworkState(mMobileNetwork,
-                null, new LinkAddress("2001:db8::/64"),
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v6OnlyMobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mMobileNetwork);
-        // - Update v4 only mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v4OnlyMobile = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyMobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v4v6 mobile network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v4v6Mobile = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("10.0.0.8/24"), new LinkAddress("2001:db8::/64"),
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4v6Mobile);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v6 only wifi network, hotspot prefix should not be removed.
-        final UpstreamNetworkState v6OnlyWifi = buildUpstreamNetworkState(mWifiNetwork,
-                null, new LinkAddress("2001:db8::/64"), makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v6OnlyWifi);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mWifiNetwork);
-        // - Update vpn network, it conflict with hotspot prefix but VPN networks are ignored.
-        final UpstreamNetworkState v4OnlyVpn = buildUpstreamNetworkState(mVpnNetwork,
-                new LinkAddress("192.168.43.5/24"), null, makeNetworkCapabilities(TRANSPORT_VPN));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyVpn);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Update v4 only wifi network, it conflict with hotspot prefix.
-        final UpstreamNetworkState v4OnlyWifi = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.43.5/24"), null, makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyWifi);
-        verify(mHotspotIpServer).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        reset(mHotspotIpServer);
-        // - Restart hotspot again and its prefix is different previous.
-        mPrivateAddressCoordinator.releaseDownstream(mHotspotIpServer);
-        final LinkAddress hotspotAddr2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix hotspotPrefix2 = asIpPrefix(hotspotAddr2);
-        assertNotEquals(hotspotPrefix, hotspotPrefix2);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(v4OnlyWifi);
-        verify(mHotspotIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-        // - Usb tethering can be enabled and its prefix is different with conflict one.
-        final LinkAddress usbAddr = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix usbPrefix = asIpPrefix(usbAddr);
-        assertNotEquals(predefinedPrefix, usbPrefix);
-        assertNotEquals(hotspotPrefix2, usbPrefix);
-        // - Disable wifi upstream, then wifi's prefix can be selected again.
-        mPrivateAddressCoordinator.removeUpstreamPrefix(mWifiNetwork);
-        final LinkAddress ethAddr = requestDownstreamAddress(mEthernetIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        final IpPrefix ethPrefix = asIpPrefix(ethAddr);
-        assertEquals(predefinedPrefix, ethPrefix);
+    public void testChooseDownstreamAddress_noUpstreamConflicts() throws Exception {
+        LinkAddress address = new LinkAddress("192.168.42.42/24");
+        UpstreamNetworkState ns = buildUpstreamNetworkState(mMobileNetwork, address, null, null);
+        updateUpstreamPrefix(ns);
+        // try to look for a /24 in upstream that does not conflict with upstream -> impossible.
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(asIpPrefix(address)));
+
+        IpPrefix prefix = new IpPrefix("192.168.0.0/16");
+        LinkAddress chosenAddress = mPrivateAddressCoordinator.chooseDownstreamAddress(prefix);
+        assertNotNull(chosenAddress);
+        assertTrue(prefix.containsPrefix(asIpPrefix(chosenAddress)));
     }
 
     @Test
-    public void testChooseAvailablePrefix() throws Exception {
-        final int randomAddress = 0x8605; // 134.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomAddress);
-        final LinkAddress addr0 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        // Check whether return address is prefix 192.168.0.0/16 + subAddress 0.0.134.5.
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.134.5/24"), addr0);
-        final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.134.13/26"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
-
-        // Check whether return address is next prefix of 192.168.134.0/24.
-        final LinkAddress addr1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.135.5/24"), addr1);
-        final UpstreamNetworkState wifiUpstream2 = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.149.16/19"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream2);
-
-
-        // The conflict range is 128 ~ 159, so the address is 192.168.160.5/24.
-        final LinkAddress addr2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.160.5/24"), addr2);
-        final UpstreamNetworkState mobileUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("192.168.129.53/18"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        // Update another conflict upstream which is covered by the previous one (but not the first
-        // one) and verify whether this would affect the result.
-        final UpstreamNetworkState mobileUpstream2 = buildUpstreamNetworkState(mMobileNetwork2,
-                new LinkAddress("192.168.170.7/19"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream);
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream2);
-
-        // The conflict range are 128 ~ 159 and 159 ~ 191, so the address is 192.168.192.5/24.
-        final LinkAddress addr3 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.192.5/24"), addr3);
-        final UpstreamNetworkState mobileUpstream3 = buildUpstreamNetworkState(mMobileNetwork3,
-                new LinkAddress("192.168.188.133/17"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream3);
-
-        // Conflict range: 128 ~ 255. The next available address is 192.168.0.5 because
-        // 192.168.134/24 ~ 192.168.255.255/24 is not available.
-        final LinkAddress addr4 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.0.5/24"), addr4);
-        final UpstreamNetworkState mobileUpstream4 = buildUpstreamNetworkState(mMobileNetwork4,
-                new LinkAddress("192.168.3.59/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream4);
-
-        // Conflict ranges: 128 ~ 255 and 0 ~ 7, so the address is 192.168.8.5/24.
-        final LinkAddress addr5 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.8.5/24"), addr5);
-        final UpstreamNetworkState mobileUpstream5 = buildUpstreamNetworkState(mMobileNetwork5,
-                new LinkAddress("192.168.68.43/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream5);
-
-        // Update an upstream that does *not* conflict, check whether return the same address
-        // 192.168.5/24.
-        final LinkAddress addr6 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.8.5/24"), addr6);
-        final UpstreamNetworkState mobileUpstream6 = buildUpstreamNetworkState(mMobileNetwork6,
-                new LinkAddress("192.168.10.97/21"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream6);
-
-        // Conflict ranges: 0 ~ 15 and 128 ~ 255, so the address is 192.168.16.5/24.
-        final LinkAddress addr7 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.16.5/24"), addr7);
-        final UpstreamNetworkState mobileUpstream7 = buildUpstreamNetworkState(mMobileNetwork6,
-                new LinkAddress("192.168.0.0/17"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream7);
-
-        // Choose prefix from next range(172.16.0.0/12) when no available prefix in 192.168.0.0/16.
-        final LinkAddress addr8 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.134.5/24"), addr8);
-    }
-
-    @Test
-    public void testChoosePrefixFromDifferentRanges() throws Exception {
-        final int randomAddress = 0x1f2b2a; // 31.43.42
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomAddress);
-        final LinkAddress classC1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        // Check whether return address is prefix 192.168.0.0/16 + subAddress 0.0.43.42.
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.43.42/24"), classC1);
-        final UpstreamNetworkState wifiUpstream = buildUpstreamNetworkState(mWifiNetwork,
-                new LinkAddress("192.168.88.23/17"), null,
-                makeNetworkCapabilities(TRANSPORT_WIFI));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(wifiUpstream);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // Check whether return address is next address of prefix 192.168.128.0/17.
-        final LinkAddress classC2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("192.168.128.42/24"), classC2);
-        final UpstreamNetworkState mobileUpstream = buildUpstreamNetworkState(mMobileNetwork,
-                new LinkAddress("192.1.2.3/8"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // Check whether return address is under prefix 172.16.0.0/12.
-        final LinkAddress classB1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.31.43.42/24"), classB1);
-        final UpstreamNetworkState mobileUpstream2 = buildUpstreamNetworkState(mMobileNetwork2,
-                new LinkAddress("172.28.123.100/14"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream2);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-
-        // 172.28.0.0 ~ 172.31.255.255 is not available.
-        // Check whether return address is next address of prefix 172.16.0.0/14.
-        final LinkAddress classB2 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.0.42/24"), classB2);
-
-        // Check whether new downstream is next address of address 172.16.0.42/24.
-        final LinkAddress classB3 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.1.42/24"), classB3);
-        final UpstreamNetworkState mobileUpstream3 = buildUpstreamNetworkState(mMobileNetwork3,
-                new LinkAddress("172.16.0.1/24"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream3);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verify(mUsbIpServer, never()).sendMessage(IpServer.CMD_NOTIFY_PREFIX_CONFLICT);
-
-        // Check whether return address is next address of prefix 172.16.1.42/24.
-        final LinkAddress classB4 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.16.2.42/24"), classB4);
-        final UpstreamNetworkState mobileUpstream4 = buildUpstreamNetworkState(mMobileNetwork4,
-                new LinkAddress("172.16.0.1/13"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream4);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verifyNotifyConflictAndRelease(mUsbIpServer);
-
-        // Check whether return address is next address of prefix 172.16.0.1/13.
-        final LinkAddress classB5 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.24.0.42/24"), classB5);
-        // Check whether return address is next address of prefix 172.24.0.42/24.
-        final LinkAddress classB6 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("172.24.1.42/24"), classB6);
-        final UpstreamNetworkState mobileUpstream5 = buildUpstreamNetworkState(mMobileNetwork5,
-                new LinkAddress("172.24.0.1/12"), null,
-                makeNetworkCapabilities(TRANSPORT_CELLULAR));
-        mPrivateAddressCoordinator.updateUpstreamPrefix(mobileUpstream5);
-        verifyNotifyConflictAndRelease(mHotspotIpServer);
-        verifyNotifyConflictAndRelease(mUsbIpServer);
-
-        // Check whether return address is prefix 10.0.0.0/8 + subAddress 0.31.43.42.
-        final LinkAddress classA1 = requestDownstreamAddress(mHotspotIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("10.31.43.42/24"), classA1);
-        // Check whether new downstream is next address of address 10.31.43.42/24.
-        final LinkAddress classA2 = requestDownstreamAddress(mUsbIpServer,
-                CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong prefix: ", new LinkAddress("10.31.44.42/24"), classA2);
+    public void testChooseDownstreamAddress_excludesWellKnownPrefixes() throws Exception {
+        IpPrefix prefix = new IpPrefix("192.168.0.0/24");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
+        prefix = new IpPrefix("192.168.100.0/24");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
+        prefix = new IpPrefix("10.3.0.0/16");
+        assertNull(mPrivateAddressCoordinator.chooseDownstreamAddress(prefix));
     }
 
     private void verifyNotifyConflictAndRelease(final IpServer ipServer) throws Exception {
@@ -562,17 +301,18 @@
 
     @Test
     public void testEnableSapAndLohsConcurrently() throws Exception {
-        // 0x2b05 -> 43.5, 0x8605 -> 134.5
-        when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(0x2b05, 0x8605);
-
         final LinkAddress hotspotAddress = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, true /* useLastAddress */);
-        assertEquals("Wrong hotspot prefix: ", new LinkAddress("192.168.43.5/24"), hotspotAddress);
+        assertNotNull(hotspotAddress);
 
         final LinkAddress localHotspotAddress = requestDownstreamAddress(mLocalHotspotIpServer,
                 CONNECTIVITY_SCOPE_LOCAL, true /* useLastAddress */);
-        assertEquals("Wrong local hotspot prefix: ", new LinkAddress("192.168.134.5/24"),
-                localHotspotAddress);
+        assertNotNull(localHotspotAddress);
+
+        final IpPrefix hotspotPrefix = asIpPrefix(hotspotAddress);
+        final IpPrefix localHotspotPrefix = asIpPrefix(localHotspotAddress);
+        assertFalse(hotspotPrefix.containsPrefix(localHotspotPrefix));
+        assertFalse(localHotspotPrefix.containsPrefix(hotspotPrefix));
     }
 
     @Test
@@ -602,7 +342,12 @@
 
     private void startedPrefixBaseTest(final String expected, final int randomIntForPrefixBase)
             throws Exception {
-        mPrivateAddressCoordinator = spy(new PrivateAddressCoordinator(mContext, mConfig));
+        mPrivateAddressCoordinator =
+                spy(
+                        new PrivateAddressCoordinator(
+                                mConnectivityMgr::getAllNetworks,
+                                mConfig.isRandomPrefixBaseEnabled(),
+                                mConfig.shouldEnableWifiP2pDedicatedIp()));
         when(mPrivateAddressCoordinator.getRandomInt()).thenReturn(randomIntForPrefixBase);
         final LinkAddress address = requestDownstreamAddress(mHotspotIpServer,
                 CONNECTIVITY_SCOPE_GLOBAL, false /* useLastAddress */);
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 6ba5d48..9a4945e 100644
--- a/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
+++ b/Tethering/tests/unit/src/com/android/networkstack/tethering/TetheringTest.java
@@ -124,6 +124,7 @@
 import android.content.res.Resources;
 import android.database.ContentObserver;
 import android.hardware.usb.UsbManager;
+import android.net.ConnectivityManager;
 import android.net.ConnectivityManager.NetworkCallback;
 import android.net.EthernetManager;
 import android.net.EthernetManager.TetheredInterfaceCallback;
@@ -374,6 +375,7 @@
         @Override
         public String getSystemServiceName(Class<?> serviceClass) {
             if (TelephonyManager.class.equals(serviceClass)) return Context.TELEPHONY_SERVICE;
+            if (ConnectivityManager.class.equals(serviceClass)) return Context.CONNECTIVITY_SERVICE;
             return super.getSystemServiceName(serviceClass);
         }
     }
diff --git a/DnsResolver/Android.bp b/bpf/dns_helper/Android.bp
similarity index 100%
rename from DnsResolver/Android.bp
rename to bpf/dns_helper/Android.bp
diff --git a/DnsResolver/DnsBpfHelper.cpp b/bpf/dns_helper/DnsBpfHelper.cpp
similarity index 100%
rename from DnsResolver/DnsBpfHelper.cpp
rename to bpf/dns_helper/DnsBpfHelper.cpp
diff --git a/DnsResolver/DnsBpfHelper.h b/bpf/dns_helper/DnsBpfHelper.h
similarity index 100%
rename from DnsResolver/DnsBpfHelper.h
rename to bpf/dns_helper/DnsBpfHelper.h
diff --git a/DnsResolver/DnsBpfHelperTest.cpp b/bpf/dns_helper/DnsBpfHelperTest.cpp
similarity index 100%
rename from DnsResolver/DnsBpfHelperTest.cpp
rename to bpf/dns_helper/DnsBpfHelperTest.cpp
diff --git a/DnsResolver/DnsHelper.cpp b/bpf/dns_helper/DnsHelper.cpp
similarity index 100%
rename from DnsResolver/DnsHelper.cpp
rename to bpf/dns_helper/DnsHelper.cpp
diff --git a/DnsResolver/include/DnsHelperPublic.h b/bpf/dns_helper/include/DnsHelperPublic.h
similarity index 100%
rename from DnsResolver/include/DnsHelperPublic.h
rename to bpf/dns_helper/include/DnsHelperPublic.h
diff --git a/DnsResolver/libcom.android.tethering.dns_helper.map.txt b/bpf/dns_helper/libcom.android.tethering.dns_helper.map.txt
similarity index 100%
rename from DnsResolver/libcom.android.tethering.dns_helper.map.txt
rename to bpf/dns_helper/libcom.android.tethering.dns_helper.map.txt
diff --git a/bpf/headers/include/bpf/KernelUtils.h b/bpf/headers/include/bpf/KernelUtils.h
index 417a5c4..68bc607 100644
--- a/bpf/headers/include/bpf/KernelUtils.h
+++ b/bpf/headers/include/bpf/KernelUtils.h
@@ -55,11 +55,12 @@
            isKernelVersion(4,  9) ||  // minimum for Android S & T
            isKernelVersion(4, 14) ||  // minimum for Android U
            isKernelVersion(4, 19) ||  // minimum for Android V
-           isKernelVersion(5,  4) ||  // first supported in Android R
+           isKernelVersion(5,  4) ||  // first supported in Android R, min for W
            isKernelVersion(5, 10) ||  // first supported in Android S
            isKernelVersion(5, 15) ||  // first supported in Android T
            isKernelVersion(6,  1) ||  // first supported in Android U
-           isKernelVersion(6,  6);    // first supported in Android V
+           isKernelVersion(6,  6) ||  // first supported in Android V
+           isKernelVersion(6, 12);    // first supported in Android W
 }
 
 // Figure out the bitness of userspace.
diff --git a/bpf/loader/NetBpfLoad.cpp b/bpf/loader/NetBpfLoad.cpp
index 69f1cb5..9a049c7 100644
--- a/bpf/loader/NetBpfLoad.cpp
+++ b/bpf/loader/NetBpfLoad.cpp
@@ -59,6 +59,9 @@
 #include "bpf/BpfUtils.h"
 #include "bpf_map_def.h"
 
+// The following matches bpf_helpers.h, which is only for inclusion in bpf code
+#define BPFLOADER_MAINLINE_VERSION 42u
+
 using android::base::EndsWith;
 using android::base::GetIntProperty;
 using android::base::GetProperty;
@@ -215,7 +218,7 @@
  * is the name of the program, and tracepoint is the type.
  *
  * However, be aware that you should not be directly using the SECTION() macro.
- * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE/CRITICAL macros.
+ * Instead use the DEFINE_(BPF|XDP)_(PROG|MAP)... & LICENSE macros.
  *
  * Programs shipped inside the tethering apex should be limited to networking stuff,
  * as KPROBE, PERF_EVENT, TRACEPOINT are dangerous to use from mainline updatable code,
@@ -1105,30 +1108,22 @@
     return 0;
 }
 
-int loadProg(const char* const elfPath, bool* const isCritical, const unsigned int bpfloader_ver,
+int loadProg(const char* const elfPath, const unsigned int bpfloader_ver,
              const char* const prefix) {
     vector<char> license;
-    vector<char> critical;
     vector<codeSection> cs;
     vector<unique_fd> mapFds;
     int ret;
 
-    if (!isCritical) return -1;
-    *isCritical = false;
-
     ifstream elfFile(elfPath, ios::in | ios::binary);
     if (!elfFile.is_open()) return -1;
 
-    ret = readSectionByName("critical", elfFile, critical);
-    *isCritical = !ret;
-
     ret = readSectionByName("license", elfFile, license);
     if (ret) {
         ALOGE("Couldn't find license in %s", elfPath);
         return ret;
     } else {
-        ALOGD("Loading %s%s ELF object %s with license %s",
-              *isCritical ? "critical for " : "optional", *isCritical ? (char*)critical.data() : "",
+        ALOGD("Loading ELF object %s with license %s",
               elfPath, (char*)license.data());
     }
 
@@ -1162,7 +1157,10 @@
         ALOGV("map_fd found at %d is %d in %s", i, mapFds[i].get(), elfPath);
 
     ret = readCodeSections(elfFile, cs);
-    if (ret == -ENOENT) return 0;  // no programs defined in this .o
+    // BPF .o's with no programs are only supported by mainline netbpfload,
+    // make sure .o's targeting non-mainline (ie. S) bpfloader don't show up.
+    if (ret == -ENOENT && bpfLoaderMinVer >= BPFLOADER_MAINLINE_VERSION)
+        return 0;
     if (ret) {
         ALOGE("Couldn't read all code sections in %s", elfPath);
         return ret;
@@ -1230,10 +1228,9 @@
             string progPath(location.dir);
             progPath += s;
 
-            bool critical;
-            int ret = loadProg(progPath.c_str(), &critical, bpfloader_ver, location.prefix);
+            int ret = loadProg(progPath.c_str(), bpfloader_ver, location.prefix);
             if (ret) {
-                if (critical) retVal = ret;
+                retVal = ret;
                 ALOGE("Failed to load object: %s, ret: %s", progPath.c_str(), std::strerror(-ret));
             } else {
                 ALOGD("Loaded object: %s", progPath.c_str());
@@ -1428,7 +1425,7 @@
     const bool has_platform_netbpfload_rc = exists("/system/etc/init/netbpfload.rc");
 
     // Version of Network BpfLoader depends on the Android OS version
-    unsigned int bpfloader_ver = 42u;    // [42] BPFLOADER_MAINLINE_VERSION
+    unsigned int bpfloader_ver = BPFLOADER_MAINLINE_VERSION;  // [42u]
     if (isAtLeastT) ++bpfloader_ver;     // [43] BPFLOADER_MAINLINE_T_VERSION
     if (isAtLeastU) ++bpfloader_ver;     // [44] BPFLOADER_MAINLINE_U_VERSION
     if (runningAsRoot) ++bpfloader_ver;  // [45] BPFLOADER_MAINLINE_U_QPR3_VERSION
diff --git a/bpf/loader/netbpfload.rc b/bpf/loader/netbpfload.rc
index e1af47f..10bfbb2 100644
--- a/bpf/loader/netbpfload.rc
+++ b/bpf/loader/netbpfload.rc
@@ -1,22 +1,3 @@
-# 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
-
 # Note: This will actually execute /apex/com.android.tethering/bin/netbpfload
 # by virtue of 'service bpfloader' being overridden by the apex shipped .rc
 # Warning: most of the below settings are irrelevant unless the apex is missing.
diff --git a/bpf/netd/BpfHandler.cpp b/bpf/netd/BpfHandler.cpp
index 9682545..5dea851 100644
--- a/bpf/netd/BpfHandler.cpp
+++ b/bpf/netd/BpfHandler.cpp
@@ -114,6 +114,11 @@
                                     cg_fd, BPF_CGROUP_INET_SOCK_CREATE));
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
+                                    cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
         RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_CONNECT4_PROG_PATH,
                                     cg_fd, BPF_CGROUP_INET4_CONNECT));
@@ -134,19 +139,12 @@
             RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_SETSOCKOPT_PROG_PATH,
                                         cg_fd, BPF_CGROUP_SETSOCKOPT));
         }
-
-        if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_INET_RELEASE_PROG_PATH,
-                                        cg_fd, BPF_CGROUP_INET_SOCK_RELEASE));
-        }
     }
 
     if (bpf::isAtLeastKernelVersion(4, 19, 0)) {
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind4_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND4_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET4_BIND));
-        RETURN_IF_NOT_OK(attachProgramToCgroup(
-                "/sys/fs/bpf/netd_readonly/prog_block_bind6_block_port",
+        RETURN_IF_NOT_OK(attachProgramToCgroup(CGROUP_BIND6_PROG_PATH,
                 cg_fd, BPF_CGROUP_INET6_BIND));
 
         // This should trivially pass, since we just attached up above,
@@ -158,6 +156,10 @@
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_BIND) <= 0) abort();
     }
 
+    if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
+        if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
+    }
+
     if (modules::sdklevel::IsAtLeastV()) {
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET4_CONNECT) <= 0) abort();
         if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET6_CONNECT) <= 0) abort();
@@ -170,10 +172,6 @@
             if (bpf::queryProgram(cg_fd, BPF_CGROUP_GETSOCKOPT) <= 0) abort();
             if (bpf::queryProgram(cg_fd, BPF_CGROUP_SETSOCKOPT) <= 0) abort();
         }
-
-        if (bpf::isAtLeastKernelVersion(5, 10, 0)) {
-            if (bpf::queryProgram(cg_fd, BPF_CGROUP_INET_SOCK_RELEASE) <= 0) abort();
-        }
     }
 
     return netdutils::status::ok;
diff --git a/bpf/progs/Android.bp b/bpf/progs/Android.bp
index dc1f56d..20d194c 100644
--- a/bpf/progs/Android.bp
+++ b/bpf/progs/Android.bp
@@ -47,8 +47,8 @@
         "com.android.tethering",
     ],
     visibility: [
+        "//packages/modules/Connectivity/bpf/dns_helper",
         "//packages/modules/Connectivity/bpf/netd",
-        "//packages/modules/Connectivity/DnsResolver",
         "//packages/modules/Connectivity/service",
         "//packages/modules/Connectivity/service/native/libs/libclat",
         "//packages/modules/Connectivity/Tethering",
@@ -64,12 +64,6 @@
 // bpf kernel programs
 //
 bpf {
-    name: "block.o",
-    srcs: ["block.c"],
-    sub_dir: "net_shared",
-}
-
-bpf {
     name: "dscpPolicy.o",
     srcs: ["dscpPolicy.c"],
     sub_dir: "net_shared",
diff --git a/bpf/progs/block.c b/bpf/progs/block.c
deleted file mode 100644
index 0e2dba9..0000000
--- a/bpf/progs/block.c
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright (C) 2022 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-// The resulting .o needs to load on Android T+
-#define BPFLOADER_MIN_VER BPFLOADER_MAINLINE_T_VERSION
-
-#include "bpf_net_helpers.h"
-
-DEFINE_BPF_MAP_GRW(blocked_ports_map, ARRAY, int, uint64_t,
-        1024 /* 64K ports -> 1024 u64s */, AID_SYSTEM)
-
-static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
-    if (!ctx->user_port) return BPF_ALLOW;
-
-    switch (ctx->protocol) {
-        case IPPROTO_TCP:
-        case IPPROTO_MPTCP:
-        case IPPROTO_UDP:
-        case IPPROTO_UDPLITE:
-        case IPPROTO_DCCP:
-        case IPPROTO_SCTP:
-            break;
-        default:
-            return BPF_ALLOW; // unknown protocols are allowed
-    }
-
-    int key = ctx->user_port >> 6;
-    int shift = ctx->user_port & 63;
-
-    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
-    // Lookup should never fail in reality, but if it does return here to keep the
-    // BPF verifier happy.
-    if (!val) return BPF_ALLOW;
-
-    if ((*val >> shift) & 1) return BPF_DISALLOW;
-    return BPF_ALLOW;
-}
-
-// the program need to be accessible/loadable by netd (from netd updatable plugin)
-#define DEFINE_NETD_RO_BPF_PROG(SECTION_NAME, the_prog, min_kver) \
-    DEFINE_BPF_PROG_EXT(SECTION_NAME, AID_ROOT, AID_ROOT, the_prog, min_kver, KVER_INF,  \
-                        BPFLOADER_MIN_VER, BPFLOADER_MAX_VER, MANDATORY, \
-                        "", "netd_readonly/", LOAD_ON_ENG, LOAD_ON_USER, LOAD_ON_USERDEBUG)
-
-DEFINE_NETD_RO_BPF_PROG("bind4/block_port", bind4_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-DEFINE_NETD_RO_BPF_PROG("bind6/block_port", bind6_block_port, KVER_4_19)
-(struct bpf_sock_addr *ctx) {
-    return block_port(ctx);
-}
-
-LICENSE("Apache 2.0");
-CRITICAL("ConnectivityNative");
diff --git a/bpf/progs/netd.c b/bpf/progs/netd.c
index 4248a46..cbe856d 100644
--- a/bpf/progs/netd.c
+++ b/bpf/progs/netd.c
@@ -69,6 +69,8 @@
 // TODO: consider whether we can merge some of these maps
 // for example it might be possible to merge 2 or 3 of:
 //   uid_counterset_map + uid_owner_map + uid_permission_map
+DEFINE_BPF_MAP_NO_NETD(blocked_ports_map, ARRAY, int, uint64_t,
+                       1024 /* 64K ports -> 1024 u64s */)
 DEFINE_BPF_MAP_RW_NETD(cookie_tag_map, HASH, uint64_t, UidTagValue, COOKIE_UID_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(uid_counterset_map, HASH, uint32_t, uint8_t, UID_COUNTERSET_MAP_SIZE)
 DEFINE_BPF_MAP_NO_NETD(app_uid_stats_map, HASH, uint32_t, StatsValue, APP_STATS_MAP_SIZE)
@@ -643,8 +645,8 @@
     return (get_app_permissions() & BPF_PERMISSION_INTERNET) ? BPF_ALLOW : BPF_DISALLOW;
 }
 
-DEFINE_NETD_V_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
-                            inet_socket_release, KVER_5_10)
+DEFINE_NETD_BPF_PROG_KVER("cgroupsockrelease/inet_release", AID_ROOT, AID_ROOT,
+                          inet_socket_release, KVER_5_10)
 (struct bpf_sock* sk) {
     uint64_t cookie = bpf_get_sk_cookie(sk);
     if (cookie) bpf_cookie_tag_map_delete_elem(&cookie);
@@ -670,6 +672,43 @@
     return BPF_ALLOW;
 }
 
+static inline __always_inline int block_port(struct bpf_sock_addr *ctx) {
+    if (!ctx->user_port) return BPF_ALLOW;
+
+    switch (ctx->protocol) {
+        case IPPROTO_TCP:
+        case IPPROTO_MPTCP:
+        case IPPROTO_UDP:
+        case IPPROTO_UDPLITE:
+        case IPPROTO_DCCP:
+        case IPPROTO_SCTP:
+            break;
+        default:
+            return BPF_ALLOW; // unknown protocols are allowed
+    }
+
+    int key = ctx->user_port >> 6;
+    int shift = ctx->user_port & 63;
+
+    uint64_t *val = bpf_blocked_ports_map_lookup_elem(&key);
+    // Lookup should never fail in reality, but if it does return here to keep the
+    // BPF verifier happy.
+    if (!val) return BPF_ALLOW;
+
+    if ((*val >> shift) & 1) return BPF_DISALLOW;
+    return BPF_ALLOW;
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind4/inet4_bind", AID_ROOT, AID_ROOT, inet4_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
+DEFINE_NETD_BPF_PROG_KVER("bind6/inet6_bind", AID_ROOT, AID_ROOT, inet6_bind, KVER_4_19)
+(struct bpf_sock_addr *ctx) {
+    return block_port(ctx);
+}
+
 DEFINE_NETD_V_BPF_PROG_KVER("connect4/inet4_connect", AID_ROOT, AID_ROOT, inet4_connect, KVER_4_14)
 (struct bpf_sock_addr *ctx) {
     return check_localhost(ctx);
diff --git a/bpf/progs/netd.h b/bpf/progs/netd.h
index 4877a4b..be7c311 100644
--- a/bpf/progs/netd.h
+++ b/bpf/progs/netd.h
@@ -157,6 +157,8 @@
 
 #define CGROUP_INET_CREATE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsock_inet_create"
 #define CGROUP_INET_RELEASE_PROG_PATH BPF_NETD_PATH "prog_netd_cgroupsockrelease_inet_release"
+#define CGROUP_BIND4_PROG_PATH BPF_NETD_PATH "prog_netd_bind4_inet4_bind"
+#define CGROUP_BIND6_PROG_PATH BPF_NETD_PATH "prog_netd_bind6_inet6_bind"
 #define CGROUP_CONNECT4_PROG_PATH BPF_NETD_PATH "prog_netd_connect4_inet4_connect"
 #define CGROUP_CONNECT6_PROG_PATH BPF_NETD_PATH "prog_netd_connect6_inet6_connect"
 #define CGROUP_UDP4_RECVMSG_PROG_PATH BPF_NETD_PATH "prog_netd_recvmsg4_udp4_recvmsg"
diff --git a/bpf/tests/mts/bpf_existence_test.cpp b/bpf/tests/mts/bpf_existence_test.cpp
index f3c6907..0b5b7be 100644
--- a/bpf/tests/mts/bpf_existence_test.cpp
+++ b/bpf/tests/mts/bpf_existence_test.cpp
@@ -82,13 +82,13 @@
 
 // Provided by *current* mainline module for T+ devices
 static const set<string> MAINLINE_FOR_T_PLUS = {
-    SHARED "map_block_blocked_ports_map",
     SHARED "map_clatd_clat_egress4_map",
     SHARED "map_clatd_clat_ingress6_map",
     SHARED "map_dscpPolicy_ipv4_dscp_policies_map",
     SHARED "map_dscpPolicy_ipv6_dscp_policies_map",
     SHARED "map_dscpPolicy_socket_policy_cache_map",
     NETD "map_netd_app_uid_stats_map",
+    NETD "map_netd_blocked_ports_map",
     NETD "map_netd_configuration_map",
     NETD "map_netd_cookie_tag_map",
     NETD "map_netd_data_saver_enabled_map",
@@ -119,8 +119,13 @@
 
 // Provided by *current* mainline module for T+ devices with 5.4+ kernels
 static const set<string> MAINLINE_FOR_T_4_19_PLUS = {
-    NETD_RO "prog_block_bind4_block_port",
-    NETD_RO "prog_block_bind6_block_port",
+    NETD "prog_netd_bind4_inet4_bind",
+    NETD "prog_netd_bind6_inet6_bind",
+};
+
+// Provided by *current* mainline module for T+ devices with 5.10+ kernels
+static const set<string> MAINLINE_FOR_T_5_10_PLUS = {
+    NETD "prog_netd_cgroupsockrelease_inet_release",
 };
 
 // Provided by *current* mainline module for T+ devices with 5.15+ kernels
@@ -154,11 +159,6 @@
     NETD "prog_netd_setsockopt_prog",
 };
 
-// Provided by *current* mainline module for V+ devices with 5.10+ kernels
-static const set<string> MAINLINE_FOR_V_5_10_PLUS = {
-    NETD "prog_netd_cgroupsockrelease_inet_release",
-};
-
 static void addAll(set<string>& a, const set<string>& b) {
     a.insert(b.begin(), b.end());
 }
@@ -196,6 +196,7 @@
     DO_EXPECT(IsAtLeastT(), MAINLINE_FOR_T_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 14, 0), MAINLINE_FOR_T_4_14_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(4, 19, 0), MAINLINE_FOR_T_4_19_PLUS);
+    DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_T_5_10_PLUS);
     DO_EXPECT(IsAtLeastT() && isAtLeastKernelVersion(5, 15, 0), MAINLINE_FOR_T_5_15_PLUS);
 
     // U requires Linux Kernel 4.14+, but nothing (as yet) added or removed in U.
@@ -207,7 +208,6 @@
     if (IsAtLeastV()) ASSERT_TRUE(isAtLeastKernelVersion(4, 19, 0));
     DO_EXPECT(IsAtLeastV(), MAINLINE_FOR_V_PLUS);
     DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 4, 0), MAINLINE_FOR_V_5_4_PLUS);
-    DO_EXPECT(IsAtLeastV() && isAtLeastKernelVersion(5, 10, 0), MAINLINE_FOR_V_5_10_PLUS);
 
     for (const auto& file : mustExist) {
         EXPECT_EQ(0, access(file.c_str(), R_OK)) << file << " does not exist";
diff --git a/common/thread_flags.aconfig b/common/thread_flags.aconfig
index c11c6c0..14b70d0 100644
--- a/common/thread_flags.aconfig
+++ b/common/thread_flags.aconfig
@@ -26,3 +26,12 @@
     description: "Controls whether the Android Thread setting max power of channel feature is enabled"
     bug: "346686506"
 }
+
+flag {
+    name: "epskc_enabled"
+    is_exported: true
+    is_fixed_read_only: true
+    namespace: "thread_network"
+    description: "Controls whether the Android Thread Ephemeral Key feature is enabled"
+    bug: "348323500"
+}
diff --git a/framework-t/api/system-current.txt b/framework-t/api/system-current.txt
index 9f26bcf..09a3681 100644
--- a/framework-t/api/system-current.txt
+++ b/framework-t/api/system-current.txt
@@ -507,7 +507,10 @@
   }
 
   @FlaggedApi("com.android.net.thread.flags.thread_enabled") public final class ThreadNetworkController {
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void activateEphemeralKeyMode(@NonNull java.time.Duration, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
     method public void createRandomizedDataset(@NonNull String, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<android.net.thread.ActiveOperationalDataset,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void deactivateEphemeralKeyMode(@NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @NonNull public java.time.Duration getMaxEphemeralKeyLifetime();
     method public int getThreadVersion();
     method public static boolean isAttached(int);
     method @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public void join(@NonNull android.net.thread.ActiveOperationalDataset, @NonNull java.util.concurrent.Executor, @NonNull android.os.OutcomeReceiver<java.lang.Void,android.net.thread.ThreadNetworkException>);
@@ -526,6 +529,9 @@
     field public static final int DEVICE_ROLE_LEADER = 4; // 0x4
     field public static final int DEVICE_ROLE_ROUTER = 3; // 0x3
     field public static final int DEVICE_ROLE_STOPPED = 0; // 0x0
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_DISABLED = 0; // 0x0
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_ENABLED = 1; // 0x1
+    field @FlaggedApi("com.android.net.thread.flags.epskc_enabled") public static final int EPHEMERAL_KEY_IN_USE = 2; // 0x2
     field public static final int MAX_POWER_CHANNEL_DISABLED = -2147483648; // 0x80000000
     field public static final int STATE_DISABLED = 0; // 0x0
     field public static final int STATE_DISABLING = 2; // 0x2
@@ -540,6 +546,7 @@
 
   public static interface ThreadNetworkController.StateCallback {
     method public void onDeviceRoleChanged(int);
+    method @FlaggedApi("com.android.net.thread.flags.epskc_enabled") @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED") public default void onEphemeralKeyStateChanged(int, @Nullable String, @Nullable java.time.Instant);
     method public default void onPartitionIdChanged(long);
     method public default void onThreadEnableStateChanged(int);
   }
diff --git a/nearby/framework/java/android/nearby/NearbyManager.java b/nearby/framework/java/android/nearby/NearbyManager.java
index 39adee3..150394b 100644
--- a/nearby/framework/java/android/nearby/NearbyManager.java
+++ b/nearby/framework/java/android/nearby/NearbyManager.java
@@ -123,9 +123,12 @@
 
     private static final int POWERED_OFF_FINDING_EID_LENGTH = 20;
 
-    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY =
+    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_RO =
             "ro.bluetooth.finder.supported";
 
+    private static final String POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST =
+            "persist.bluetooth.finder.supported";
+
     /**
      * TODO(b/286137024): Remove this when CTS R5 is rolled out.
      * Whether allows Fast Pair to scan.
@@ -618,7 +621,9 @@
     }
 
     private boolean isPoweredOffFindingSupported() {
-        return Boolean.parseBoolean(SystemProperties.get(POWER_OFF_FINDING_SUPPORTED_PROPERTY));
+        return Boolean.parseBoolean(SystemProperties.get(POWER_OFF_FINDING_SUPPORTED_PROPERTY_RO))
+                || Boolean.parseBoolean(SystemProperties.get(
+                        POWER_OFF_FINDING_SUPPORTED_PROPERTY_PERSIST));
     }
 
     private boolean areLocationAndBluetoothEnabled() {
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
index f196abb..a263546 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyFlagsListener.java
@@ -15,8 +15,6 @@
  */
 package com.android.server.net.ct;
 
-import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
-
 import android.annotation.RequiresApi;
 import android.content.Context;
 import android.os.Build;
@@ -46,7 +44,7 @@
         mDataStore.load();
         mCertificateTransparencyDownloader.registerReceiver();
         DeviceConfig.addOnPropertiesChangedListener(
-                NAMESPACE_TETHERING, Executors.newSingleThreadExecutor(), this);
+                Config.NAMESPACE_NETWORK_SECURITY, Executors.newSingleThreadExecutor(), this);
         if (Config.DEBUG) {
             Log.d(TAG, "CertificateTransparencyFlagsListener initialized successfully");
         }
@@ -55,14 +53,18 @@
 
     @Override
     public void onPropertiesChanged(Properties properties) {
-        if (!NAMESPACE_TETHERING.equals(properties.getNamespace())) {
+        if (!Config.NAMESPACE_NETWORK_SECURITY.equals(properties.getNamespace())) {
             return;
         }
 
-        String newVersion = DeviceConfig.getString(NAMESPACE_TETHERING, Config.VERSION, "");
-        String newContentUrl = DeviceConfig.getString(NAMESPACE_TETHERING, Config.CONTENT_URL, "");
+        String newVersion =
+                DeviceConfig.getString(Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_VERSION, "");
+        String newContentUrl =
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_CONTENT_URL, "");
         String newMetadataUrl =
-                DeviceConfig.getString(NAMESPACE_TETHERING, Config.METADATA_URL, "");
+                DeviceConfig.getString(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_METADATA_URL, "");
         if (TextUtils.isEmpty(newVersion)
                 || TextUtils.isEmpty(newContentUrl)
                 || TextUtils.isEmpty(newMetadataUrl)) {
diff --git a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
index 52478c0..edf7c56 100644
--- a/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
+++ b/networksecurity/service/src/com/android/server/net/ct/CertificateTransparencyService.java
@@ -19,27 +19,23 @@
 import android.content.Context;
 import android.net.ct.ICertificateTransparencyManager;
 import android.os.Build;
+import android.provider.DeviceConfig;
 
 import com.android.net.ct.flags.Flags;
-import com.android.net.module.util.DeviceConfigUtils;
 import com.android.server.SystemService;
 
 /** Implementation of the Certificate Transparency service. */
 @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
 public class CertificateTransparencyService extends ICertificateTransparencyManager.Stub {
 
-    private static final String CERTIFICATE_TRANSPARENCY_ENABLED =
-            "certificate_transparency_service_enabled";
-
     private final CertificateTransparencyFlagsListener mFlagsListener;
 
     /**
      * @return true if the CertificateTransparency service is enabled.
      */
     public static boolean enabled(Context context) {
-        // TODO: replace isTetheringFeatureEnabled with CT namespace flag.
-        return DeviceConfigUtils.isTetheringFeatureEnabled(
-                        context, CERTIFICATE_TRANSPARENCY_ENABLED)
+        return DeviceConfig.getBoolean(
+                        Config.NAMESPACE_NETWORK_SECURITY, Config.FLAG_SERVICE_ENABLED, false)
                 && Flags.certificateTransparencyService();
     }
 
diff --git a/networksecurity/service/src/com/android/server/net/ct/Config.java b/networksecurity/service/src/com/android/server/net/ct/Config.java
index 04b7dac..2a6b8e2 100644
--- a/networksecurity/service/src/com/android/server/net/ct/Config.java
+++ b/networksecurity/service/src/com/android/server/net/ct/Config.java
@@ -33,7 +33,15 @@
     private static final String PREFERENCES_FILE_NAME = "ct.preferences";
     static final File PREFERENCES_FILE = new File(DEVICE_PROTECTED_DATA_DIR, PREFERENCES_FILE_NAME);
 
-    // flags and properties names
+    // Phenotype flags
+    static final String NAMESPACE_NETWORK_SECURITY = "network_security";
+    private static final String FLAGS_PREFIX = "CertificateTransparencyLogList__";
+    static final String FLAG_SERVICE_ENABLED = FLAGS_PREFIX + "service_enabled";
+    static final String FLAG_CONTENT_URL = FLAGS_PREFIX + "content_url";
+    static final String FLAG_METADATA_URL = FLAGS_PREFIX + "metadata_url";
+    static final String FLAG_VERSION = FLAGS_PREFIX + "version";
+
+    // properties
     static final String VERSION_PENDING = "version_pending";
     static final String VERSION = "version";
     static final String CONTENT_URL_PENDING = "content_url_pending";
diff --git a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
index 241d5fa..9cca078 100644
--- a/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
+++ b/service-t/native/libs/libnetworkstats/NetworkTracePoller.cpp
@@ -41,10 +41,7 @@
   // The task runner is sequential so these can't run on top of each other.
   runner->PostDelayedTask([=, this]() { PollAndSchedule(runner, poll_ms); }, poll_ms);
 
-  if (mMutex.try_lock()) {
-    ConsumeAllLocked();
-    mMutex.unlock();
-  }
+  ConsumeAll();
 }
 
 bool NetworkTracePoller::Start(uint32_t pollMs) {
@@ -76,7 +73,10 @@
     return false;
   }
 
-  mRingBuffer = std::move(*rb);
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer = std::move(*rb);
+  }
 
   auto res = mConfigurationMap.writeValue(0, true, BPF_ANY);
   if (!res.ok()) {
@@ -114,10 +114,14 @@
   // Drain remaining events from the ring buffer now that tracing is disabled.
   // This prevents the next trace from seeing stale events and allows writing
   // the last batch of events to Perfetto.
-  ConsumeAllLocked();
+  ConsumeAll();
 
   mTaskRunner.reset();
-  mRingBuffer.reset();
+
+  {
+    std::scoped_lock<std::mutex> block(mBufferMutex);
+    mRingBuffer.reset();
+  }
 
   return res.ok();
 }
@@ -145,22 +149,20 @@
 }
 
 bool NetworkTracePoller::ConsumeAll() {
-  std::scoped_lock<std::mutex> lock(mMutex);
-  return ConsumeAllLocked();
-}
-
-bool NetworkTracePoller::ConsumeAllLocked() {
-  if (mRingBuffer == nullptr) {
-    ALOGW("Tracing is not active");
-    return false;
-  }
-
   std::vector<PacketTrace> packets;
-  base::Result<int> ret = mRingBuffer->ConsumeAll(
-      [&](const PacketTrace& pkt) { packets.push_back(pkt); });
-  if (!ret.ok()) {
-    ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
-    return false;
+  {
+    std::scoped_lock<std::mutex> lock(mBufferMutex);
+    if (mRingBuffer == nullptr) {
+      ALOGW("Tracing is not active");
+      return false;
+    }
+
+    base::Result<int> ret = mRingBuffer->ConsumeAll(
+        [&](const PacketTrace& pkt) { packets.push_back(pkt); });
+    if (!ret.ok()) {
+      ALOGW("Failed to poll ringbuf: %s", ret.error().message().c_str());
+      return false;
+    }
   }
 
   ATRACE_INT("NetworkTracePackets", packets.size());
diff --git a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
index 092ab64..72fa66e 100644
--- a/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
+++ b/service-t/native/libs/libnetworkstats/include/netdbpf/NetworkTracePoller.h
@@ -50,7 +50,7 @@
   bool Stop() EXCLUDES(mMutex);
 
   // Consumes all available events from the ringbuffer.
-  bool ConsumeAll() EXCLUDES(mMutex);
+  bool ConsumeAll() EXCLUDES(mBufferMutex);
 
  private:
   // Poll the ring buffer for new data and schedule another run of ourselves
@@ -59,15 +59,19 @@
   // and thus a deadlock while resetting the TaskRunner. The runner pointer is
   // always valid within tasks run by that runner.
   void PollAndSchedule(perfetto::base::TaskRunner* runner, uint32_t poll_ms);
-  bool ConsumeAllLocked() REQUIRES(mMutex);
 
   // Record sparse iface stats via atrace. This queries the per-iface stats maps
   // for any iface present in the vector of packets. This is inexact, but should
   // have sufficient coverage given these are cumulative counters.
-  void TraceIfaces(const std::vector<PacketTrace>& packets) REQUIRES(mMutex);
+  static void TraceIfaces(const std::vector<PacketTrace>& packets);
 
   std::mutex mMutex;
 
+  // The mBufferMutex protects the ring buffer. This allows separate protected
+  // access of mTaskRunner in Stop (to terminate) and mRingBuffer in ConsumeAll.
+  // Without this separation, Stop() can deadlock.
+  std::mutex mBufferMutex;
+
   // Records the number of successfully started active sessions so that only the
   // first active session attempts setup and only the last cleans up. Note that
   // the session count will remain zero if Start fails. It is expected that Stop
@@ -78,10 +82,10 @@
   uint32_t mPollMs GUARDED_BY(mMutex);
 
   // The function to process PacketTrace, typically a Perfetto sink.
-  EventSink mCallback GUARDED_BY(mMutex);
+  const EventSink mCallback;
 
   // The BPF ring buffer handle.
-  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mMutex);
+  std::unique_ptr<BpfRingbuf<PacketTrace>> mRingBuffer GUARDED_BY(mBufferMutex);
 
   // The packet tracing config map (really a 1-element array).
   BpfMap<uint32_t, bool> mConfigurationMap GUARDED_BY(mMutex);
diff --git a/service/src/com/android/server/connectivity/ConnectivityNativeService.java b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
index 917ad4d..7a008c6 100644
--- a/service/src/com/android/server/connectivity/ConnectivityNativeService.java
+++ b/service/src/com/android/server/connectivity/ConnectivityNativeService.java
@@ -43,7 +43,7 @@
     private static final String TAG = ConnectivityNativeService.class.getSimpleName();
 
     private static final String BLOCKED_PORTS_MAP_PATH =
-            "/sys/fs/bpf/net_shared/map_block_blocked_ports_map";
+            "/sys/fs/bpf/netd_shared/map_netd_blocked_ports_map";
 
     private final Context mContext;
 
diff --git a/staticlibs/device/com/android/net/module/util/SyncStateMachine.java b/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
index da184d3..fc0161b 100644
--- a/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
+++ b/staticlibs/device/com/android/net/module/util/SyncStateMachine.java
@@ -225,7 +225,8 @@
             consideredState = mStateInfo.get(consideredState.parent);
         }
         if (null == consideredState) {
-            Log.wtf(mName, "Message " + msg.what + " was not handled");
+            final String state = mCurrentState == null ? "null" : mCurrentState.getName();
+            Log.wtf(mName, "Message " + msg.what + " was not handled. Current state: " + state);
         }
 
         performTransitions();
diff --git a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
index 541a375..f34159e 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/NetlinkUtils.java
@@ -30,6 +30,7 @@
 import static android.system.OsConstants.SO_RCVTIMEO;
 import static android.system.OsConstants.SO_SNDTIMEO;
 
+import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
 import static com.android.net.module.util.netlink.NetlinkConstants.hexify;
 import static com.android.net.module.util.netlink.NetlinkConstants.NLMSG_DONE;
 import static com.android.net.module.util.netlink.NetlinkConstants.RTNL_FAMILY_IP6MR;
@@ -57,6 +58,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 
 /**
@@ -225,6 +227,96 @@
     }
 
     /**
+     * Sends an RTM_NEWLINK message to kernel to set a network interface up or down.
+     *
+     * @param ifName  The name of the network interface to modify.
+     * @param isUp    {@code true} to set the interface up, {@code false} to set it down.
+     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
+     */
+    public static boolean sendRtmSetLinkStateRequest(@NonNull String ifName, boolean isUp) {
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                ifName, 1 /*sequenceNumber*/, isUp);
+        if (msg == null) {
+            return false;
+        }
+
+        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
+        try {
+            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
+            return true;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Fail to set the interface " + ifName + " " + (isUp ? "up" : "down"), e);
+            return false;
+        }
+    }
+
+    /**
+     * Sends an RTM_NEWLINK message to kernel to rename a network interface.
+     *
+     * @param ifName     The current name of the network interface.
+     * @param newIfName  The new name to assign to the interface.
+     * @return {@code true} if the request was successfully sent, {@code false} otherwise.
+     */
+    public static boolean sendRtmSetLinkNameRequest(
+            @NonNull String ifName, @NonNull String newIfName) {
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
+                ifName, 1 /*sequenceNumber*/, newIfName);
+        if (msg == null) {
+            return false;
+        }
+
+        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
+        try {
+            NetlinkUtils.sendOneShotKernelMessage(NETLINK_ROUTE, bytes);
+            return true;
+        } catch (ErrnoException e) {
+            Log.e(TAG, "Fail to rename the interface from " + ifName + " to " + newIfName, e);
+            return false;
+        }
+    }
+
+    /**
+     * Gets the information of a network interface using a Netlink message.
+     * <p>
+     * This method sends a Netlink message to the kernel to request information about the specified
+     * network interface and returns a {@link RtNetlinkLinkMessage} containing the interface status.
+     *
+     * @param ifName The name of the network interface to query.
+     * @return An {@link RtNetlinkLinkMessage} containing the interface status, or {@code null} if
+     *         the interface does not exist or an error occurred during the query.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage getLinkRequest(@NonNull String ifName) {
+        final int ifIndex = new OsAccess().if_nametoindex(ifName);
+        if (ifIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        final AtomicReference<RtNetlinkLinkMessage> recvMsg = new AtomicReference<>();
+        final Consumer<RtNetlinkLinkMessage> handleNlMsg = (msg) -> {
+            if (msg.getHeader().nlmsg_type == RTM_NEWLINK
+                    && msg.getIfinfoHeader().index == ifIndex) {
+                recvMsg.set(msg);
+            }
+        };
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createGetLinkMessage(
+                ifName, 1 /*sequenceNumber*/);
+        if (msg == null) {
+            return null;
+        }
+
+        final byte[] bytes = msg.pack(ByteOrder.nativeOrder());
+        try {
+            NetlinkUtils.getAndProcessNetlinkDumpMessages(
+                    bytes, NETLINK_ROUTE, RtNetlinkLinkMessage.class, handleNlMsg);
+        } catch (SocketException | InterruptedIOException | ErrnoException e) {
+            // Nothing we can do here.
+        }
+        return recvMsg.get();
+    }
+
+    /**
      * Create netlink socket with the given netlink protocol type and buffersize.
      *
      * @param nlProto the netlink protocol
diff --git a/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
new file mode 100644
index 0000000..7591d5c
--- /dev/null
+++ b/staticlibs/device/com/android/net/module/util/netlink/OsAccess.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.net.module.util.netlink;
+
+import android.system.Os;
+
+import androidx.annotation.NonNull;
+
+/**
+ * This class wraps the static methods of {@link android.system.Os} for mocking and testing.
+ */
+public class OsAccess {
+    /**
+     * Constant indicating that the {@code if_nametoindex()} function could not find the network
+     * interface index corresponding to the given interface name.
+     */
+    public static int INVALID_INTERFACE_INDEX = 0;
+
+    /** Wraps {@link Os#if_nametoindex(String)}. */
+    public int if_nametoindex(@NonNull String name) {
+        return Os.if_nametoindex(name);
+    }
+}
diff --git a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
index 0c49edc..037d95f 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/RtNetlinkLinkMessage.java
@@ -16,6 +16,15 @@
 
 package com.android.net.module.util.netlink;
 
+import static android.system.OsConstants.AF_UNSPEC;
+
+import static com.android.net.module.util.NetworkStackConstants.ETHER_ADDR_LEN;
+import static com.android.net.module.util.netlink.NetlinkConstants.IFF_UP;
+import static com.android.net.module.util.netlink.NetlinkConstants.RTM_GETLINK;
+import static com.android.net.module.util.netlink.NetlinkConstants.RTM_NEWLINK;
+import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST;
+import static com.android.net.module.util.netlink.StructNlMsgHdr.NLM_F_REQUEST_ACK;
+
 import android.net.MacAddress;
 import android.system.OsConstants;
 
@@ -24,6 +33,7 @@
 import androidx.annotation.VisibleForTesting;
 
 import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
 
 /**
  * A NetlinkMessage subclass for rtnetlink link messages.
@@ -46,29 +56,55 @@
 
     public static final short IN6_ADDR_GEN_MODE_NONE = 1;
 
-    private int mMtu;
-    @NonNull
-    private StructIfinfoMsg mIfinfomsg;
-    @Nullable
-    private MacAddress mHardwareAddress;
-    @Nullable
-    private String mInterfaceName;
+    // The maximum buffer size to hold an interface name including the null-terminator '\0'.
+    private static final int IFNAMSIZ = 16;
+    // The default value of MTU, which means the MTU is unspecified.
+    private static final int DEFAULT_MTU = 0;
 
-    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr header) {
-        super(header);
-        mIfinfomsg = null;
-        mMtu = 0;
-        mHardwareAddress = null;
-        mInterfaceName = null;
+    @NonNull
+    private final StructIfinfoMsg mIfinfomsg;
+    private final int mMtu;
+    @Nullable
+    private final MacAddress mHardwareAddress;
+    @Nullable
+    private final String mInterfaceName;
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance.
+     *
+     * <p>This method validates the arguments and returns {@code null} if any of them are invalid.
+     * nlmsghdr's nlmsg_len will be updated to the correct length before creation.
+     *
+     * @param nlmsghdr The Netlink message header. Must not be {@code null}.
+     * @param ifinfomsg The interface information message. Must not be {@code null}.
+     * @param mtu The Maximum Transmission Unit (MTU) value for the link.
+     * @param hardwareAddress The hardware address (MAC address) of the link. May be {@code null}.
+     * @param interfaceName The name of the interface. May be {@code null}.
+     * @return A new {@link RtNetlinkLinkMessage} instance, or {@code null} if the input arguments
+     *         are invalid.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage build(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
+        if (mtu < 0) {
+            return null;
+        }
+        if (interfaceName != null
+                && (interfaceName.isEmpty() || interfaceName.length() + 1 > IFNAMSIZ)) {
+            return null;
+        }
+
+        nlmsghdr.nlmsg_len = calculateMessageLength(mtu, hardwareAddress, interfaceName);
+        return new RtNetlinkLinkMessage(nlmsghdr, ifinfomsg, mtu, hardwareAddress, interfaceName);
     }
 
-    @VisibleForTesting
-    public RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
-            int mtu, @NonNull StructIfinfoMsg ifinfomsg, @NonNull MacAddress hardwareAddress,
-            @NonNull String interfaceName) {
+    private RtNetlinkLinkMessage(@NonNull StructNlMsgHdr nlmsghdr,
+            @NonNull StructIfinfoMsg ifinfomsg, int mtu, @Nullable MacAddress hardwareAddress,
+            @Nullable String interfaceName) {
         super(nlmsghdr);
-        mMtu = mtu;
         mIfinfomsg = ifinfomsg;
+        mMtu = mtu;
         mHardwareAddress = hardwareAddress;
         mInterfaceName = interfaceName;
     }
@@ -102,33 +138,46 @@
     @Nullable
     public static RtNetlinkLinkMessage parse(@NonNull final StructNlMsgHdr header,
             @NonNull final ByteBuffer byteBuffer) {
-        final RtNetlinkLinkMessage linkMsg = new RtNetlinkLinkMessage(header);
-
-        linkMsg.mIfinfomsg = StructIfinfoMsg.parse(byteBuffer);
-        if (linkMsg.mIfinfomsg == null) return null;
+        final StructIfinfoMsg ifinfoMsg = StructIfinfoMsg.parse(byteBuffer);
+        if (ifinfoMsg == null) {
+            return null;
+        }
 
         // IFLA_MTU
+        int mtu = DEFAULT_MTU;
         final int baseOffset = byteBuffer.position();
         StructNlAttr nlAttr = StructNlAttr.findNextAttrOfType(IFLA_MTU, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mMtu = nlAttr.getValueAsInt(0 /* default value */);
+            mtu = nlAttr.getValueAsInt(DEFAULT_MTU);
         }
 
         // IFLA_ADDRESS
+        MacAddress hardwareAddress = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_ADDRESS, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mHardwareAddress = nlAttr.getValueAsMacAddress();
+            hardwareAddress = nlAttr.getValueAsMacAddress();
         }
 
         // IFLA_IFNAME
+        String interfaceName = null;
         byteBuffer.position(baseOffset);
         nlAttr = StructNlAttr.findNextAttrOfType(IFLA_IFNAME, byteBuffer);
         if (nlAttr != null) {
-            linkMsg.mInterfaceName = nlAttr.getValueAsString();
+            interfaceName = nlAttr.getValueAsString();
         }
 
-        return linkMsg;
+        return new RtNetlinkLinkMessage(header, ifinfoMsg, mtu, hardwareAddress, interfaceName);
+    }
+
+    /**
+     *  Write a rtnetlink link message to {@link byte} array.
+     */
+    public byte[] pack(ByteOrder order) {
+        byte[] bytes = new byte[mHeader.nlmsg_len];
+        ByteBuffer buffer = ByteBuffer.wrap(bytes).order(order);
+        pack(buffer);
+        return bytes;
     }
 
     /**
@@ -136,10 +185,10 @@
      */
     @VisibleForTesting
     protected void pack(ByteBuffer byteBuffer) {
-        getHeader().pack(byteBuffer);
+        mHeader.pack(byteBuffer);
         mIfinfomsg.pack(byteBuffer);
 
-        if (mMtu != 0) {
+        if (mMtu != DEFAULT_MTU) {
             final StructNlAttr mtu = new StructNlAttr(IFLA_MTU, mMtu);
             mtu.pack(byteBuffer);
         }
@@ -153,11 +202,121 @@
         }
     }
 
+    /**
+     *  Calculate the byte length of the packed buffer.
+     */
+    private static int calculateMessageLength(int mtu, MacAddress hardwareAddress,
+            String interfaceName) {
+        int length = StructNlMsgHdr.STRUCT_SIZE + StructIfinfoMsg.STRUCT_SIZE;
+
+        if (mtu != DEFAULT_MTU) {
+            length += NetlinkConstants.alignedLengthOf(StructNlAttr.NLA_HEADERLEN + Integer.BYTES);
+        }
+        if (hardwareAddress != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    StructNlAttr.NLA_HEADERLEN + ETHER_ADDR_LEN);
+        }
+        if (interfaceName != null) {
+            length += NetlinkConstants.alignedLengthOf(
+                    // The string should be end with '\0', so the length should plus 1.
+                    StructNlAttr.NLA_HEADERLEN + interfaceName.length() + 1);
+        }
+
+        return length;
+    }
+
+    /**
+     * Create a link message to set the operational state (up or down) of a network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param isUp           {@code true} to set the interface up, {@code false} to set it down.
+     * @return A `RtNetlinkLinkMessage` instance configured to set the link state.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp) {
+        return createSetLinkStateMessage(interfaceName, sequenceNumber, isUp, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkStateMessage(@NonNull String interfaceName,
+            int sequenceNumber, boolean isUp, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex,
+                                    isUp ? IFF_UP : 0, IFF_UP), DEFAULT_MTU, null, null);
+    }
+
+    /**
+     * Create a link message to rename the network interface.
+     *
+     * @param interfaceName  The network interface name.
+     * @param sequenceNumber The sequence number to use for the Netlink message.
+     * @param newName        The new name of the network interface.
+     * @return A `RtNetlinkLinkMessage` instance configured to rename the network interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName) {
+        return createSetLinkNameMessage(interfaceName, sequenceNumber, newName, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createSetLinkNameMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull String newName, OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_NEWLINK, NLM_F_REQUEST_ACK, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
+                DEFAULT_MTU, null, newName);
+    }
+
+    /**
+     * Creates an {@link RtNetlinkLinkMessage} instance that can be used to get the link information
+     * of a network interface.
+     *
+     * @param interfaceName The name of the network interface to query.
+     * @param sequenceNumber The sequence number for the Netlink message.
+     * @return An `RtNetlinkLinkMessage` instance representing the request to query the interface.
+     */
+    @Nullable
+    public static RtNetlinkLinkMessage createGetLinkMessage(@NonNull String interfaceName,
+            int sequenceNumber) {
+        return createGetLinkMessage(interfaceName, sequenceNumber, new OsAccess());
+    }
+
+    @VisibleForTesting
+    @Nullable
+    protected static RtNetlinkLinkMessage createGetLinkMessage(@NonNull String interfaceName,
+            int sequenceNumber, @NonNull OsAccess osAccess) {
+        final int interfaceIndex = osAccess.if_nametoindex(interfaceName);
+        if (interfaceIndex == OsAccess.INVALID_INTERFACE_INDEX) {
+            return null;
+        }
+
+        return RtNetlinkLinkMessage.build(
+                new StructNlMsgHdr(0, RTM_GETLINK, NLM_F_REQUEST, sequenceNumber),
+                new StructIfinfoMsg((short) AF_UNSPEC, (short) 0, interfaceIndex, 0, 0),
+                DEFAULT_MTU, null, null);
+    }
+
     @Override
     public String toString() {
         return "RtNetlinkLinkMessage{ "
                 + "nlmsghdr{" + mHeader.toString(OsConstants.NETLINK_ROUTE) + "}, "
-                + "Ifinfomsg{" + mIfinfomsg.toString() + "}, "
+                + "Ifinfomsg{" + mIfinfomsg + "}, "
                 + "Hardware Address{" + mHardwareAddress + "}, "
                 + "MTU{" + mMtu + "}, "
                 + "Ifname{" + mInterfaceName + "} "
diff --git a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
index 5272366..7cc95de 100644
--- a/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
+++ b/staticlibs/device/com/android/net/module/util/netlink/StructNlMsgHdr.java
@@ -32,10 +32,11 @@
     // Already aligned.
     public static final int STRUCT_SIZE = 16;
 
-    public static final short NLM_F_REQUEST = 0x0001;
-    public static final short NLM_F_MULTI   = 0x0002;
-    public static final short NLM_F_ACK     = 0x0004;
-    public static final short NLM_F_ECHO    = 0x0008;
+    public static final short NLM_F_REQUEST     = 0x0001;
+    public static final short NLM_F_MULTI       = 0x0002;
+    public static final short NLM_F_ACK         = 0x0004;
+    public static final short NLM_F_ECHO        = 0x0008;
+    public static final short NLM_F_REQUEST_ACK = NLM_F_REQUEST | NLM_F_ACK;
     // Flags for a GET request.
     public static final short NLM_F_ROOT    = 0x0100;
     public static final short NLM_F_MATCH   = 0x0200;
diff --git a/staticlibs/tests/unit/host/python/apf_utils_test.py b/staticlibs/tests/unit/host/python/apf_utils_test.py
index b5a941b..2885460 100644
--- a/staticlibs/tests/unit/host/python/apf_utils_test.py
+++ b/staticlibs/tests/unit/host/python/apf_utils_test.py
@@ -25,6 +25,8 @@
     get_apf_capabilities,
     get_apf_counter,
     get_apf_counters_from_dumpsys,
+    get_ipv4_addresses,
+    get_ipv6_addresses,
     get_hardware_address,
     is_send_raw_packet_downstream_supported,
     send_raw_packet_downstream,
@@ -112,6 +114,55 @@
       get_hardware_address(self.mock_ad, "wlan0")
 
   @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv4_addresses_success(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = """
+54: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+    inet 192.168.195.162/24 brd 192.168.195.255 scope global wlan0
+       valid_lft forever preferred_lft forever
+    inet 192.168.200.1/24 brd 192.168.200.255 scope global wlan0
+       valid_lft forever preferred_lft forever
+"""
+    ip_addresses = get_ipv4_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, ["192.168.195.162", "192.168.200.1"])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv4_addresses_not_found(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = ""
+    ip_addresses = get_ipv4_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, [])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv6_addresses_success(
+      self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = """
+54: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+    inet6 fe80::10a3:5dff:fe52:de32/64 scope link
+        valid_lft forever preferred_lft forever
+    inet6 2001:b400:e53f:164e:9c1e:780e:d1:4658/64 scope global dynamic mngtmpaddr noprefixroute
+        valid_lft 6995sec preferred_lft 6995sec
+    inet6 fe80::3aff:2199:2d8e:20d1/64 scope link noprefixroute
+        valid_lft forever preferred_lft forever
+"""
+    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses,
+                         ["fe80::10a3:5dff:fe52:de32",
+                          "2001:b400:e53f:164e:9c1e:780e:d1:4658",
+                          "fe80::3aff:2199:2d8e:20d1"])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
+  def test_get_ipv6_address_not_found(
+          self, mock_adb_shell: MagicMock
+  ) -> None:
+    mock_adb_shell.return_value = ""
+    ip_addresses = get_ipv6_addresses(self.mock_ad, "wlan0")
+    asserts.assert_equal(ip_addresses, [])
+
+  @patch("net_tests_utils.host.python.adb_utils.adb_shell")
   def test_send_raw_packet_downstream_success(
       self, mock_adb_shell: MagicMock
   ) -> None:
diff --git a/staticlibs/tests/unit/host/python/packet_utils_test.py b/staticlibs/tests/unit/host/python/packet_utils_test.py
new file mode 100644
index 0000000..8ad9576
--- /dev/null
+++ b/staticlibs/tests/unit/host/python/packet_utils_test.py
@@ -0,0 +1,72 @@
+#  Copyright (C) 2024 The Android Open Source Project
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+from mobly import asserts
+from mobly import base_test
+from net_tests_utils.host.python import packet_utils
+
+class TestPacketUtils(base_test.BaseTestClass):
+    def test_unicast_arp_request(self):
+        # Using scapy to generate unicast arp request packet:
+        #   eth = Ether(src="00:01:02:03:04:05", dst="01:02:03:04:05:06")
+        #   arp = ARP(op=1, pdst="192.168.1.1", hwsrc="00:01:02:03:04:05", psrc="192.168.1.2")
+        #   pkt = eth/arp
+        expect_arp_request = """
+            01020304050600010203040508060001080006040001000102030405c0a80102000000000000c0a80101
+        """.upper().replace(" ", "").replace("\n", "")
+        arp_request = packet_utils.construct_arp_packet(
+            src_mac="00:01:02:03:04:05",
+            dst_mac="01:02:03:04:05:06",
+            src_ip="192.168.1.2",
+            dst_ip="192.168.1.1",
+            op=packet_utils.ARP_REQUEST_OP
+        )
+        asserts.assert_equal(expect_arp_request, arp_request)
+
+    def test_broadcast_arp_request(self):
+        # Using scapy to generate unicast arp request packet:
+        #   eth = Ether(src="00:01:02:03:04:05", dst="FF:FF:FF:FF:FF:FF")
+        #   arp = ARP(op=1, pdst="192.168.1.1", hwsrc="00:01:02:03:04:05", psrc="192.168.1.2")
+        #   pkt = eth/arp
+        expect_arp_request = """
+            ffffffffffff00010203040508060001080006040001000102030405c0a80102000000000000c0a80101
+        """.upper().replace(" ", "").replace("\n", "")
+        arp_request = packet_utils.construct_arp_packet(
+            src_mac="00:01:02:03:04:05",
+            dst_mac=packet_utils.ETHER_BROADCAST_MAC_ADDRESS,
+            src_ip="192.168.1.2",
+            dst_ip="192.168.1.1",
+            op=packet_utils.ARP_REQUEST_OP
+        )
+        asserts.assert_equal(expect_arp_request, arp_request)
+
+    def test_arp_reply(self):
+        # Using scapy to generate unicast arp request packet:
+        #   eth = Ether(src="01:02:03:04:05:06", dst="00:01:02:03:04:05")
+        #   arp = ARP(op=2, pdst="192.168.1.2", \
+        #             hwsrc="01:02:03:04:05:06", \
+        #             psrc="192.168.1.1", \
+        #             hwdst="00:01:02:03:04:05")
+        #   pkt = eth/arp
+        expect_arp_reply = """
+            00010203040501020304050608060001080006040002010203040506c0a80101000102030405c0a80102
+        """.upper().replace(" ", "").replace("\n", "")
+        arp_reply = packet_utils.construct_arp_packet(
+            src_mac="01:02:03:04:05:06",
+            dst_mac="00:01:02:03:04:05",
+            src_ip="192.168.1.1",
+            dst_ip="192.168.1.2",
+            op=packet_utils.ARP_REPLY_OP
+        )
+        asserts.assert_equal(expect_arp_reply, arp_reply)
diff --git a/staticlibs/tests/unit/host/python/run_tests.py b/staticlibs/tests/unit/host/python/run_tests.py
index fa6a310..498dbaf 100644
--- a/staticlibs/tests/unit/host/python/run_tests.py
+++ b/staticlibs/tests/unit/host/python/run_tests.py
@@ -18,6 +18,7 @@
 from host.python.adb_utils_test import TestAdbUtils
 from host.python.apf_utils_test import TestApfUtils
 from host.python.assert_utils_test import TestAssertUtils
+from host.python.packet_utils_test import TestPacketUtils
 from mobly import suite_runner
 
 
@@ -31,5 +32,5 @@
   sys.argv.pop(1)
   # TODO: make the tests can be executed without manually list classes.
   suite_runner.run_suite(
-      [TestAssertUtils, TestAdbUtils, TestApfUtils], sys.argv
+      [TestAssertUtils, TestAdbUtils, TestApfUtils, TestPacketUtils], sys.argv
   )
diff --git a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
index 9db63db..bd0e31d 100644
--- a/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
+++ b/staticlibs/tests/unit/src/com/android/net/module/util/netlink/RtNetlinkLinkMessageTest.java
@@ -24,24 +24,28 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
 
 import android.net.MacAddress;
 import android.system.OsConstants;
 
 import androidx.test.filters.SmallTest;
-import androidx.test.runner.AndroidJUnit4;
 
 import com.android.net.module.util.HexDump;
 
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 
-@RunWith(AndroidJUnit4.class)
+@RunWith(MockitoJUnitRunner.class)
 @SmallTest
 public class RtNetlinkLinkMessageTest {
+    @Mock
+    private OsAccess mOsAccess;
 
     // An example of the full RTM_NEWLINK message.
     private static final String RTM_NEWLINK_HEX =
@@ -124,14 +128,14 @@
     }
 
     private static final String RTM_NEWLINK_PACK_HEX =
-            "34000000100000000000000000000000"   // struct nlmsghr
+            "40000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
             + "08000400DC050000"                 // IFLA_MTU
             + "0A00010092C3E3C9374E0000"         // IFLA_ADDRESS
             + "0A000300776C616E30000000";        // IFLA_IFNAME(wlan0)
 
     @Test
-    public void testPackRtmNewLink() {
+    public void testParseAndPackRtmNewLink() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_PACK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
         final NetlinkMessage msg = NetlinkMessage.parse(byteBuffer, NETLINK_ROUTE);
@@ -145,6 +149,21 @@
         assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBuffer.array()));
     }
 
+    @Test
+    public void testPackRtmNewLink() {
+        final RtNetlinkLinkMessage linkMsg = RtNetlinkLinkMessage.build(
+                // nlmsg_len will be updated inside create() method, so it's ok to set 0 here.
+                new StructNlMsgHdr(0 /*nlmsg_len*/, (short) 0x10, (short) 0, 0),
+                new StructIfinfoMsg((byte) 0, (short) 1, 0x1e, 0x1002, 0),
+                1500,
+                MacAddress.fromString("92:c3:e3:c9:37:4e"),
+                "wlan0");
+        assertNotNull(linkMsg);
+
+        final byte[] packBytes = linkMsg.pack(ByteOrder.LITTLE_ENDIAN);
+        assertEquals(RTM_NEWLINK_PACK_HEX, HexDump.toHexString(packBytes));
+    }
+
     private static final String RTM_NEWLINK_TRUNCATED_HEX =
             "54000000100000000000000000000000"   // struct nlmsghr
             + "000001001E0000000210000000000000" // struct ifinfo
@@ -171,6 +190,122 @@
     }
 
     @Test
+    public void testCreateSetLinkUpMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                + "00000000080000000100000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = true;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkDownMessage() {
+        final String expectedHexBytes =
+                "20000000100005006824000000000000"     // struct nlmsghdr
+                        + "00000000080000000000000001000000";  // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkStateMessage_InvalidInterface() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final boolean isUp = false;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkStateMessage(
+                interfaceName, sequenceNumber, isUp, mOsAccess);
+        assertNull(msg);
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage() {
+        final String expectedHexBytes =
+                "2C000000100005006824000000000000"   // struct nlmsghdr
+                + "00000000080000000000000000000000" // struct ifinfomsg
+                + "0A000300776C616E31000000";        // IFLA_IFNAME(wlan1)
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InterfaceNotFound() {
+        final String interfaceName = "wlan0";
+        final int sequenceNumber = 0x2468;
+        final String newName = "wlan1";
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(OsAccess.INVALID_INTERFACE_INDEX);
+
+        assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                interfaceName, sequenceNumber, newName, mOsAccess));
+    }
+
+    @Test
+    public void testCreateSetLinkNameMessage_InvalidNewName() {
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final String[] invalidNames = {"", "interface_name_longer_than_limit"};
+        for (String invalidName : invalidNames) {
+            assertNull(RtNetlinkLinkMessage.createSetLinkNameMessage(
+                    interfaceName, sequenceNumber, invalidName, mOsAccess));
+        }
+    }
+
+    @Test
+    public void testCreateGetLinkMessage() {
+        final String expectedHexBytes =
+                "20000000120001006824000000000000"    // struct nlmsghdr
+                + "00000000080000000000000000000000"; // struct ifinfomsg
+        final String interfaceName = "wlan0";
+        final int interfaceIndex = 8;
+        final int sequenceNumber = 0x2468;
+
+        when(mOsAccess.if_nametoindex(interfaceName)).thenReturn(interfaceIndex);
+
+        final RtNetlinkLinkMessage msg = RtNetlinkLinkMessage.createGetLinkMessage(
+                interfaceName, sequenceNumber, mOsAccess);
+        assertNotNull(msg);
+        final byte[] bytes = msg.pack(ByteOrder.LITTLE_ENDIAN);  // For testing.
+        assertEquals(expectedHexBytes, HexDump.toHexString(bytes));
+    }
+
+    @Test
     public void testToString() {
         final ByteBuffer byteBuffer = toByteBuffer(RTM_NEWLINK_HEX);
         byteBuffer.order(ByteOrder.LITTLE_ENDIAN);  // For testing.
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java b/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java
new file mode 100644
index 0000000..8608344
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/DnsSvcbUtils.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.testutils;
+
+import static android.net.DnsResolver.CLASS_IN;
+
+import static com.android.net.module.util.DnsPacket.TYPE_SVCB;
+import static com.android.net.module.util.DnsPacketUtils.DnsRecordParser.domainNameToLabels;
+import static com.android.net.module.util.NetworkStackConstants.IPV4_ADDR_LEN;
+import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_LEN;
+
+import static org.junit.Assert.fail;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+
+import android.net.InetAddresses;
+
+import androidx.annotation.NonNull;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class DnsSvcbUtils {
+    private static final Pattern SVC_PARAM_PATTERN = Pattern.compile("([a-z0-9-]+)=?(.*)");
+
+    /**
+     * Returns a DNS SVCB response with given hostname `hostname` and given SVCB records
+     * `records`. Each record must contain the service priority, the target name, and the service
+     * parameters.
+     *     E.g. "1 doh.google alpn=h2,h3 port=443 ipv4hint=192.0.2.1 dohpath=/dns-query{?dns}"
+     */
+    @NonNull
+    public static byte[] makeSvcbResponse(String hostname, String[] records) throws IOException {
+        if (records == null) throw new NullPointerException();
+        if (!hostname.startsWith("_dns.")) throw new UnsupportedOperationException();
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        // Write DNS header.
+        os.write(shortsToByteArray(
+                0x1234,         /* Transaction ID */
+                0x8100,         /* Flags */
+                1,              /* qdcount */
+                records.length, /* ancount */
+                0,              /* nscount */
+                0               /* arcount */
+        ));
+        // Write Question.
+        // - domainNameToLabels() doesn't support the hostname starting with "_", so divide
+        //   the writing into two steps.
+        os.write(new byte[] { 0x04, '_', 'd', 'n', 's' });
+        os.write(domainNameToLabels(hostname.substring(5)));
+        os.write(shortsToByteArray(TYPE_SVCB, CLASS_IN));
+        // Write Answer section.
+        for (String r : records) {
+            os.write(makeSvcbRecord(r));
+        }
+        return os.toByteArray();
+    }
+
+    @NonNull
+    private static byte[] makeSvcbRecord(String representation) throws IOException {
+        if (representation == null) return new byte[0];
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        os.write(shortsToByteArray(
+                0xc00c, /* Pointer to qname in question section */
+                TYPE_SVCB,
+                CLASS_IN,
+                0, 16, /* TTL = 16 */
+                0 /* Data Length = 0 */
+
+        ));
+        final String[] strings = representation.split(" +");
+        // SvcPriority and TargetName are mandatory in the representation.
+        if (strings.length < 3) {
+            fail("Invalid SVCB representation: " + representation);
+        }
+        // Write SvcPriority, TargetName, and SvcParams.
+        os.write(shortsToByteArray(Short.parseShort(strings[0])));
+        os.write(domainNameToLabels(strings[1]));
+        for (int i = 2; i < strings.length; i++) {
+            try {
+                os.write(svcParamToByteArray(strings[i]));
+            } catch (UnsupportedEncodingException e) {
+                throw new IOException(e);
+            }
+        }
+        // Update rdata length.
+        final byte[] out = os.toByteArray();
+        ByteBuffer.wrap(out).putShort(10, (short) (out.length - 12));
+        return out;
+    }
+
+    @NonNull
+    private static byte[] svcParamToByteArray(String svcParam) throws IOException {
+        final Matcher matcher = SVC_PARAM_PATTERN.matcher(svcParam);
+        if (!matcher.matches() || matcher.groupCount() != 2) {
+            fail("Invalid SvcParam: " + svcParam);
+        }
+        final String svcParamkey = matcher.group(1);
+        final String svcParamValue = matcher.group(2);
+        final ByteArrayOutputStream os = new ByteArrayOutputStream();
+        os.write(svcParamKeyToBytes(svcParamkey));
+        switch (svcParamkey) {
+            case "mandatory":
+                final String[] keys = svcParamValue.split(",");
+                os.write(shortsToByteArray(keys.length));
+                for (String v : keys) {
+                    os.write(svcParamKeyToBytes(v));
+                }
+                break;
+            case "alpn":
+                os.write(shortsToByteArray((svcParamValue.length() + 1)));
+                for (String v : svcParamValue.split(",")) {
+                    os.write(v.length());
+                    // TODO: support percent-encoding per RFC 7838.
+                    os.write(v.getBytes(US_ASCII));
+                }
+                break;
+            case "no-default-alpn":
+                os.write(shortsToByteArray(0));
+                break;
+            case "port":
+                os.write(shortsToByteArray(2));
+                os.write(shortsToByteArray(Short.parseShort(svcParamValue)));
+                break;
+            case "ipv4hint":
+                final String[] v4Addrs = svcParamValue.split(",");
+                os.write(shortsToByteArray((v4Addrs.length * IPV4_ADDR_LEN)));
+                for (String v : v4Addrs) {
+                    os.write(InetAddresses.parseNumericAddress(v).getAddress());
+                }
+                break;
+            case "ech":
+                os.write(shortsToByteArray(svcParamValue.length()));
+                os.write(svcParamValue.getBytes(US_ASCII));  // base64 encoded
+                break;
+            case "ipv6hint":
+                final String[] v6Addrs = svcParamValue.split(",");
+                os.write(shortsToByteArray((v6Addrs.length * IPV6_ADDR_LEN)));
+                for (String v : v6Addrs) {
+                    os.write(InetAddresses.parseNumericAddress(v).getAddress());
+                }
+                break;
+            case "dohpath":
+                os.write(shortsToByteArray(svcParamValue.length()));
+                // TODO: support percent-encoding, since this is a URI template.
+                os.write(svcParamValue.getBytes(US_ASCII));
+                break;
+            default:
+                os.write(shortsToByteArray(svcParamValue.length()));
+                os.write(svcParamValue.getBytes(US_ASCII));
+                break;
+        }
+        return os.toByteArray();
+    }
+
+    @NonNull
+    private static byte[] svcParamKeyToBytes(String key) {
+        switch (key) {
+            case "mandatory": return shortsToByteArray(0);
+            case "alpn": return shortsToByteArray(1);
+            case "no-default-alpn": return shortsToByteArray(2);
+            case "port": return shortsToByteArray(3);
+            case "ipv4hint": return shortsToByteArray(4);
+            case "ech": return shortsToByteArray(5);
+            case "ipv6hint": return shortsToByteArray(6);
+            case "dohpath": return shortsToByteArray(7);
+            default:
+                if (!key.startsWith("key")) fail("Invalid SvcParamKey " + key);
+                return shortsToByteArray(Short.parseShort(key.substring(3)));
+        }
+    }
+
+    @NonNull
+    private static byte[] shortsToByteArray(int... values) {
+        final ByteBuffer out = ByteBuffer.allocate(values.length * 2);
+        for (int value: values) {
+            if (value < 0 || value > 0xffff) {
+                throw new AssertionError("not an unsigned short: " + value);
+            }
+            out.putShort((short) value);
+        }
+        return out.array();
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt b/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
index 1f82a35..e49c0c7 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/FakeDns.kt
@@ -18,72 +18,56 @@
 
 import android.net.DnsResolver
 import android.net.InetAddresses
-import android.os.Looper
+import android.net.Network
 import android.os.Handler
+import android.os.Looper
 import com.android.internal.annotations.GuardedBy
-import java.net.InetAddress
-import java.util.concurrent.Executor
-import org.mockito.invocation.InvocationOnMock
+import com.android.net.module.util.DnsPacket
 import org.mockito.Mockito.any
 import org.mockito.Mockito.anyInt
 import org.mockito.Mockito.doAnswer
+import org.mockito.invocation.InvocationOnMock
+import org.mockito.stubbing.Answer
+import java.net.InetAddress
+import java.net.UnknownHostException
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.Executor
+import java.util.concurrent.TimeUnit
 
-const val TYPE_UNSPECIFIED = -1
-// TODO: Integrate with NetworkMonitorTest.
-class FakeDns(val mockResolver: DnsResolver) {
-    class DnsEntry(val hostname: String, val type: Int, val addresses: List<InetAddress>) {
-        fun match(host: String, type: Int) = hostname.equals(host) && type == type
-    }
+// Nonexistent DNS query type to represent "A and/or AAAA queries".
+// TODO: deduplicate this with DnsUtils.TYPE_ADDRCONFIG.
+private const val TYPE_ADDRCONFIG = -1
 
-    @GuardedBy("answers")
-    val answers = ArrayList<DnsEntry>()
+class FakeDns(val network: Network, val dnsResolver: DnsResolver) {
+    private val HANDLER_TIMEOUT_MS = 1000
 
-    fun getAnswer(hostname: String, type: Int): DnsEntry? = synchronized(answers) {
-        return answers.firstOrNull { it.match(hostname, type) }
-    }
-
-    fun setAnswer(hostname: String, answer: Array<String>, type: Int) = synchronized(answers) {
-        val ans = DnsEntry(hostname, type, generateAnswer(answer))
-        // Replace or remove the existing one.
-        when (val index = answers.indexOfFirst { it.match(hostname, type) }) {
-            -1 -> answers.add(ans)
-            else -> answers[index] = ans
+    /** Data class to record the Dns entry.  */
+    class DnsEntry (val hostname: String, val type: Int, val answerSupplier: AnswerSupplier) {
+        // Full match or partial match that target host contains the entry hostname to support
+        // random private dns probe hostname.
+        fun matches(hostname: String, type: Int): Boolean {
+            return hostname.endsWith(this.hostname) && type == this.type
         }
     }
 
-    private fun generateAnswer(answer: Array<String>) =
-            answer.filterNotNull().map { InetAddresses.parseNumericAddress(it) }
+    /**
+     * Whether queries on [network] will be answered when private DNS is enabled. Queries that
+     * bypass private DNS by using [network.privateDnsBypassingCopy] are always answered.
+     */
+    var nonBypassPrivateDnsWorking: Boolean = true
 
-    fun startMocking() {
-        // Mock DnsResolver.query() w/o type
-        doAnswer {
-            mockAnswer(it, 1, -1, 3, 5)
-        }.`when`(mockResolver).query(any() /* network */, any() /* domain */, anyInt() /* flags */,
-                any() /* executor */, any() /* cancellationSignal */, any() /*callback*/)
-        // Mock DnsResolver.query() w/ type
-        doAnswer {
-            mockAnswer(it, 1, 2, 4, 6)
-        }.`when`(mockResolver).query(any() /* network */, any() /* domain */, anyInt() /* nsType */,
-                anyInt() /* flags */, any() /* executor */, any() /* cancellationSignal */,
-        any() /*callback*/)
+    @GuardedBy("answers")
+    private val answers = mutableListOf<DnsEntry>()
+
+    interface AnswerSupplier {
+        /** Supplies the answer to one DnsResolver query method call.  */
+        @Throws(DnsResolver.DnsException::class)
+        fun get(): Array<String>?
     }
 
-    private fun mockAnswer(
-        it: InvocationOnMock,
-        posHos: Int,
-        posType: Int,
-        posExecutor: Int,
-        posCallback: Int
-    ) {
-        val hostname = it.arguments[posHos] as String
-        val executor = it.arguments[posExecutor] as Executor
-        val callback = it.arguments[posCallback] as DnsResolver.Callback<List<InetAddress>>
-        var type = if (posType != -1) it.arguments[posType] as Int else TYPE_UNSPECIFIED
-        val answer = getAnswer(hostname, type)
-
-        if (answer != null && !answer.addresses.isNullOrEmpty()) {
-            Handler(Looper.getMainLooper()).post({ executor.execute({
-                    callback.onAnswer(answer.addresses, 0); }) })
+    private class InstantAnswerSupplier(val answers: Array<String>?) : AnswerSupplier {
+        override fun get(): Array<String>? {
+            return answers
         }
     }
 
@@ -91,4 +75,177 @@
     fun clearAll() = synchronized(answers) {
         answers.clear()
     }
+
+    /** Returns the answer for a given name and type on the given mock network.  */
+    private fun getAnswer(mockNetwork: Network, hostname: String, type: Int):
+            CompletableFuture<Array<String>?> {
+        if (!checkQueryNetwork(mockNetwork)) {
+            return CompletableFuture.completedFuture(null)
+        }
+        val answerSupplier: AnswerSupplier? = synchronized(answers) {
+            answers.firstOrNull({e: DnsEntry -> e.matches(hostname, type)})?.answerSupplier
+        }
+        if (answerSupplier == null) {
+            return CompletableFuture.completedFuture(null)
+        }
+        if (answerSupplier is InstantAnswerSupplier) {
+            // Save latency waiting for a query thread if the answer is hardcoded.
+            return CompletableFuture.completedFuture<Array<String>?>(answerSupplier.get())
+        }
+        val answerFuture = CompletableFuture<Array<String>?>()
+        // Don't worry about ThreadLeadMonitor: these threads terminate immediately, so they won't
+        // leak, and ThreadLeakMonitor won't monitor them anyway, since they have one-time names
+        // such as "Thread-42".
+        Thread {
+            try {
+                answerFuture.complete(answerSupplier.get())
+            } catch (e: DnsResolver.DnsException) {
+                answerFuture.completeExceptionally(e)
+            }
+        }.start()
+        return answerFuture
+    }
+
+    /** Sets the answer for a given name and type.  */
+    fun setAnswer(hostname: String, answer: Array<String>?, type: Int) = setAnswer(
+            hostname, InstantAnswerSupplier(answer), type)
+
+    /** Sets the answer for a given name and type.  */
+    fun setAnswer(
+            hostname: String, answerSupplier: AnswerSupplier, type: Int) = synchronized (answers) {
+        val ans = DnsEntry(hostname, type, answerSupplier)
+        // Replace or remove the existing one.
+        when (val index = answers.indexOfFirst { it.matches(hostname, type) }) {
+            -1 -> answers.add(ans)
+            else -> answers[index] = ans
+        }
+    }
+
+    private fun checkQueryNetwork(mockNetwork: Network): Boolean {
+        // Queries on the wrong network do not work.
+        // Queries that bypass private DNS work.
+        // Queries that do not bypass private DNS work only if nonBypassPrivateDnsWorking is true.
+        return mockNetwork == network.privateDnsBypassingCopy ||
+                mockNetwork == network && nonBypassPrivateDnsWorking
+    }
+
+    /** Simulates a getAllByName call for the specified name on the specified mock network.  */
+    private fun getAllByName(mockNetwork: Network, hostname: String): Array<InetAddress>? {
+        val answer = stringsToInetAddresses(queryAllTypes(mockNetwork, hostname)
+            .get(HANDLER_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS))
+        if (answer == null || answer.size == 0) {
+            throw UnknownHostException(hostname)
+        }
+        return answer.toTypedArray()
+    }
+
+    // Regardless of the type, depends on what the responses contained in the network.
+    private fun queryAllTypes(
+        mockNetwork: Network, hostname: String
+    ): CompletableFuture<Array<String>?> {
+        val aFuture = getAnswer(mockNetwork, hostname, DnsResolver.TYPE_A)
+                .exceptionally { emptyArray() }
+        val aaaaFuture = getAnswer(mockNetwork, hostname, DnsResolver.TYPE_AAAA)
+                .exceptionally { emptyArray() }
+        val combinedFuture = CompletableFuture<Array<String>?>()
+        aFuture.thenAcceptBoth(aaaaFuture) { res1: Array<String>?, res2: Array<String>? ->
+            var answer: Array<String> = arrayOf()
+            if (res1 != null) answer += res1
+            if (res2 != null) answer += res2
+            combinedFuture.complete(answer)
+        }
+        return combinedFuture
+    }
+
+    /** Starts mocking DNS queries.  */
+    fun startMocking() {
+        // Queries on mNetwork using getAllByName.
+        doAnswer {
+            getAllByName(it.mock as Network, it.getArgument(0))
+        }.`when`(network).getAllByName(any())
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 3, posCallback = 5,
+                posType = -1)
+        }.`when`(dnsResolver).query(any(), any(), anyInt(), any(), any(), any())
+
+        // Queries on mCleartextDnsNetwork using DnsResolver#query with QueryType.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 4, posCallback = 6,
+                posType = 2)
+        }.`when`(dnsResolver).query(any(), any(), anyInt(), anyInt(), any(), any(), any())
+
+        // Queries using rawQuery. Currently, mockQuery only supports TYPE_SVCB.
+        doAnswer {
+            mockQuery(it, posNetwork = 0, posHostname = 1, posExecutor = 5, posCallback = 7,
+                posType = 3)
+        }.`when`(dnsResolver).rawQuery(any(), any(), anyInt(), anyInt(), anyInt(), any(), any(),
+            any())
+    }
+
+    private fun stringsToInetAddresses(addrs: Array<String>?): List<InetAddress>? {
+        if (addrs == null) return null
+        val out: MutableList<InetAddress> = ArrayList()
+        for (addr in addrs) {
+            out.add(InetAddresses.parseNumericAddress(addr))
+        }
+        return out
+    }
+
+    // Mocks all the DnsResolver query methods used in this test.
+    private fun mockQuery(
+        invocation: InvocationOnMock, posNetwork: Int, posHostname: Int,
+        posExecutor: Int, posCallback: Int, posType: Int
+    ): Answer<*>? {
+        val hostname = invocation.getArgument<String>(posHostname)
+        val executor = invocation.getArgument<Executor>(posExecutor)
+        val network = invocation.getArgument<Network>(posNetwork)
+        val qtype = if (posType != -1) invocation.getArgument(posType) else TYPE_ADDRCONFIG
+        val answerFuture: CompletableFuture<Array<String>?> = if (posType != -1) getAnswer(
+            network,
+            hostname,
+            invocation.getArgument(posType)
+        ) else queryAllTypes(network, hostname)
+
+        // Discriminate between different callback types to avoid unchecked cast warnings when
+        // calling the onAnswer methods.
+        val inetAddressCallback: DnsResolver.Callback<List<InetAddress>> =
+            invocation.getArgument(posCallback)
+        val byteArrayCallback: DnsResolver.Callback<ByteArray> =
+            invocation.getArgument(posCallback)
+        val callback: DnsResolver.Callback<*> = invocation.getArgument(posCallback)
+
+        answerFuture.whenComplete { answer: Array<String>?, exception: Throwable? ->
+            // Use getMainLooper() because that's what android.net.DnsResolver currently uses.
+            Handler(Looper.getMainLooper()).post {
+                executor.execute {
+                    if (exception != null) {
+                        if (exception !is DnsResolver.DnsException) {
+                            throw java.lang.AssertionError(
+                                "Test error building DNS response",
+                                exception
+                            )
+                        }
+                        callback.onError((exception as DnsResolver.DnsException?)!!)
+                        return@execute
+                    }
+                    if (answer != null && answer.size > 0) {
+                        when (qtype) {
+                            DnsResolver.TYPE_A, DnsResolver.TYPE_AAAA, TYPE_ADDRCONFIG ->
+                                inetAddressCallback.onAnswer(stringsToInetAddresses(answer)!!, 0)
+                            DnsPacket.TYPE_SVCB ->
+                                byteArrayCallback.onAnswer(
+                                    DnsSvcbUtils.makeSvcbResponse(hostname, answer), 0)
+                            else -> throw UnsupportedOperationException(
+                                "Unsupported qtype $qtype, update this fake"
+                            )
+                        }
+                    }
+                }
+            }
+        }
+        // If the future does not complete or has no answer do nothing. The timeout should fire.
+        return null
+    }
 }
diff --git a/staticlibs/testutils/host/python/apf_utils.py b/staticlibs/testutils/host/python/apf_utils.py
index c3330d2..e84ba3e 100644
--- a/staticlibs/testutils/host/python/apf_utils.py
+++ b/staticlibs/testutils/host/python/apf_utils.py
@@ -83,6 +83,69 @@
   ad.log.debug("Getting apf counters: " + str(result))
   return result
 
+def get_ipv4_addresses(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> list[str]:
+  """Retrieves the IPv4 addresses of a given interface on an Android device.
+
+  This function executes an ADB shell command (`ip -4 address show`) to get the
+  network interface information and extracts the IPv4 address from the output.
+  If devices have no IPv4 address, raise PatternNotFoundException.
+
+  Args:
+      ad: The Android device object.
+      iface_name: The name of the network interface (e.g., "wlan0").
+
+  Returns:
+      The IPv4 addresses of the interface as a list of string.
+      Return empty list if no IPv4 address.
+  """
+  # output format:
+  # 54: wlan2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
+  #     inet 192.168.195.162/24 brd 192.168.195.255 scope global wlan2
+  #         valid_lft forever preferred_lft forever
+  #     inet 192.168.1.1/24 brd 192.168.1.255 scope global wlan2
+  #         valid_lft forever preferred_lft forever
+  output = adb_utils.adb_shell(ad, f"ip -4 address show {iface_name}")
+  pattern = r"inet\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/\d+"
+  matches = re.findall(pattern, output)
+
+  if matches:
+    return matches
+  else:
+    return []
+
+def get_ipv6_addresses(
+    ad: android_device.AndroidDevice, iface_name: str
+) -> list[str]:
+  """Retrieves the IPv6 addresses of a given interface on an Android device.
+
+  This function executes an ADB shell command (`ip -6 address show`) to get the
+  network interface information and extracts the IPv6 address from the output.
+  If devices have no IPv6 address, raise PatternNotFoundException.
+
+  Args:
+      ad: The Android device object.
+      iface_name: The name of the network interface (e.g., "wlan0").
+
+  Returns:
+      The IPv6 addresses of the interface as a list of string.
+      Return empty list if no IPv6 address.
+  """
+  # output format
+  # 54: wlan2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
+  #     inet6 fe80::10a3:5dff:fe52:de32/64 scope link
+  #         valid_lft forever preferred_lft forever
+  #     inet6 fe80::1233:aadb:3d32:1234/64 scope link
+  #         valid_lft forever preferred_lft forever
+  output = adb_utils.adb_shell(ad, f"ip -6 address show {iface_name}")
+  pattern = r"inet6\s+([0-9a-fA-F:]+)\/\d+"
+  matches = re.findall(pattern, output)
+
+  if matches:
+    return matches
+  else:
+    return []
 
 def get_hardware_address(
     ad: android_device.AndroidDevice, iface_name: str
diff --git a/staticlibs/testutils/host/python/packet_utils.py b/staticlibs/testutils/host/python/packet_utils.py
new file mode 100644
index 0000000..b613f03
--- /dev/null
+++ b/staticlibs/testutils/host/python/packet_utils.py
@@ -0,0 +1,70 @@
+#  Copyright (C) 2024 The Android Open Source Project
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+from ipaddress import IPv4Address
+from socket import inet_aton
+
+ETHER_BROADCAST_MAC_ADDRESS = "FF:FF:FF:FF:FF:FF"
+ARP_REQUEST_OP = 1
+ARP_REPLY_OP = 2
+
+"""
+This variable defines a template for constructing ARP packets in hexadecimal format.
+It's used to provide the common fields for ARP packet, and replaced needed fields when constructing
+"""
+ARP_TEMPLATE = (
+    # Ether Header (14 bytes)
+    "{dst_mac}" + # DA
+    "{src_mac}" + # SA
+    "0806" + # ARP
+    # ARP Header (28 bytes)
+    "0001" + # Hardware type (Ethernet)
+    "0800" + # Protocol type (IPv4)
+    "06" + # hardware address length
+    "04" + # protocol address length
+    "{opcode}" + # opcode
+    "{sender_mac}" + # sender MAC
+    "{sender_ip}" + # sender IP
+    "{target_mac}" + # target MAC
+    "{target_ip}" # target IP
+)
+
+def construct_arp_packet(src_mac, dst_mac, src_ip, dst_ip, op) -> str:
+    """Constructs an ARP packet as a hexadecimal string.
+
+    This function creates an ARP packet by filling in the required fields
+    in a predefined ARP packet template.
+
+    Args:
+    src_mac: The MAC address of the sender. (e.g. "11:22:33:44:55:66")
+    dst_mac: The MAC address of the recipient. (e.g. "aa:bb:cc:dd:ee:ff")
+    src_ip: The IP address of the sender. (e.g. "1.1.1.1")
+    dst_ip: The IP address of the target machine. (e.g. "2.2.2.2")
+    op: The op code of the ARP packet, refer to ARP_*_OP
+
+    Returns:
+    A string representing the ARP packet in hexadecimal format.
+    """
+    # Replace the needed fields from packet template
+    arp_pkt = ARP_TEMPLATE.format(
+            dst_mac=dst_mac.replace(":",""),
+            src_mac=src_mac.replace(":",""),
+            opcode=str(op).rjust(4, "0"),
+            sender_mac=src_mac.replace(":",""),
+            sender_ip=inet_aton(src_ip).hex(),
+            target_mac=("000000000000" if op == ARP_REQUEST_OP else dst_mac.replace(":", "")),
+            target_ip=inet_aton(dst_ip).hex()
+    )
+
+    # always convert to upper case hex string
+    return arp_pkt.upper()
\ No newline at end of file
diff --git a/tests/cts/hostside/Android.bp b/tests/cts/hostside/Android.bp
index 14d5d54..97be91a 100644
--- a/tests/cts/hostside/Android.bp
+++ b/tests/cts/hostside/Android.bp
@@ -12,17 +12,27 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-next_app_data = [":CtsHostsideNetworkTestsAppNext"]
-
-// The above line is put in place to prevent any future automerger merge conflict between aosp,
-// downstream branches. The CtsHostsideNetworkTestsAppNext target will not exist in
-// some downstream branches, but it should exist in aosp and some downstream branches.
-
 package {
     default_team: "trendy_team_fwk_core_networking",
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+java_defaults {
+    name: "CtsHostsideNetworkTestsAllAppDefaults",
+    platform_apis: true,
+    min_sdk_version: "30",
+    // Set target SDK to 10000 so that all the test helper apps are always subject to the most
+    // recent (and possibly most restrictive) target SDK behaviour. Also, this matches the target
+    // SDK of the tests themselves, and of other tests such as CtsNetTestCases.
+    // Note that some of the test helper apps (e.g., CtsHostsideNetworkCapTestsAppSdk33) override
+    // this with older SDK versions.
+    // Also note that unlike android_test targets, "current" does not work: the target SDK is set to
+    // something like "VanillaIceCream" instead of 100000. This means that the tests will not run on
+    // released devices with errors such as "Requires development platform VanillaIceCream but this
+    // is a release platform".
+    target_sdk_version: "10000",
+}
+
 java_test_host {
     name: "CtsHostsideNetworkTests",
     defaults: ["cts_defaults"],
@@ -52,6 +62,6 @@
         ":CtsHostsideNetworkCapTestsAppWithoutProperty",
         ":CtsHostsideNetworkCapTestsAppWithProperty",
         ":CtsHostsideNetworkCapTestsAppSdk33",
-    ] + next_app_data,
+    ],
     per_testcase_directory: true,
 }
diff --git a/tests/cts/hostside/app/Android.bp b/tests/cts/hostside/app/Android.bp
index 2ca9adb..7fff1c2 100644
--- a/tests/cts/hostside/app/Android.bp
+++ b/tests/cts/hostside/app/Android.bp
@@ -19,9 +19,13 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-java_defaults {
-    name: "CtsHostsideNetworkTestsAppDefaults",
-    platform_apis: true,
+android_test_helper_app {
+    name: "CtsHostsideNetworkTestsApp",
+    defaults: [
+        "cts_support_defaults",
+        "framework-connectivity-test-defaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
+    ],
     static_libs: [
         "CtsHostsideNetworkTestsAidl",
         "androidx.test.ext.junit",
@@ -39,35 +43,4 @@
     srcs: [
         "src/**/*.java",
     ],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "general-tests",
-        "sts",
-    ],
-    min_sdk_version: "30",
-}
-
-android_test_helper_app {
-    name: "CtsHostsideNetworkTestsApp",
-    defaults: [
-        "cts_support_defaults",
-        "framework-connectivity-test-defaults",
-        "CtsHostsideNetworkTestsAppDefaults",
-    ],
-    static_libs: [
-        "NetworkStackApiStableShims",
-    ],
-}
-
-android_test_helper_app {
-    name: "CtsHostsideNetworkTestsAppNext",
-    defaults: [
-        "cts_support_defaults",
-        "framework-connectivity-test-defaults",
-        "CtsHostsideNetworkTestsAppDefaults",
-        "ConnectivityNextEnableDefaults",
-    ],
-    static_libs: [
-        "NetworkStackApiCurrentShims",
-    ],
 }
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
index fe522a0..a39a8d0 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/MyVpnService.java
@@ -29,9 +29,6 @@
 import android.util.Pair;
 
 import com.android.modules.utils.build.SdkLevel;
-import com.android.networkstack.apishim.VpnServiceBuilderShimImpl;
-import com.android.networkstack.apishim.common.UnsupportedApiLevelException;
-import com.android.networkstack.apishim.common.VpnServiceBuilderShim;
 import com.android.testutils.PacketReflector;
 
 import java.io.IOException;
@@ -102,8 +99,7 @@
     }
 
     private void start(String packageName, Intent intent) {
-        Builder builder = new Builder();
-        VpnServiceBuilderShim vpnServiceBuilderShim = VpnServiceBuilderShimImpl.newInstance();
+        VpnService.Builder builder = new VpnService.Builder();
 
         final String addresses = parseIpAndMaskListArgument(packageName, intent, "addresses",
                 builder::addAddress);
@@ -112,11 +108,7 @@
         if (SdkLevel.isAtLeastT() && intent.getBooleanExtra(packageName + ".addRoutesByIpPrefix",
                 false)) {
             addedRoutes = parseIpPrefixListArgument(packageName, intent, "routes", (prefix) -> {
-                try {
-                    vpnServiceBuilderShim.addRoute(builder, prefix);
-                } catch (UnsupportedApiLevelException e) {
-                    throw new RuntimeException(e);
-                }
+                builder.addRoute(prefix);
             });
         } else {
             addedRoutes = parseIpAndMaskListArgument(packageName, intent, "routes",
@@ -127,11 +119,7 @@
         if (SdkLevel.isAtLeastT()) {
             excludedRoutes = parseIpPrefixListArgument(packageName, intent, "excludedRoutes",
                     (prefix) -> {
-                        try {
-                            vpnServiceBuilderShim.excludeRoute(builder, prefix);
-                        } catch (UnsupportedApiLevelException e) {
-                            throw new RuntimeException(e);
-                        }
+                        builder.excludeRoute(prefix);
                     });
         }
 
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
index d7631eb..d05a8d0 100755
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/VpnTest.java
@@ -20,8 +20,11 @@
 import static android.Manifest.permission.NETWORK_SETTINGS;
 import static android.Manifest.permission.READ_DEVICE_CONFIG;
 import static android.Manifest.permission.WRITE_DEVICE_CONFIG;
+import static android.content.Context.RECEIVER_EXPORTED;
 import static android.content.pm.PackageManager.FEATURE_TELEPHONY;
 import static android.content.pm.PackageManager.FEATURE_WIFI;
+import static android.net.ConnectivityManager.BLOCKED_REASON_LOCKDOWN_VPN;
+import static android.net.ConnectivityManager.BLOCKED_REASON_NONE;
 import static android.net.ConnectivityManager.FIREWALL_CHAIN_BACKGROUND;
 import static android.net.ConnectivityManager.TYPE_VPN;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
@@ -46,9 +49,6 @@
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_RESUMED;
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_STARTED;
 import static com.android.cts.net.hostside.VpnTest.TestSocketKeepaliveCallback.CallbackType.ON_STOPPED;
-import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_LOCKDOWN_VPN;
-import static com.android.networkstack.apishim.ConstantsShim.BLOCKED_REASON_NONE;
-import static com.android.networkstack.apishim.ConstantsShim.RECEIVER_EXPORTED;
 import static com.android.testutils.Cleanup.testAndCleanup;
 import static com.android.testutils.RecorderCallback.CallbackEntry.BLOCKED_STATUS_INT;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
diff --git a/tests/cts/hostside/app2/Android.bp b/tests/cts/hostside/app2/Android.bp
index cb55c7b..05abcdd 100644
--- a/tests/cts/hostside/app2/Android.bp
+++ b/tests/cts/hostside/app2/Android.bp
@@ -21,20 +21,14 @@
 
 android_test_helper_app {
     name: "CtsHostsideNetworkTestsApp2",
-    defaults: ["cts_support_defaults"],
-    platform_apis: true,
+    defaults: [
+        "cts_support_defaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
+    ],
     static_libs: [
         "androidx.annotation_annotation",
         "CtsHostsideNetworkTestsAidl",
-        "NetworkStackApiStableShims",
     ],
     srcs: ["src/**/*.java"],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "cts",
-        "general-tests",
-        "sts",
-    ],
     sdk_version: "test_current",
-    min_sdk_version: "30",
 }
diff --git a/tests/cts/hostside/networkslicingtestapp/Android.bp b/tests/cts/hostside/networkslicingtestapp/Android.bp
index 79ad2e2..0eed51c 100644
--- a/tests/cts/hostside/networkslicingtestapp/Android.bp
+++ b/tests/cts/hostside/networkslicingtestapp/Android.bp
@@ -21,7 +21,6 @@
 
 java_defaults {
     name: "CtsHostsideNetworkCapTestsAppDefaults",
-    platform_apis: true,
     static_libs: [
         "androidx.test.ext.junit",
         "androidx.test.rules",
@@ -29,13 +28,6 @@
         "cts-net-utils",
     ],
     srcs: ["src/**/*.java"],
-    // Tag this module as a cts test artifact
-    test_suites: [
-        "cts",
-        "general-tests",
-        "sts",
-    ],
-    min_sdk_version: "30",
 }
 
 android_test_helper_app {
@@ -43,6 +35,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     manifest: "AndroidManifestWithoutProperty.xml",
     sdk_version: "test_current",
@@ -53,6 +46,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     manifest: "AndroidManifestWithProperty.xml",
     sdk_version: "test_current",
@@ -63,6 +57,7 @@
     defaults: [
         "cts_support_defaults",
         "CtsHostsideNetworkCapTestsAppDefaults",
+        "CtsHostsideNetworkTestsAllAppDefaults",
     ],
     target_sdk_version: "33",
     manifest: "AndroidManifestWithoutProperty.xml",
diff --git a/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java b/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
index 69d61b3..e222ff6 100644
--- a/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
+++ b/tests/cts/hostside/src/com/android/cts/net/HostsideNetworkTestCase.java
@@ -19,7 +19,6 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 
-import com.android.modules.utils.build.testing.DeviceSdkLevel;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.invoker.TestInformation;
 import com.android.tradefed.targetprep.BuildError;
@@ -36,17 +35,13 @@
 abstract class HostsideNetworkTestCase extends BaseHostJUnit4Test {
     protected static final String TEST_PKG = "com.android.cts.net.hostside";
     protected static final String TEST_APK = "CtsHostsideNetworkTestsApp.apk";
-    protected static final String TEST_APK_NEXT = "CtsHostsideNetworkTestsAppNext.apk";
     protected static final String TEST_APP2_PKG = "com.android.cts.net.hostside.app2";
     protected static final String TEST_APP2_APK = "CtsHostsideNetworkTestsApp2.apk";
 
     @BeforeClassWithInfo
     public static void setUpOnceBase(TestInformation testInfo) throws Exception {
-        DeviceSdkLevel deviceSdkLevel = new DeviceSdkLevel(testInfo.getDevice());
-        String testApk = deviceSdkLevel.isDeviceAtLeastV() ? TEST_APK_NEXT : TEST_APK;
-
         uninstallPackage(testInfo, TEST_PKG, false);
-        installPackage(testInfo, testApk);
+        installPackage(testInfo, TEST_APK);
     }
 
     @AfterClassWithInfo
diff --git a/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java b/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
index fa68e3e..ae572e6 100644
--- a/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
+++ b/tests/cts/hostside/src/com/android/cts/net/ProcNetTest.java
@@ -166,6 +166,8 @@
      */
     @Test
     public void testRouterSolicitations() throws Exception {
+        assumeTrue(new DeviceSdkLevel(mDevice).isDeviceAtLeastU());
+
         for (String interfaceDir : mSysctlDirs) {
             String path = IPV6_SYSCTL_DIR + "/" + interfaceDir + "/" + "router_solicitations";
             int value = readIntFromPath(path);
@@ -186,8 +188,7 @@
      */
     @Test
     public void testCongestionControl() throws Exception {
-        final DeviceSdkLevel dsl = new DeviceSdkLevel(mDevice);
-        assumeTrue(dsl.isDeviceAtLeastV());
+        assumeTrue(new DeviceSdkLevel(mDevice).isDeviceAtLeastV());
 
         String path = "/proc/sys/net/ipv4/tcp_congestion_control";
         String value = mDevice.executeAdbCommand("shell", "cat", path).trim();
diff --git a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
index f6a025a..cb55bd5 100644
--- a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
+++ b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
@@ -184,6 +184,8 @@
 
     // Static state to reduce setup/teardown
     private static final Context sContext = InstrumentationRegistry.getContext();
+    private static boolean sIsWatch =
+                sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH);
     private static final ConnectivityManager sCM =
             (ConnectivityManager) sContext.getSystemService(Context.CONNECTIVITY_SERVICE);
     private static final VpnManager sVpnMgr =
@@ -205,12 +207,15 @@
 
     @Before
     public void setUp() {
-        assumeFalse("Skipping test because watches don't support VPN",
-            sContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_WATCH));
+        assumeFalse("Skipping test because watches don't support VPN", sIsWatch);
     }
 
     @After
     public void tearDown() {
+        if (sIsWatch) {
+            return; // Tests are skipped for watches.
+        }
+
         for (TestableNetworkCallback callback : mCallbacksToUnregister) {
             sCM.unregisterNetworkCallback(callback);
         }
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 d801fba..ab2fb99 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsDiscoveryManagerTests.java
@@ -26,6 +26,7 @@
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -499,7 +500,7 @@
         verify(executorProvider).shutdownExecutorService(mockExecutorService);
         verify(mockServiceTypeClientType1Network1).stopSendAndReceive(mockListenerOne);
         verify(socketClient).stopDiscovery();
-        verify(mockServiceCache).removeServices(cacheKey);
+        verify(mockServiceCache, timeout(DEFAULT_TIMEOUT)).removeServices(cacheKey);
     }
 
     @Test
@@ -523,7 +524,7 @@
         runOnHandler(() -> callback.onSocketDestroyed(SOCKET_KEY_NETWORK_1));
         verify(mockServiceTypeClientType1Network1).notifySocketDestroyed();
         verify(executorProvider).shutdownExecutorService(mockExecutorService);
-        verify(mockServiceCache).removeServices(cacheKey);
+        verify(mockServiceCache, timeout(DEFAULT_TIMEOUT)).removeServices(cacheKey);
     }
 
     private MdnsPacket createMdnsPacket(String serviceType) {
diff --git a/thread/docs/build-an-android-border-router.md b/thread/docs/build-an-android-border-router.md
index 257999b..f90a23b 100644
--- a/thread/docs/build-an-android-border-router.md
+++ b/thread/docs/build-an-android-border-router.md
@@ -169,7 +169,7 @@
     user thread_network
 ```
 
-For real RCP devices, it supports both SPI and UART interace and you can
+For real RCP devices, it supports both SPI and UART interfaces and you can
 specify the device with the schema `spinel+spi://`, `spinel+hdlc+uart://` and
 `spinel+socket://` respectively.
 
diff --git a/thread/framework/java/android/net/thread/IOutputReceiver.aidl b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
new file mode 100644
index 0000000..b6b4375
--- /dev/null
+++ b/thread/framework/java/android/net/thread/IOutputReceiver.aidl
@@ -0,0 +1,24 @@
+/*
+ * 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;
+
+/** Receives the output of a Thread network operation. @hide */
+oneway interface IOutputReceiver {
+    void onOutput(in String output);
+    void onComplete();
+    void onError(int errorCode, String errorMessage);
+}
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 9d0a571..57c365b 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -23,4 +23,6 @@
     void onDeviceRoleChanged(int deviceRole);
     void onPartitionIdChanged(long partitionId);
     void onThreadEnableStateChanged(int enabledState);
+    void onEphemeralKeyStateChanged(
+            int ephemeralKeyState, @nullable String ephemeralKey, long expiryMillis);
 }
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index b7f68c9..e9cbb83 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -53,4 +53,7 @@
     void setConfiguration(in ThreadConfiguration config, in IOperationReceiver receiver);
     void registerConfigurationCallback(in IConfigurationReceiver receiver);
     void unregisterConfigurationCallback(in IConfigurationReceiver receiver);
+
+    void activateEphemeralKeyMode(long lifetimeMillis, in IOperationReceiver receiver);
+    void deactivateEphemeralKeyMode(in IOperationReceiver receiver);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index ecaefd0..1222398 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -40,6 +40,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
@@ -82,6 +83,25 @@
     /** The Thread radio is being disabled. */
     public static final int STATE_DISABLING = 2;
 
+    /** The ephemeral key mode is disabled. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_DISABLED = 0;
+
+    /**
+     * The ephemeral key mode is enabled, an external commissioner candidate can use the ephemeral
+     * key to connect to this device and get Thread credential shared.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_ENABLED = 1;
+
+    /**
+     * The ephemeral key is in use. This state means there is already an active secure session
+     * connected to this device with the ephemeral key, it's not possible to use the ephemeral key
+     * for new connections in this state.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_IN_USE = 2;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({
@@ -100,6 +120,13 @@
             value = {STATE_DISABLED, STATE_ENABLED, STATE_DISABLING})
     public @interface EnabledState {}
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            prefix = {"EPHEMERAL_KEY_"},
+            value = {EPHEMERAL_KEY_DISABLED, EPHEMERAL_KEY_ENABLED, EPHEMERAL_KEY_IN_USE})
+    public @interface EphemeralKeyState {}
+
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
@@ -110,6 +137,9 @@
     @SuppressLint("MinMaxConstant")
     public static final int MAX_POWER_CHANNEL_DISABLED = Integer.MIN_VALUE;
 
+    /** The maximum lifetime of an ephemeral key. @hide */
+    @NonNull private static final Duration EPHEMERAL_KEY_LIFETIME_MAX = Duration.ofMinutes(10);
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({THREAD_VERSION_1_3})
@@ -174,6 +204,87 @@
         }
     }
 
+    /** Returns the maximum lifetime allowed when activating ephemeral key mode. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @NonNull
+    public Duration getMaxEphemeralKeyLifetime() {
+        return EPHEMERAL_KEY_LIFETIME_MAX;
+    }
+
+    /**
+     * Activates ephemeral key mode with a given {@code lifetime}. The ephemeral key is a temporary,
+     * single-use numeric code that is used for Thread Administration Sharing. After activation, the
+     * mode may expire or get deactivated, caller to this method should subscribe to the ephemeral
+     * key state updates with {@link #registerStateCallback} to get notified when the ephemeral key
+     * state changes.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The ephemeral
+     * key string contains a sequence of numeric digits 0-9 of user-input friendly length (typically
+     * 9). Subscribers to ephemeral key state updates with {@link #registerStateCallback} will be
+     * notified with a call to {@link #onEphemeralKeyStateChanged}.
+     *
+     * <p>On failure, {@link OutcomeReceiver#onError} of {@code receiver} will be invoked with a
+     * specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not
+     *       attached to Thread network
+     *   <li>{@link ThreadNetworkException#ERROR_BUSY} when ephemeral key mode is already activated
+     *       on the device, caller can recover from this error when the ephemeral key mode gets
+     *       deactivated
+     * </ul>
+     *
+     * @param lifetime valid lifetime of the generated ephemeral key, should be larger than {@link
+     *     Duration#ZERO} and at most the duration returned by {@link #getMaxEphemeralKeyLifetime}.
+     * @param executor the executor on which to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     * @throws IllegalArgumentException if the {@code lifetime} exceeds the allowed range
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void activateEphemeralKeyMode(
+            @NonNull Duration lifetime,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        if (lifetime.compareTo(Duration.ZERO) <= 0
+                || lifetime.compareTo(EPHEMERAL_KEY_LIFETIME_MAX) > 0) {
+            throw new IllegalArgumentException(
+                    "Invalid ephemeral key lifetime: the value must be in range of (0, "
+                            + EPHEMERAL_KEY_LIFETIME_MAX
+                            + "]");
+        }
+        long lifetimeMillis = lifetime.toMillis();
+        try {
+            mControllerService.activateEphemeralKeyMode(
+                    lifetimeMillis, new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Deactivates ephemeral key mode. If there is an active connection with the ephemeral key, the
+     * connection will be terminated.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The call will
+     * always succeed if the device is not in ephemeral key mode.
+     *
+     * @param executor the executor to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void deactivateEphemeralKeyMode(
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        try {
+            mControllerService.deactivateEphemeralKeyMode(
+                    new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
     /** Returns the Thread version this device is operating on. */
     @ThreadVersion
     public int getThreadVersion() {
@@ -248,6 +359,24 @@
          * @param enabledState the new Thread enabled state
          */
         default void onThreadEnableStateChanged(@EnabledState int enabledState) {}
+
+        /**
+         * The ephemeral key state has changed.
+         *
+         * @param ephemeralKeyState the ephemeral key state
+         * @param ephemeralKey the ephemeral key string which contains a sequence of numeric digits
+         *     0-9 of user-input friendly length (typically 9), or {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED} or the caller doesn't have the
+         *     permission {@link android.permission.THREAD_NETWORK_PRIVILEGED}
+         * @param expiry a timestamp of when the ephemeral key will expireor {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED}
+         */
+        @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+        @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+        default void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState,
+                @Nullable String ephemeralKey,
+                @Nullable Instant expiry) {}
     }
 
     private static final class StateCallbackProxy extends IStateCallback.Stub {
@@ -288,13 +417,37 @@
                 Binder.restoreCallingIdentity(identity);
             }
         }
+
+        @Override
+        public void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState, String ephemeralKey, long expiryMillis) {
+            if (!Flags.epskcEnabled()) {
+                throw new IllegalStateException(
+                        "This should not be called when Ephemeral key API is disabled");
+            }
+
+            final long identity = Binder.clearCallingIdentity();
+            final Instant expiry =
+                    ephemeralKeyState == EPHEMERAL_KEY_DISABLED
+                            ? null
+                            : Instant.ofEpochMilli(expiryMillis);
+
+            try {
+                mExecutor.execute(
+                        () ->
+                                mCallback.onEphemeralKeyStateChanged(
+                                        ephemeralKeyState, ephemeralKey, expiry));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
     }
 
     /**
      * Registers a callback to be called when Thread network states are changed.
      *
-     * <p>Upon return of this method, methods of {@code callback} will be invoked immediately with
-     * existing states.
+     * <p>Upon return of this method, all methods of {@code callback} will be invoked immediately
+     * with existing states. The order of the invoked callbacks is not guaranteed.
      *
      * @param executor the executor to execute the {@code callback}
      * @param callback the callback to receive Thread network state changes
@@ -706,9 +859,9 @@
     /**
      * Sets max power of each channel.
      *
-     * <p>This method sets the max power for the given channel. The platform sets the actual
-     * output power to be less than or equal to the {@code channelMaxPowers} and as close as
-     * possible to the {@code channelMaxPowers}.
+     * <p>This method sets the max power for the given channel. The platform sets the actual output
+     * power to be less than or equal to the {@code channelMaxPowers} and as close as possible to
+     * the {@code channelMaxPowers}.
      *
      * <p>If not set, the default max power is set by the Thread HAL service or the Thread radio
      * chip firmware.
@@ -726,13 +879,13 @@
      *     and corresponding max power. Valid channel values should be between {@link
      *     ActiveOperationalDataset#CHANNEL_MIN_24_GHZ} and {@link
      *     ActiveOperationalDataset#CHANNEL_MAX_24_GHZ}. The unit of the max power is 0.01dBm. For
-     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of
-     *     {@code channelMaxPowers} is lower than the minimum output power supported by the
-     *     platform, the output power will be set to the minimum output power supported by the
-     *     platform. If the power value of {@code channelMaxPowers} is higher than the maximum
-     *     output power supported by the platform, the output power will be set to the maximum
-     *     output power supported by the platform. If the power value of {@code channelMaxPowers}
-     *     is set to {@link #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
+     *     example, 1000 means 0.01W and 2000 means 0.1W. If the power value of {@code
+     *     channelMaxPowers} is lower than the minimum output power supported by the platform, the
+     *     output power will be set to the minimum output power supported by the platform. If the
+     *     power value of {@code channelMaxPowers} is higher than the maximum output power supported
+     *     by the platform, the output power will be set to the maximum output power supported by
+     *     the platform. If the power value of {@code channelMaxPowers} is set to {@link
+     *     #MAX_POWER_CHANNEL_DISABLED}, the corresponding channel is disabled.
      * @param executor the executor to execute {@code receiver}.
      * @param receiver the receiver to receive the result of this operation.
      * @throws IllegalArgumentException if the size of {@code channelMaxPowers} is smaller than 1,
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index bca8b6e..b863bc2 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -81,6 +81,19 @@
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
     /**
+     * Permission allows accessing Thread network state and performing certain testing-related
+     * operations.
+     *
+     * <p>This is the same value as android.Manifest.permission.THREAD_NETWORK_TESTING. That symbol
+     * is not available on U while this feature needs to support Android U TV devices, so here is
+     * making a copy of android.Manifest.permission.THREAD_NETWORK_TESTING.
+     *
+     * @hide
+     */
+    public static final String PERMISSION_THREAD_NETWORK_TESTING =
+            "android.permission.THREAD_NETWORK_TESTING";
+
+    /**
      * This user restriction specifies if Thread network is disallowed on the device. If Thread
      * network is disallowed it cannot be turned on via Settings.
      *
diff --git a/thread/service/java/com/android/server/thread/NsdPublisher.java b/thread/service/java/com/android/server/thread/NsdPublisher.java
index 8d89e13..9697c02 100644
--- a/thread/service/java/com/android/server/thread/NsdPublisher.java
+++ b/thread/service/java/com/android/server/thread/NsdPublisher.java
@@ -23,6 +23,7 @@
 import android.content.Context;
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -30,6 +31,7 @@
 import android.os.CancellationSignal;
 import android.os.Handler;
 import android.os.RemoteException;
+import android.system.Os;
 import android.text.TextUtils;
 import android.util.SparseArray;
 
@@ -66,6 +68,7 @@
 
     // TODO: b/321883491 - specify network for mDNS operations
     @Nullable private Network mNetwork;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final NsdManager mNsdManager;
     private final DnsResolver mDnsResolver;
     private final Handler mHandler;
@@ -76,17 +79,28 @@
     private final SparseArray<HostInfoListener> mHostInfoListeners = new SparseArray<>(0);
 
     @VisibleForTesting
-    public NsdPublisher(NsdManager nsdManager, DnsResolver dnsResolver, Handler handler) {
+    public NsdPublisher(
+            NsdManager nsdManager,
+            DnsResolver dnsResolver,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mNetwork = null;
         mNsdManager = nsdManager;
         mDnsResolver = dnsResolver;
         mHandler = handler;
         mExecutor = runnable -> mHandler.post(runnable);
+        mNetworkToLinkProperties = networkToLinkProperties;
     }
 
-    public static NsdPublisher newInstance(Context context, Handler handler) {
+    public static NsdPublisher newInstance(
+            Context context,
+            Handler handler,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         return new NsdPublisher(
-                context.getSystemService(NsdManager.class), DnsResolver.getInstance(), handler);
+                context.getSystemService(NsdManager.class),
+                DnsResolver.getInstance(),
+                handler,
+                networkToLinkProperties);
     }
 
     // TODO: b/321883491 - NsdPublisher should be disabled when mNetwork is null
@@ -586,6 +600,14 @@
                             + ", serviceInfo: "
                             + serviceInfo);
             List<String> addresses = new ArrayList<>();
+            int interfaceIndex = 0;
+            if (mNetworkToLinkProperties.containsKey(serviceInfo.getNetwork())) {
+                interfaceIndex =
+                        Os.if_nametoindex(
+                                mNetworkToLinkProperties
+                                        .get(serviceInfo.getNetwork())
+                                        .getInterfaceName());
+            }
             for (InetAddress address : serviceInfo.getHostAddresses()) {
                 if (address instanceof Inet6Address) {
                     addresses.add(address.getHostAddress());
@@ -602,6 +624,7 @@
             try {
                 mResolveServiceCallback.onServiceResolved(
                         serviceInfo.getHostname(),
+                        interfaceIndex,
                         serviceInfo.getServiceName(),
                         serviceInfo.getServiceType(),
                         serviceInfo.getPort(),
diff --git a/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
new file mode 100644
index 0000000..aa9a05d
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/OutputReceiverWrapper.java
@@ -0,0 +1,120 @@
+/*
+ * 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.thread;
+
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
+import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
+
+import android.net.thread.IOutputReceiver;
+import android.net.thread.ThreadNetworkException;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/** A {@link IOutputReceiver} wrapper which makes it easier to invoke the callbacks. */
+final class OutputReceiverWrapper {
+    private final IOutputReceiver mReceiver;
+    private final boolean mExpectOtDaemonDied;
+
+    private static final Object sPendingReceiversLock = new Object();
+
+    @GuardedBy("sPendingReceiversLock")
+    private static final Set<OutputReceiverWrapper> sPendingReceivers = new HashSet<>();
+
+    public OutputReceiverWrapper(IOutputReceiver receiver) {
+        this(receiver, false /* expectOtDaemonDied */);
+    }
+
+    /**
+     * Creates a new {@link OutputReceiverWrapper}.
+     *
+     * <p>If {@code expectOtDaemonDied} is {@code true}, it's expected that ot-daemon becomes dead
+     * before {@code receiver} is completed with {@code onComplete} and {@code onError} and {@code
+     * receiver#onComplete} will be invoked in this case.
+     */
+    public OutputReceiverWrapper(IOutputReceiver receiver, boolean expectOtDaemonDied) {
+        mReceiver = receiver;
+        mExpectOtDaemonDied = expectOtDaemonDied;
+
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.add(this);
+        }
+    }
+
+    public static void onOtDaemonDied() {
+        synchronized (sPendingReceiversLock) {
+            for (OutputReceiverWrapper receiver : sPendingReceivers) {
+                try {
+                    if (receiver.mExpectOtDaemonDied) {
+                        receiver.mReceiver.onComplete();
+                    } else {
+                        receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                    }
+                } catch (RemoteException e) {
+                    // The client is dead, do nothing
+                }
+            }
+            sPendingReceivers.clear();
+        }
+    }
+
+    public void onOutput(String output) {
+        try {
+            mReceiver.onOutput(output);
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onComplete() {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onComplete();
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onError(Throwable e) {
+        if (e instanceof ThreadNetworkException) {
+            ThreadNetworkException threadException = (ThreadNetworkException) e;
+            onError(threadException.getErrorCode(), threadException.getMessage());
+        } else if (e instanceof RemoteException) {
+            onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        } else {
+            throw new AssertionError(e);
+        }
+    }
+
+    public void onError(int errorCode, String errorMessage, Object... messageArgs) {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onError(errorCode, String.format(errorMessage, messageArgs));
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 362ca7e..3d854d7 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -15,6 +15,7 @@
 package com.android.server.thread;
 
 import static android.Manifest.permission.NETWORK_SETTINGS;
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
 import static android.net.MulticastRoutingConfig.CONFIG_FORWARD_NONE;
 import static android.net.MulticastRoutingConfig.FORWARD_SELECTED;
 import static android.net.MulticastRoutingConfig.FORWARD_WITH_MIN_SCOPE;
@@ -26,6 +27,7 @@
 import static android.net.thread.ActiveOperationalDataset.MESH_LOCAL_PREFIX_FIRST_BYTE;
 import static android.net.thread.ActiveOperationalDataset.SecurityPolicy.DEFAULT_ROTATION_TIME_HOURS;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_DETACHED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
@@ -43,6 +45,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
@@ -94,6 +97,7 @@
 import android.net.thread.IConfigurationReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.IOperationalDatasetCallback;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.IStateCallback;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.OperationalDatasetTimestamp;
@@ -124,6 +128,7 @@
 import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
 import com.android.server.thread.openthread.IOtDaemonCallback;
+import com.android.server.thread.openthread.IOtOutputReceiver;
 import com.android.server.thread.openthread.IOtStatusReceiver;
 import com.android.server.thread.openthread.InfraLinkState;
 import com.android.server.thread.openthread.Ipv6AddressInfo;
@@ -209,7 +214,7 @@
     private NetworkRequest mUpstreamNetworkRequest;
     private UpstreamNetworkCallback mUpstreamNetworkCallback;
     private TestNetworkSpecifier mUpstreamTestNetworkSpecifier;
-    private final HashMap<Network, String> mNetworkToInterface;
+    private final Map<Network, LinkProperties> mNetworkToLinkProperties;
     private final ThreadPersistentSettings mPersistentSettings;
     private final UserManager mUserManager;
     private boolean mUserRestricted;
@@ -231,7 +236,8 @@
             NsdPublisher nsdPublisher,
             UserManager userManager,
             ConnectivityResources resources,
-            Supplier<String> countryCodeSupplier) {
+            Supplier<String> countryCodeSupplier,
+            Map<Network, LinkProperties> networkToLinkProperties) {
         mContext = context;
         mHandler = handler;
         mNetworkProvider = networkProvider;
@@ -240,7 +246,9 @@
         mTunIfController = tunIfController;
         mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
-        mNetworkToInterface = new HashMap<Network, String>();
+        // TODO: networkToLinkProperties should be shared with NsdPublisher, add a test/assert to
+        // verify they are the same.
+        mNetworkToLinkProperties = networkToLinkProperties;
         mOtDaemonConfig = new OtDaemonConfiguration.Builder().build();
         mInfraLinkState = new InfraLinkState.Builder().build();
         mPersistentSettings = persistentSettings;
@@ -259,6 +267,7 @@
         Handler handler = new Handler(handlerThread.getLooper());
         NetworkProvider networkProvider =
                 new NetworkProvider(context, handlerThread.getLooper(), "ThreadNetworkProvider");
+        Map<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
 
         return new ThreadNetworkControllerService(
                 context,
@@ -269,10 +278,11 @@
                 new TunInterfaceController(TUN_IF_NAME),
                 new InfraInterfaceController(),
                 persistentSettings,
-                NsdPublisher.newInstance(context, handler),
+                NsdPublisher.newInstance(context, handler, networkToLinkProperties),
                 context.getSystemService(UserManager.class),
                 new ConnectivityResources(context),
-                countryCodeSupplier);
+                countryCodeSupplier,
+                networkToLinkProperties);
     }
 
     private NetworkRequest newUpstreamNetworkRequest() {
@@ -426,6 +436,7 @@
         LOG.w("OT daemon is dead, clean up...");
 
         OperationReceiverWrapper.onOtDaemonDied();
+        OutputReceiverWrapper.onOtDaemonDied();
         mOtDaemonCallbackProxy.onOtDaemonDied();
         mTunIfController.onOtDaemonDied();
         mNsdPublisher.onOtDaemonDied();
@@ -690,7 +701,7 @@
         if (mUpstreamNetworkCallback == null) {
             throw new AssertionError("The upstream network request null.");
         }
-        mNetworkToInterface.clear();
+        mNetworkToLinkProperties.clear();
         mConnectivityManager.unregisterNetworkCallback(mUpstreamNetworkCallback);
         mUpstreamNetworkCallback = null;
     }
@@ -712,20 +723,19 @@
 
         @Override
         public void onLinkPropertiesChanged(
-                @NonNull Network network, @NonNull LinkProperties linkProperties) {
+                @NonNull Network network, @NonNull LinkProperties newLinkProperties) {
             checkOnHandlerThread();
 
-            String existingIfName = mNetworkToInterface.get(network);
-            String newIfName = linkProperties.getInterfaceName();
-            if (Objects.equals(existingIfName, newIfName)) {
+            LinkProperties oldLinkProperties = mNetworkToLinkProperties.get(network);
+            if (Objects.equals(oldLinkProperties, newLinkProperties)) {
                 return;
             }
-            LOG.i("Upstream network changed: " + existingIfName + " -> " + newIfName);
-            mNetworkToInterface.put(network, newIfName);
+            LOG.i("Upstream network changed: " + oldLinkProperties + " -> " + newLinkProperties);
+            mNetworkToLinkProperties.put(network, newLinkProperties);
 
             // TODO: disable border routing if netIfName is null
             if (network.equals(mUpstreamNetwork)) {
-                enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
+                setInfraLinkState(newInfraLinkStateBuilder(newLinkProperties).build());
             }
         }
     }
@@ -741,7 +751,7 @@
         public void onLost(@NonNull Network network) {
             checkOnHandlerThread();
             LOG.i("Thread network is lost: " + network);
-            disableBorderRouting();
+            setInfraLinkState(newInfraLinkStateBuilder().build());
         }
 
         @Override
@@ -755,13 +765,15 @@
                             + localNetworkInfo
                             + "}");
             if (localNetworkInfo.getUpstreamNetwork() == null) {
-                disableBorderRouting();
+                setInfraLinkState(newInfraLinkStateBuilder().build());
                 return;
             }
             if (!localNetworkInfo.getUpstreamNetwork().equals(mUpstreamNetwork)) {
                 mUpstreamNetwork = localNetworkInfo.getUpstreamNetwork();
-                if (mNetworkToInterface.containsKey(mUpstreamNetwork)) {
-                    enableBorderRouting(mNetworkToInterface.get(mUpstreamNetwork));
+                if (mNetworkToLinkProperties.containsKey(mUpstreamNetwork)) {
+                    setInfraLinkState(
+                            newInfraLinkStateBuilder(mNetworkToLinkProperties.get(mUpstreamNetwork))
+                                    .build());
                 }
                 mNsdPublisher.setNetworkForHostResolution(mUpstreamNetwork);
             }
@@ -845,6 +857,47 @@
     }
 
     @Override
+    public void activateEphemeralKeyMode(long lifetimeMillis, IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () ->
+                        activateEphemeralKeyModeInternal(
+                                lifetimeMillis, new OperationReceiverWrapper(receiver)));
+    }
+
+    private void activateEphemeralKeyModeInternal(
+            long lifetimeMillis, OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().activateEphemeralKeyMode(lifetimeMillis, newOtStatusReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.activateEphemeralKeyMode failed", e);
+            receiver.onError(e);
+        }
+    }
+
+    @Override
+    public void deactivateEphemeralKeyMode(IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        mHandler.post(
+                () -> deactivateEphemeralKeyModeInternal(new OperationReceiverWrapper(receiver)));
+    }
+
+    private void deactivateEphemeralKeyModeInternal(OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().deactivateEphemeralKeyMode(newOtStatusReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.deactivateEphemeralKeyMode failed", e);
+            receiver.onError(e);
+        }
+    }
+
+    @Override
     public void createRandomizedDataset(
             String networkName, IActiveOperationalDatasetReceiver receiver) {
         ActiveOperationalDatasetReceiverWrapper receiverWrapper =
@@ -993,7 +1046,14 @@
     @Override
     public void registerStateCallback(IStateCallback stateCallback) throws RemoteException {
         enforceAllPermissionsGranted(permission.ACCESS_NETWORK_STATE);
-        mHandler.post(() -> mOtDaemonCallbackProxy.registerStateCallback(stateCallback));
+        boolean hasThreadPrivilegedPermission =
+                (mContext.checkCallingOrSelfPermission(PERMISSION_THREAD_NETWORK_PRIVILEGED)
+                        == PERMISSION_GRANTED);
+
+        mHandler.post(
+                () ->
+                        mOtDaemonCallbackProxy.registerStateCallback(
+                                stateCallback, hasThreadPrivilegedPermission));
     }
 
     @Override
@@ -1042,6 +1102,25 @@
         };
     }
 
+    private IOtOutputReceiver newOtOutputReceiver(OutputReceiverWrapper receiver) {
+        return new IOtOutputReceiver.Stub() {
+            @Override
+            public void onOutput(String output) {
+                receiver.onOutput(output);
+            }
+
+            @Override
+            public void onComplete() {
+                receiver.onComplete();
+            }
+
+            @Override
+            public void onError(int otError, String message) {
+                receiver.onError(otErrorToAndroidError(otError), message);
+            }
+        };
+    }
+
     @ErrorCode
     private static int otErrorToAndroidError(int otError) {
         // See external/openthread/include/openthread/error.h for OT error definition
@@ -1228,45 +1307,51 @@
         }
     }
 
-    private void setInfraLinkState(InfraLinkState infraLinkState) {
-        if (mInfraLinkState.equals(infraLinkState)) {
+    private void setInfraLinkState(InfraLinkState newInfraLinkState) {
+        if (mInfraLinkState.equals(newInfraLinkState)) {
             return;
         }
-        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + infraLinkState);
-        mInfraLinkState = infraLinkState;
+        LOG.i("Infra link state changed: " + mInfraLinkState + " -> " + newInfraLinkState);
+
+        setInfraLinkInterfaceName(newInfraLinkState.interfaceName);
+        setInfraLinkNat64Prefix(newInfraLinkState.nat64Prefix);
+        mInfraLinkState = newInfraLinkState;
+    }
+
+    private void setInfraLinkInterfaceName(String newInfraLinkInterfaceName) {
+        if (Objects.equals(mInfraLinkState.interfaceName, newInfraLinkInterfaceName)) {
+            return;
+        }
         ParcelFileDescriptor infraIcmp6Socket = null;
-        if (mInfraLinkState.interfaceName != null) {
+        if (newInfraLinkInterfaceName != null) {
             try {
-                infraIcmp6Socket =
-                        mInfraIfController.createIcmp6Socket(mInfraLinkState.interfaceName);
+                infraIcmp6Socket = mInfraIfController.createIcmp6Socket(newInfraLinkInterfaceName);
             } catch (IOException e) {
                 LOG.e("Failed to create ICMPv6 socket on infra network interface", e);
             }
         }
         try {
             getOtDaemon()
-                    .setInfraLinkState(
-                            mInfraLinkState,
+                    .setInfraLinkInterfaceName(
+                            newInfraLinkInterfaceName,
                             infraIcmp6Socket,
-                            new LoggingOtStatusReceiver("setInfraLinkState"));
+                            new LoggingOtStatusReceiver("setInfraLinkInterfaceName"));
         } catch (RemoteException | ThreadNetworkException e) {
-            LOG.e("Failed to configure border router " + mOtDaemonConfig, e);
+            LOG.e("Failed to set infra link interface name " + newInfraLinkInterfaceName, e);
         }
     }
 
-    private void enableBorderRouting(String infraIfName) {
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(infraIfName).build();
-        LOG.i("Enable border routing on AIL: " + infraIfName);
-        setInfraLinkState(infraLinkState);
-    }
-
-    private void disableBorderRouting() {
-        mUpstreamNetwork = null;
-        InfraLinkState infraLinkState =
-                newInfraLinkStateBuilder(mInfraLinkState).setInterfaceName(null).build();
-        LOG.i("Disabling border routing");
-        setInfraLinkState(infraLinkState);
+    private void setInfraLinkNat64Prefix(@Nullable String newNat64Prefix) {
+        if (Objects.equals(mInfraLinkState.nat64Prefix, newNat64Prefix)) {
+            return;
+        }
+        try {
+            getOtDaemon()
+                    .setInfraLinkNat64Prefix(
+                            newNat64Prefix, new LoggingOtStatusReceiver("setInfraLinkNat64Prefix"));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("Failed to set infra link NAT64 prefix " + newNat64Prefix, e);
+        }
     }
 
     private void handleThreadInterfaceStateChanged(boolean isUp) {
@@ -1318,6 +1403,31 @@
         }
     }
 
+    @RequiresPermission(
+            allOf = {PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING})
+    public void runOtCtlCommand(
+            @NonNull String command, boolean isInteractive, @NonNull IOutputReceiver receiver) {
+        enforceAllPermissionsGranted(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED, PERMISSION_THREAD_NETWORK_TESTING);
+
+        mHandler.post(
+                () ->
+                        runOtCtlCommandInternal(
+                                command, isInteractive, new OutputReceiverWrapper(receiver)));
+    }
+
+    private void runOtCtlCommandInternal(
+            String command, boolean isInteractive, @NonNull OutputReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().runOtCtlCommand(command, isInteractive, newOtOutputReceiver(receiver));
+        } catch (RemoteException | ThreadNetworkException e) {
+            LOG.e("otDaemon.runOtCtlCommand failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
     private void sendLocalNetworkConfig() {
         if (mNetworkAgent == null) {
             return;
@@ -1372,8 +1482,22 @@
         return new OtDaemonConfiguration.Builder();
     }
 
-    private static InfraLinkState.Builder newInfraLinkStateBuilder(InfraLinkState infraLinkState) {
-        return new InfraLinkState.Builder().setInterfaceName(infraLinkState.interfaceName);
+    private static InfraLinkState.Builder newInfraLinkStateBuilder() {
+        return new InfraLinkState.Builder().setInterfaceName("");
+    }
+
+    private static InfraLinkState.Builder newInfraLinkStateBuilder(
+            @Nullable LinkProperties linkProperties) {
+        if (linkProperties == null) {
+            return newInfraLinkStateBuilder();
+        }
+        String nat64Prefix = null;
+        if (linkProperties.getNat64Prefix() != null) {
+            nat64Prefix = linkProperties.getNat64Prefix().toString();
+        }
+        return new InfraLinkState.Builder()
+                .setInterfaceName(linkProperties.getInterfaceName())
+                .setNat64Prefix(nat64Prefix);
     }
 
     private static final class CallbackMetadata {
@@ -1384,9 +1508,13 @@
 
         final IBinder.DeathRecipient deathRecipient;
 
-        CallbackMetadata(IBinder.DeathRecipient deathRecipient) {
+        final boolean hasThreadPrivilegedPermission;
+
+        CallbackMetadata(
+                IBinder.DeathRecipient deathRecipient, boolean hasThreadPrivilegedPermission) {
             this.id = allocId();
             this.deathRecipient = deathRecipient;
+            this.hasThreadPrivilegedPermission = hasThreadPrivilegedPermission;
         }
 
         private static long allocId() {
@@ -1428,7 +1556,8 @@
         private ActiveOperationalDataset mActiveDataset;
         private PendingOperationalDataset mPendingDataset;
 
-        public void registerStateCallback(IStateCallback callback) {
+        public void registerStateCallback(
+                IStateCallback callback, boolean hasThreadPrivilegedPermission) {
             checkOnHandlerThread();
             if (mStateCallbacks.containsKey(callback)) {
                 throw new IllegalStateException("Registering the same IStateCallback twice");
@@ -1436,7 +1565,8 @@
 
             IBinder.DeathRecipient deathRecipient =
                     () -> mHandler.post(() -> unregisterStateCallback(callback));
-            CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient);
+            CallbackMetadata callbackMetadata =
+                    new CallbackMetadata(deathRecipient, hasThreadPrivilegedPermission);
             mStateCallbacks.put(callback, callbackMetadata);
             try {
                 callback.asBinder().linkToDeath(deathRecipient, 0);
@@ -1469,7 +1599,8 @@
 
             IBinder.DeathRecipient deathRecipient =
                     () -> mHandler.post(() -> unregisterDatasetCallback(callback));
-            CallbackMetadata callbackMetadata = new CallbackMetadata(deathRecipient);
+            CallbackMetadata callbackMetadata =
+                    new CallbackMetadata(deathRecipient, true /* hasThreadPrivilegedPermission */);
             mOpDatasetCallbacks.put(callback, callbackMetadata);
             try {
                 callback.asBinder().linkToDeath(deathRecipient, 0);
@@ -1548,16 +1679,18 @@
         }
 
         @Override
-        public void onStateChanged(OtDaemonState newState, long listenerId) {
+        public void onStateChanged(@NonNull OtDaemonState newState, long listenerId) {
             mHandler.post(() -> onStateChangedInternal(newState, listenerId));
         }
 
         private void onStateChangedInternal(OtDaemonState newState, long listenerId) {
             checkOnHandlerThread();
+
             onInterfaceStateChanged(newState.isInterfaceUp);
             onDeviceRoleChanged(newState.deviceRole, listenerId);
             onPartitionIdChanged(newState.partitionId, listenerId);
             onThreadEnabledChanged(newState.threadEnabled, listenerId);
+            onEphemeralKeyStateChanged(newState, listenerId);
             mState = newState;
 
             ActiveOperationalDataset newActiveDataset;
@@ -1633,6 +1766,43 @@
             }
         }
 
+        private void onEphemeralKeyStateChanged(OtDaemonState newState, long listenerId) {
+            checkOnHandlerThread();
+            boolean hasChange = isEphemeralKeyStateChanged(mState, newState);
+
+            for (var callbackEntry : mStateCallbacks.entrySet()) {
+                if (!hasChange && callbackEntry.getValue().id != listenerId) {
+                    continue;
+                }
+                String passcode =
+                        callbackEntry.getValue().hasThreadPrivilegedPermission
+                                ? newState.ephemeralKeyPasscode
+                                : null;
+                if (newState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) {
+                    passcode = null;
+                }
+                try {
+                    callbackEntry
+                            .getKey()
+                            .onEphemeralKeyStateChanged(
+                                    newState.ephemeralKeyState,
+                                    passcode,
+                                    newState.ephemeralKeyExpiryMillis);
+                } catch (RemoteException ignored) {
+                    // do nothing if the client is dead
+                }
+            }
+        }
+
+        private static boolean isEphemeralKeyStateChanged(
+                OtDaemonState oldState, @NonNull OtDaemonState newState) {
+            if (oldState == null) return true;
+            if (oldState.ephemeralKeyState != newState.ephemeralKeyState) return true;
+            if (oldState.ephemeralKeyState == EPHEMERAL_KEY_DISABLED) return false;
+            return (!Objects.equals(oldState.ephemeralKeyPasscode, newState.ephemeralKeyPasscode)
+                    || oldState.ephemeralKeyExpiryMillis != newState.ephemeralKeyExpiryMillis);
+        }
+
         private void onActiveOperationalDatasetChanged(
                 ActiveOperationalDataset activeDataset, long listenerId) {
             checkOnHandlerThread();
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
index 54155ee..1eddebf 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -20,9 +20,12 @@
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.OperationalDatasetTimestamp;
 import android.net.thread.PendingOperationalDataset;
 import android.net.thread.ThreadNetworkException;
+import android.os.Binder;
+import android.os.Process;
 import android.text.TextUtils;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -52,6 +55,7 @@
     private static final Duration LEAVE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration MIGRATE_TIMEOUT = Duration.ofSeconds(2);
     private static final Duration FORCE_STOP_TIMEOUT = Duration.ofSeconds(1);
+    private static final Duration OT_CTL_COMMAND_TIMEOUT = Duration.ofSeconds(5);
     private static final String PERMISSION_THREAD_NETWORK_TESTING =
             "android.permission.THREAD_NETWORK_TESTING";
 
@@ -62,7 +66,8 @@
     @Nullable private PrintWriter mOutputWriter;
     @Nullable private PrintWriter mErrorWriter;
 
-    public ThreadNetworkShellCommand(
+    @VisibleForTesting
+    ThreadNetworkShellCommand(
             Context context,
             ThreadNetworkControllerService controllerService,
             ThreadNetworkCountryCode countryCode) {
@@ -77,6 +82,10 @@
         mErrorWriter = errorWriter;
     }
 
+    private static boolean isRootProcess() {
+        return Binder.getCallingUid() == Process.ROOT_UID;
+    }
+
     private PrintWriter getOutputWriter() {
         return (mOutputWriter != null) ? mOutputWriter : getOutPrintWriter();
     }
@@ -107,6 +116,8 @@
         pw.println("    Gets country code as a two-letter string");
         pw.println("  force-country-code enabled <two-letter code> | disabled ");
         pw.println("    Sets country code to <two-letter code> or left for normal value");
+        pw.println("  ot-ctl <subcommand>");
+        pw.println("    Runs ot-ctl command");
     }
 
     @Override
@@ -133,6 +144,8 @@
                 return forceCountryCode();
             case "get-country-code":
                 return getCountryCode();
+            case "ot-ctl":
+                return handleOtCtlCommand();
             default:
                 return handleDefaultCommands(cmd);
         }
@@ -248,6 +261,50 @@
         return 0;
     }
 
+    private static final class OutputReceiver extends IOutputReceiver.Stub {
+        private final CompletableFuture<Void> future;
+        private final PrintWriter outputWriter;
+
+        public OutputReceiver(CompletableFuture<Void> future, PrintWriter outputWriter) {
+            this.future = future;
+            this.outputWriter = outputWriter;
+        }
+
+        @Override
+        public void onOutput(String output) {
+            outputWriter.print(output);
+            outputWriter.flush();
+        }
+
+        @Override
+        public void onComplete() {
+            future.complete(null);
+        }
+
+        @Override
+        public void onError(int errorCode, String errorMessage) {
+            future.completeExceptionally(new ThreadNetworkException(errorCode, errorMessage));
+        }
+    }
+
+    private int handleOtCtlCommand() {
+        ensureTestingPermission();
+
+        if (!isRootProcess()) {
+            getErrorWriter().println("No access to ot-ctl command");
+            return -1;
+        }
+
+        final String subCommand = String.join(" ", peekRemainingArgs());
+
+        CompletableFuture<Void> completeFuture = new CompletableFuture<>();
+        mControllerService.runOtCtlCommand(
+                subCommand,
+                false /* isInteractive */,
+                new OutputReceiver(completeFuture, getOutputWriter()));
+        return waitForFuture(completeFuture, OT_CTL_COMMAND_TIMEOUT, getErrorWriter());
+    }
+
     private static IOperationReceiver newOperationReceiver(CompletableFuture<Void> future) {
         return new IOperationReceiver.Stub() {
             @Override
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 34aabe2..e954d3b 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -56,4 +56,14 @@
         <!-- Ignores tests introduced by guava-android-testlib -->
         <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
+
+    <!--
+        This doesn't override a read-only flag, to run the tests locally with `epskc_enabled` flag
+        enabled, set the flag to `is_fixed_read_only: false`. This should be removed after the
+        `epskc_enabled` flag is rolled out.
+    -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/com.android.net.thread.flags.epskc_enabled=true"/>
+    </target_preparer>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index c048394..1792bfb 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -27,11 +27,14 @@
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_ENABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
 import static android.net.thread.ThreadNetworkException.ERROR_ABORTED;
+import static android.net.thread.ThreadNetworkException.ERROR_BUSY;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
@@ -72,6 +75,8 @@
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.util.SparseIntArray;
 
 import androidx.annotation.NonNull;
@@ -82,6 +87,8 @@
 import com.android.net.thread.flags.Flags;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
+import kotlin.Triple;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
@@ -96,6 +103,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -134,9 +142,13 @@
                     put(VALID_CHANNEL, VALID_POWER);
                 }
             };
+    private static final Duration EPHEMERAL_KEY_LIFETIME = Duration.ofSeconds(1);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
     private ThreadNetworkController mController;
@@ -164,6 +176,7 @@
 
         setEnabledAndWait(mController, true);
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @After
@@ -183,6 +196,7 @@
             }
         }
         mConfigurationCallbacksToCleanUp.clear();
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @Test
@@ -819,6 +833,221 @@
         listener.unregisterStateCallback();
     }
 
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void getMaxEphemeralKeyLifetime_isLargerThanZero() {
+        assertThat(mController.getMaxEphemeralKeyLifetime()).isGreaterThan(Duration.ZERO);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(Duration.ZERO, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(lifetime, Runnable::run, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
+            throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> future1 = new CompletableFuture<>();
+        CompletableFuture<Void> future2 = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future1));
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future2));
+                });
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> {
+                            future2.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+                        });
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_BUSY);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () -> mController.deactivateEphemeralKeyMode(mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                THREAD_NETWORK_PRIVILEGED,
+                () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_DISABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(expiryFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
+            throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+        joinRandomizedDatasetAndWait(mController);
+        activateEphemeralKeyModeAndWait(mController);
+
+        runAsShell(
+                ACCESS_NETWORK_STATE, () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_ENABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(
+                            expiryFuture
+                                    .get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)
+                                    .isAfter(Instant.now()))
+                    .isTrue();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+        EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
+        joinRandomizedDatasetAndWait(mController);
+
+        try {
+            activateEphemeralKeyModeAndWait(mController);
+            deactivateEphemeralKeyModeAndWait(mController);
+
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+        } finally {
+            listener.unregisterStateCallback();
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+        EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
+        Triple<Integer, String, Instant> epskc1;
+        try {
+            joinRandomizedDatasetAndWait(mController);
+            activateEphemeralKeyModeAndWait(mController);
+            epskc1 = listener1.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+        } finally {
+            listener1.unregisterStateCallback();
+        }
+
+        EphemeralKeyStateListener listener2 = new EphemeralKeyStateListener(mController);
+        try {
+            Triple<Integer, String, Instant> epskc2 =
+                    listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+
+            assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
+            assertThat(epskc2.getThird()).isEqualTo(epskc1.getThird());
+        } finally {
+            listener2.unregisterStateCallback();
+        }
+    }
+
     // TODO (b/322437869): add test case to verify when Thread is in DISABLING state, any commands
     // (join/leave/scheduleMigration/setEnabled) fail with ERROR_BUSY. This is not currently tested
     // because DISABLING has very short lifecycle, it's not possible to guarantee the command can be
@@ -1274,6 +1503,71 @@
         setFuture.get(SET_CONFIGURATION_TIMEOUT_MILLIS, MILLISECONDS);
     }
 
+    private void deactivateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> clearFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.deactivateEphemeralKeyMode(
+                                mExecutor, newOutcomeReceiver(clearFuture)));
+        clearFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private void activateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private class EphemeralKeyStateListener {
+        private ArrayTrackRecord<Triple<Integer, String, Instant>> mEphemeralKeyStates =
+                new ArrayTrackRecord<>();
+        private final ArrayTrackRecord<Triple<Integer, String, Instant>>.ReadHead mReadHead =
+                mEphemeralKeyStates.newReadHead();
+        ThreadNetworkController mController;
+        StateCallback mCallback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        mEphemeralKeyStates.add(new Triple<>(state, ephemeralKey, expiry));
+                    }
+                };
+
+        EphemeralKeyStateListener(ThreadNetworkController controller) {
+            this.mController = controller;
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    THREAD_NETWORK_PRIVILEGED,
+                    () -> controller.registerStateCallback(mExecutor, mCallback));
+        }
+
+        // Expect that EphemeralKey has the expected state, and return a Triple of <state,
+        // passcode, expiry>.
+        public Triple<Integer, String, Instant> expectThreadEphemeralKeyMode(int state) {
+            Triple<Integer, String, Instant> epskc =
+                    mReadHead.poll(
+                            ENABLED_TIMEOUT_MILLIS, e -> Objects.equals(e.getFirst(), state));
+            assertThat(epskc).isNotNull();
+            return epskc;
+        }
+
+        public void unregisterStateCallback() {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+        }
+    }
+
     private CompletableFuture joinRandomizedDataset(
             ThreadNetworkController controller, String networkName) throws Exception {
         ActiveOperationalDataset activeDataset = newRandomizedDataset(networkName, controller);
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 103282a..4a8462d8 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -46,8 +46,11 @@
 import static java.util.Objects.requireNonNull;
 
 import android.content.Context;
+import android.net.InetAddresses;
 import android.net.IpPrefix;
 import android.net.LinkAddress;
+import android.net.LinkProperties;
+import android.net.RouteInfo;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
 import android.net.thread.utils.IntegrationTestUtils;
@@ -99,6 +102,11 @@
     private static final Inet4Address IPV4_SERVER_ADDR =
             (Inet4Address) parseNumericAddress("8.8.8.8");
     private static final String NAT64_CIDR = "192.168.255.0/24";
+    private static final IpPrefix DHCP6_PD_PREFIX = new IpPrefix("2001:db8::/64");
+    private static final IpPrefix AIL_NAT64_PREFIX = new IpPrefix("2001:db8:1234::/96");
+    private static final Inet6Address AIL_NAT64_SYNTHESIZED_SERVER_ADDR =
+            (Inet6Address) parseNumericAddress("2001:db8:1234::8.8.8.8");
+    private static final Duration UPDATE_NAT64_PREFIX_TIMEOUT = Duration.ofSeconds(10);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
@@ -623,13 +631,50 @@
         // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
         mOtCtl.setNat64Cidr(NAT64_CIDR);
         mOtCtl.setNat64Enabled(true);
-        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), Duration.ofSeconds(10));
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
 
         ftd.ping(IPV4_SERVER_ADDR);
 
         assertNotNull(pollForIcmpPacketOnInfraNetwork(ICMP_ECHO, null, IPV4_SERVER_ADDR));
     }
 
+    @Test
+    public void nat64_withAilNat64Prefix_threadDevicePingIpv4InfraDevice_outboundPacketIsForwarded()
+            throws Exception {
+        tearDownInfraNetwork();
+        LinkProperties lp = new LinkProperties();
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("0.0.0.0/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.addRoute(
+                new RouteInfo(
+                        new IpPrefix("::/0") /* destination */,
+                        null /* gateway */,
+                        null /* iface */,
+                        RouteInfo.RTN_UNICAST,
+                        1500 /* mtu */));
+        lp.setNat64Prefix(AIL_NAT64_PREFIX);
+        mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController, lp);
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        FullThreadDevice ftd = mFtds.get(0);
+        joinNetworkAndWaitForOmr(ftd, DEFAULT_DATASET);
+        // TODO: enable NAT64 via ThreadNetworkController API instead of ot-ctl
+        mOtCtl.setNat64Enabled(true);
+        mOtCtl.addPrefixInNetworkData(DHCP6_PD_PREFIX, "paros", "med");
+        waitFor(() -> mOtCtl.hasNat64PrefixInNetdata(), UPDATE_NAT64_PREFIX_TIMEOUT);
+
+        ftd.ping(IPV4_SERVER_ADDR);
+
+        assertNotNull(
+                pollForIcmpPacketOnInfraNetwork(
+                        ICMPV6_ECHO_REQUEST_TYPE, null, AIL_NAT64_SYNTHESIZED_SERVER_ADDR));
+    }
+
     private void setUpInfraNetwork() throws Exception {
         mInfraNetworkTracker = IntegrationTestUtils.setUpInfraNetwork(mContext, mController);
     }
diff --git a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
index 8835f40..87219d3 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadNetworkShellCommandTest.java
@@ -19,14 +19,18 @@
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
+import static android.net.thread.utils.IntegrationTestUtils.DEFAULT_DATASET;
 
 import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 
 import static com.google.common.truth.Truth.assertThat;
 
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 
 import android.content.Context;
+import android.net.thread.utils.FullThreadDevice;
+import android.net.thread.utils.OtDaemonController;
 import android.net.thread.utils.ThreadFeatureCheckerRule;
 import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.net.thread.utils.ThreadNetworkControllerWrapper;
@@ -41,6 +45,9 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.net.Inet6Address;
+import java.time.Duration;
+import java.util.List;
 import java.util.concurrent.ExecutionException;
 
 /** Integration tests for {@link ThreadNetworkShellCommand}. */
@@ -53,14 +60,24 @@
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private final ThreadNetworkControllerWrapper mController =
             ThreadNetworkControllerWrapper.newInstance(mContext);
+    private final OtDaemonController mOtCtl = new OtDaemonController();
+    private FullThreadDevice mFtd;
 
     @Before
-    public void setUp() {
+    public void setUp() throws Exception {
+        // TODO(b/366141754): The current implementation of "thread_network ot-ctl factoryreset"
+        // results in timeout error.
+        // A future fix will provide proper support for factoryreset, allowing us to replace the
+        // legacy "ot-ctl".
+        mOtCtl.factoryReset();
+
+        mFtd = new FullThreadDevice(10 /* nodeId */);
         ensureThreadEnabled();
     }
 
     @After
-    public void tearDown() {
+    public void tearDown() throws Exception {
+        mFtd.destroy();
         ensureThreadEnabled();
     }
 
@@ -69,6 +86,13 @@
         runThreadCommand("enable");
     }
 
+    private static void startFtdChild(FullThreadDevice ftd, ActiveOperationalDataset activeDataset)
+            throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(activeDataset);
+        ftd.waitForStateAnyOf(List.of("router", "child"), Duration.ofSeconds(8));
+    }
+
     @Test
     public void enable_threadStateIsEnabled() throws Exception {
         runThreadCommand("enable");
@@ -123,6 +147,38 @@
         assertThat(result).contains("Thread country code = CN");
     }
 
+    @Test
+    public void handleOtCtlCommand_enableIfconfig_getIfconfigReturnsUP() {
+        runThreadCommand("ot-ctl ifconfig up");
+
+        final String result = runThreadCommand("ot-ctl ifconfig");
+
+        assertThat(result).isEqualTo("up\r\nDone\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_disableIfconfig_startThreadFailsWithInvalidState() {
+        runThreadCommand("ot-ctl ifconfig down");
+
+        final String result = runThreadCommand("ot-ctl thread start");
+
+        assertThat(result).isEqualTo("Error 13: InvalidState\r\n");
+    }
+
+    @Test
+    public void handleOtCtlCommand_pingFtd_getValidResponse() throws Exception {
+        mController.joinAndWait(DEFAULT_DATASET);
+        startFtdChild(mFtd, DEFAULT_DATASET);
+        final Inet6Address ftdMlEid = mFtd.getMlEid();
+        assertNotNull(ftdMlEid);
+
+        final String result = runThreadCommand("ot-ctl ping " + ftdMlEid.getHostAddress());
+
+        assertThat(result).contains("1 packets transmitted, 1 packets received");
+        assertThat(result).contains("Packet loss = 0.0%");
+        assertThat(result).endsWith("Done\r\n");
+    }
+
     private static String runThreadCommand(String cmd) {
         return runShellCommandOrThrow("cmd thread_network " + cmd);
     }
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
index fa9855e..3df74b0 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.kt
@@ -551,6 +551,22 @@
         )
     }
 
+    private fun defaultLinkProperties(): LinkProperties {
+        val lp = LinkProperties()
+        // TODO: use a fake DNS server
+        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
+        // NAT64 feature requires the infra network to have an IPv4 default route.
+        lp.addRoute(
+            RouteInfo(
+                IpPrefix("0.0.0.0/0") /* destination */,
+                null /* gateway */,
+                null /* iface */,
+                RouteInfo.RTN_UNICAST, 1500 /* mtu */
+            )
+        )
+        return lp
+    }
+
     @JvmStatic
     @JvmOverloads
     fun startInfraDeviceAndWaitForOnLinkAddr(
@@ -564,23 +580,13 @@
     }
 
     @JvmStatic
+    @JvmOverloads
     @Throws(java.lang.Exception::class)
     fun setUpInfraNetwork(
-        context: Context, controller: ThreadNetworkControllerWrapper
+        context: Context,
+        controller: ThreadNetworkControllerWrapper,
+        lp: LinkProperties = defaultLinkProperties()
     ): TestNetworkTracker {
-        val lp = LinkProperties()
-
-        // TODO: use a fake DNS server
-        lp.setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
-        // NAT64 feature requires the infra network to have an IPv4 default route.
-        lp.addRoute(
-            RouteInfo(
-                IpPrefix("0.0.0.0/0") /* destination */,
-                null /* gateway */,
-                null /* iface */,
-                RouteInfo.RTN_UNICAST, 1500 /* mtu */
-            )
-        )
         val infraNetworkTracker: TestNetworkTracker =
             runAsShell(
                 MANAGE_TEST_NETWORKS,
diff --git a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
index 15a3f5c..046d9bf 100644
--- a/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
+++ b/thread/tests/integration/src/android/net/thread/utils/OtDaemonController.java
@@ -128,6 +128,12 @@
         return false;
     }
 
+    /** Adds a prefix in the Network Data. */
+    public void addPrefixInNetworkData(IpPrefix ipPrefix, String flags, String preference) {
+        executeCommand("prefix add " + ipPrefix + " " + flags + " " + preference);
+        executeCommand("netdata register");
+    }
+
     public String executeCommand(String cmd) {
         return SystemUtil.runShellCommand(OT_CTL + " " + cmd);
     }
diff --git a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
index 0423578..62801bf 100644
--- a/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
+++ b/thread/tests/unit/src/android/net/thread/ThreadNetworkControllerTest.java
@@ -17,6 +17,8 @@
 package android.net.thread;
 
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_CHILD;
+import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
+import static android.net.thread.ThreadNetworkException.ERROR_INTERNAL_ERROR;
 import static android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_FEATURE;
@@ -26,6 +28,7 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 
@@ -134,6 +137,16 @@
         return (IOperationalDatasetCallback) invocation.getArguments()[0];
     }
 
+    private static IOperationReceiver getActivateEphemeralKeyModeReceiver(
+            InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[1];
+    }
+
+    private static IOperationReceiver getDeactivateEphemeralKeyModeReceiver(
+            InvocationOnMock invocation) {
+        return (IOperationReceiver) invocation.getArguments()[0];
+    }
+
     @Test
     public void registerStateCallback_callbackIsInvokedWithCallingAppIdentity() throws Exception {
         setBinderUid(SYSTEM_UID);
@@ -440,4 +453,88 @@
         assertThat(callbackUid.get()).isNotEqualTo(SYSTEM_UID);
         assertThat(callbackUid.get()).isEqualTo(Process.myUid());
     }
+
+    @Test
+    public void activateEphemeralKeyMode_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        Duration lifetime = Duration.ofSeconds(100);
+        doAnswer(
+                        invoke -> {
+                            getActivateEphemeralKeyModeReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .activateEphemeralKeyMode(anyLong(), any(IOperationReceiver.class));
+        mController.activateEphemeralKeyMode(
+                lifetime, Runnable::run, v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getActivateEphemeralKeyModeReceiver(invoke)
+                                    .onError(ERROR_FAILED_PRECONDITION, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .activateEphemeralKeyMode(anyLong(), any(IOperationReceiver.class));
+        mController.activateEphemeralKeyMode(
+                lifetime,
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
+
+    @Test
+    public void deactivateEphemeralKeyMode_callbackIsInvokedWithCallingAppIdentity()
+            throws Exception {
+        setBinderUid(SYSTEM_UID);
+        AtomicInteger successCallbackUid = new AtomicInteger(0);
+        AtomicInteger errorCallbackUid = new AtomicInteger(0);
+        doAnswer(
+                        invoke -> {
+                            getDeactivateEphemeralKeyModeReceiver(invoke).onSuccess();
+                            return null;
+                        })
+                .when(mMockService)
+                .deactivateEphemeralKeyMode(any(IOperationReceiver.class));
+        mController.deactivateEphemeralKeyMode(
+                Runnable::run, v -> successCallbackUid.set(Binder.getCallingUid()));
+        doAnswer(
+                        invoke -> {
+                            getDeactivateEphemeralKeyModeReceiver(invoke)
+                                    .onError(ERROR_INTERNAL_ERROR, "");
+                            return null;
+                        })
+                .when(mMockService)
+                .deactivateEphemeralKeyMode(any(IOperationReceiver.class));
+        mController.deactivateEphemeralKeyMode(
+                Runnable::run,
+                new OutcomeReceiver<>() {
+                    @Override
+                    public void onResult(Void unused) {}
+
+                    @Override
+                    public void onError(ThreadNetworkException e) {
+                        errorCallbackUid.set(Binder.getCallingUid());
+                    }
+                });
+
+        assertThat(successCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(successCallbackUid.get()).isEqualTo(Process.myUid());
+        assertThat(errorCallbackUid.get()).isNotEqualTo(SYSTEM_UID);
+        assertThat(errorCallbackUid.get()).isEqualTo(Process.myUid());
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
index b32986d..d52191a 100644
--- a/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/NsdPublisherTest.java
@@ -34,6 +34,7 @@
 
 import android.net.DnsResolver;
 import android.net.InetAddresses;
+import android.net.LinkProperties;
 import android.net.Network;
 import android.net.nsd.DiscoveryRequest;
 import android.net.nsd.NsdManager;
@@ -61,6 +62,7 @@
 import java.net.InetAddress;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Executor;
@@ -584,6 +586,7 @@
         verify(mResolveServiceCallback, times(1))
                 .onServiceResolved(
                         eq("test-host"),
+                        eq(0),
                         eq("test"),
                         eq("_test._tcp"),
                         eq(12345),
@@ -811,7 +814,10 @@
     private void prepareTest() {
         mTestLooper = new TestLooper();
         Handler handler = new Handler(mTestLooper.getLooper());
-        mNsdPublisher = new NsdPublisher(mMockNsdManager, mMockDnsResolver, handler);
+        HashMap<Network, LinkProperties> networkToLinkProperties = new HashMap<>();
+        mNsdPublisher =
+                new NsdPublisher(
+                        mMockNsdManager, mMockDnsResolver, handler, networkToLinkProperties);
         mNsdPublisher.setNetworkForHostResolution(mNetwork);
     }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index be32764..b97e2b7 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -30,6 +30,7 @@
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_TESTING;
 
 import static com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
@@ -63,12 +64,15 @@
 import android.content.Intent;
 import android.content.res.Resources;
 import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
 import android.net.NetworkAgent;
 import android.net.NetworkProvider;
 import android.net.NetworkRequest;
 import android.net.thread.ActiveOperationalDataset;
 import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.ThreadConfiguration;
 import android.net.thread.ThreadNetworkException;
 import android.os.Handler;
@@ -110,6 +114,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicReference;
@@ -170,6 +175,7 @@
     @Mock private IBinder mIBinder;
     @Mock Resources mResources;
     @Mock ConnectivityResources mConnectivityResources;
+    @Mock Map<Network, LinkProperties> mMockNetworkToLinkProperties;
 
     private Context mContext;
     private TestLooper mTestLooper;
@@ -192,6 +198,9 @@
                         eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), anyString());
         doNothing()
                 .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), anyString());
+        doNothing()
+                .when(mContext)
                 .enforceCallingOrSelfPermission(eq(NETWORK_SETTINGS), anyString());
 
         mTestLooper = new TestLooper();
@@ -232,7 +241,8 @@
                         mMockNsdPublisher,
                         mMockUserManager,
                         mConnectivityResources,
-                        () -> DEFAULT_COUNTRY_CODE);
+                        () -> DEFAULT_COUNTRY_CODE,
+                        mMockNetworkToLinkProperties);
         mService.setTestNetworkAgent(mMockNetworkAgent);
     }
 
@@ -801,4 +811,53 @@
         assertThat(networkRequest2.getNetworkSpecifier()).isNull();
         assertThat(networkRequest2.hasCapability(NET_CAPABILITY_NOT_VPN)).isTrue();
     }
+
+    @Test
+    public void runOtCtlCommand_noPermission_throwsSecurityException() {
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), any());
+        doThrow(new SecurityException(""))
+                .when(mContext)
+                .enforceCallingOrSelfPermission(eq(PERMISSION_THREAD_NETWORK_TESTING), any());
+
+        assertThrows(
+                SecurityException.class,
+                () -> mService.runOtCtlCommand("", false, new IOutputReceiver.Default()));
+    }
+
+    @Test
+    public void runOtCtlCommand_otDaemonRemoteFailure_receiverOnErrorIsCalled() throws Exception {
+        mService.initialize();
+        final IOutputReceiver mockReceiver = mock(IOutputReceiver.class);
+        mFakeOtDaemon.setRunOtCtlCommandException(
+                new RemoteException("ot-daemon runOtCtlCommand() throws"));
+
+        mService.runOtCtlCommand("ot-ctl state", false, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+    }
+
+    @Test
+    public void activateEphemeralKeyMode_succeed() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+
+        mService.activateEphemeralKeyMode(1_000L, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+    }
+
+    @Test
+    public void deactivateEphemeralKeyMode_succeed() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+
+        mService.deactivateEphemeralKeyMode(mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+    }
 }
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
index dfb3129..af5c9aa 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -20,12 +20,15 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyString;
 import static org.mockito.Mockito.atLeastOnce;
 import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
@@ -35,8 +38,10 @@
 
 import android.content.Context;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IOutputReceiver;
 import android.net.thread.PendingOperationalDataset;
 import android.os.Binder;
+import android.os.Process;
 
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.SmallTest;
@@ -47,6 +52,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
@@ -95,6 +101,9 @@
 
         mShellCommand = new ThreadNetworkShellCommand(mContext, mControllerService, mCountryCode);
         mShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+
+        // by default emulate shell uid.
+        BinderUtil.setUid(Process.SHELL_UID);
     }
 
     @After
@@ -102,16 +111,20 @@
         validateMockitoUsage();
     }
 
-    @Test
-    public void getCountryCode_testingPermissionIsChecked() {
-        when(mCountryCode.getCountryCode()).thenReturn("US");
-
+    private void runShellCommand(String... args) {
         mShellCommand.exec(
                 new Binder(),
                 new FileDescriptor(),
                 new FileDescriptor(),
                 new FileDescriptor(),
-                new String[] {"get-country-code"});
+                args);
+    }
+
+    @Test
+    public void getCountryCode_testingPermissionIsChecked() {
+        when(mCountryCode.getCountryCode()).thenReturn("US");
+
+        runShellCommand("get-country-code");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -122,24 +135,14 @@
     public void getCountryCode_currentCountryCodePrinted() {
         when(mCountryCode.getCountryCode()).thenReturn("US");
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"get-country-code"});
+        runShellCommand("get-country-code");
 
         verify(mOutputWriter).println(contains("US"));
     }
 
     @Test
     public void forceSetCountryCodeEnabled_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -148,36 +151,21 @@
 
     @Test
     public void forceSetCountryCodeEnabled_countryCodeIsOverridden() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "enabled", "US"});
+        runShellCommand("force-country-code", "enabled", "US");
 
         verify(mCountryCode).setOverrideCountryCode(eq("US"));
     }
 
     @Test
     public void forceSetCountryCodeDisabled_overriddenCountryCodeIsCleared() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-country-code", "disabled"});
+        runShellCommand("force-country-code", "disabled");
 
         verify(mCountryCode).clearOverrideCountryCode();
     }
 
     @Test
     public void forceStopOtDaemon_testingPermissionIsChecked() {
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mContext, times(1))
                 .enforceCallingOrSelfPermission(
@@ -190,12 +178,7 @@
                 .when(mControllerService)
                 .forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mOutputWriter, never()).println();
@@ -205,12 +188,7 @@
     public void forceStopOtDaemon_serviceApiTimeout_failedWithTimeoutError() {
         doNothing().when(mControllerService).forceStopOtDaemonForTest(eq(true), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"force-stop-ot-daemon", "enabled"});
+        runShellCommand("force-stop-ot-daemon", "enabled");
 
         verify(mControllerService, times(1)).forceStopOtDaemonForTest(eq(true), any());
         verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
@@ -221,12 +199,7 @@
     public void join_controllerServiceJoinIsCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", DEFAULT_ACTIVE_DATASET_TLVS});
+        runShellCommand("join", DEFAULT_ACTIVE_DATASET_TLVS);
 
         var activeDataset =
                 ActiveOperationalDataset.fromThreadTlvs(
@@ -239,12 +212,7 @@
     public void join_invalidDataset_controllerServiceJoinIsNotCalled() {
         doNothing().when(mControllerService).join(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"join", "000102"});
+        runShellCommand("join", "000102");
 
         verify(mControllerService, never()).join(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -254,12 +222,7 @@
     public void migrate_controllerServiceMigrateIsCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300"});
+        runShellCommand("migrate", DEFAULT_ACTIVE_DATASET_TLVS, "300");
 
         ArgumentCaptor<PendingOperationalDataset> captor =
                 ArgumentCaptor.forClass(PendingOperationalDataset.class);
@@ -276,12 +239,7 @@
     public void migrate_invalidDataset_controllerServiceMigrateIsNotCalled() {
         doNothing().when(mControllerService).scheduleMigration(any(), any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"migrate", "000102", "300"});
+        runShellCommand("migrate", "000102", "300");
 
         verify(mControllerService, never()).scheduleMigration(any(), any());
         verify(mErrorWriter, times(1)).println(contains("Invalid dataset argument"));
@@ -291,14 +249,75 @@
     public void leave_controllerServiceLeaveIsCalled() {
         doNothing().when(mControllerService).leave(any());
 
-        mShellCommand.exec(
-                new Binder(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new FileDescriptor(),
-                new String[] {"leave"});
+        runShellCommand("leave");
 
         verify(mControllerService, times(1)).leave(any());
         verify(mErrorWriter, never()).println();
     }
+
+    @Test
+    public void handleOtCtlCommand_testingPermissionIsChecked() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(1);
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(anyString(), anyBoolean(), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mContext, times(1))
+                .enforceCallingOrSelfPermission(
+                        eq("android.permission.THREAD_NETWORK_TESTING"), anyString());
+    }
+
+    @Test
+    public void handleOtCtlCommand_failsWithNonRootProcess() {
+        runShellCommand("ot-ctl", "state");
+
+        verify(mErrorWriter, times(1)).println(contains("No access to ot-ctl command"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_serviceTimeout_failsWithTimeoutError() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doNothing().when(mControllerService).runOtCtlCommand(anyString(), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        verify(mControllerService, times(1)).runOtCtlCommand(anyString(), eq(false), any());
+        verify(mErrorWriter, atLeastOnce()).println(contains("timeout"));
+        verify(mOutputWriter, never()).println();
+    }
+
+    @Test
+    public void handleOtCtlCommand_nonInteractive_state_outputIsPrinted() {
+        BinderUtil.setUid(Process.ROOT_UID);
+        doAnswer(
+                        invocation -> {
+                            IOutputReceiver receiver = invocation.getArgument(2);
+
+                            receiver.onOutput("leader");
+                            receiver.onOutput("\r\n");
+                            receiver.onOutput("Done");
+                            receiver.onOutput("\r\n");
+
+                            receiver.onComplete();
+                            return null;
+                        })
+                .when(mControllerService)
+                .runOtCtlCommand(eq("state"), eq(false), any());
+
+        runShellCommand("ot-ctl", "state");
+
+        InOrder inOrder = inOrder(mOutputWriter);
+        inOrder.verify(mOutputWriter).print("leader");
+        inOrder.verify(mOutputWriter).print("\r\n");
+        inOrder.verify(mOutputWriter).print("Done");
+        inOrder.verify(mOutputWriter).print("\r\n");
+    }
 }