Merge "Migrate Tethering#dump to use runWithScissorsForDump" into main
diff --git a/.gitignore b/.gitignore
index c9b6393..b517674 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,6 @@
 # VS Code project
 **/.vscode
 **/*.code-workspace
+
+# Vim temporary files
+**/*.swp
diff --git a/DnsResolver/include/DnsHelperPublic.h b/DnsResolver/include/DnsHelperPublic.h
index 7c9fc9e..44b0012 100644
--- a/DnsResolver/include/DnsHelperPublic.h
+++ b/DnsResolver/include/DnsHelperPublic.h
@@ -25,7 +25,8 @@
  * Perform any required initialization - including opening any required BPF maps. This function
  * needs to be called before using other functions of this library.
  *
- * Returns 0 on success, a negative POSIX error code (see errno.h) on other failures.
+ * Returns 0 on success, -EOPNOTSUPP when the function is called on the Android version before
+ * T. Returns a negative POSIX error code (see errno.h) on other failures.
  */
 int ADnsHelper_init();
 
@@ -36,7 +37,9 @@
  * |uid| is a Linux/Android UID to be queried. It is a combination of UserID and AppID.
  * |metered| indicates whether the uid is currently using a billing network.
  *
- * Returns 0(false)/1(true) on success, a negative POSIX error code (see errno.h) on other failures.
+ * Returns 0(false)/1(true) on success, -EUNATCH when the ADnsHelper_init is not called before
+ * calling this function. Returns a negative POSIX error code (see errno.h) on other failures
+ * that return from bpf syscall.
  */
 int ADnsHelper_isUidNetworkingBlocked(uid_t uid, bool metered);
 
diff --git a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
index 377da91..c232697 100644
--- a/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
+++ b/Tethering/tests/integration/base/android/net/EthernetTetheringTestBase.java
@@ -31,12 +31,14 @@
 import static android.net.TetheringTester.isExpectedIcmpPacket;
 import static android.net.TetheringTester.isExpectedTcpPacket;
 import static android.net.TetheringTester.isExpectedUdpPacket;
+
 import static com.android.net.module.util.HexDump.dumpHexString;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT;
 import static com.android.net.module.util.NetworkStackConstants.TCPHDR_ACK;
 import static com.android.net.module.util.NetworkStackConstants.TCPHDR_SYN;
 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
+
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
@@ -46,7 +48,6 @@
 import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
-import android.app.UiAutomation;
 import android.content.Context;
 import android.content.pm.PackageManager;
 import android.net.EthernetManager.TetheredInterfaceCallback;
@@ -56,8 +57,6 @@
 import android.net.TetheringManager.TetheringRequest;
 import android.net.TetheringTester.TetheredDevice;
 import android.net.cts.util.CtsNetUtils;
-import android.net.cts.util.CtsTetheringUtils;
-import android.net.cts.util.CtsTetheringUtils.TestTetheringEventCallback;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.SystemClock;
@@ -141,11 +140,12 @@
     protected static final ByteBuffer TX_PAYLOAD =
             ByteBuffer.wrap(new byte[] { (byte) 0x56, (byte) 0x78 });
 
-    private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
-    private final EthernetManager mEm = mContext.getSystemService(EthernetManager.class);
-    private final TetheringManager mTm = mContext.getSystemService(TetheringManager.class);
-    private final PackageManager mPackageManager = mContext.getPackageManager();
-    private final CtsNetUtils mCtsNetUtils = new CtsNetUtils(mContext);
+    private static final Context sContext =
+            InstrumentationRegistry.getInstrumentation().getContext();
+    private static final EthernetManager sEm = sContext.getSystemService(EthernetManager.class);
+    private static final TetheringManager sTm = sContext.getSystemService(TetheringManager.class);
+    private static final PackageManager sPackageManager = sContext.getPackageManager();
+    private static final CtsNetUtils sCtsNetUtils = new CtsNetUtils(sContext);
 
     // Late initialization in setUp()
     private boolean mRunTests;
@@ -161,7 +161,7 @@
     private MyTetheringEventCallback mTetheringEventCallback;
 
     public Context getContext() {
-        return mContext;
+        return sContext;
     }
 
     @BeforeClass
@@ -170,19 +170,24 @@
         // Tethering would cache the last upstreams so that the next enabled tethering avoids
         // picking up the address that is in conflict with the upstreams. To protect subsequent
         // tests, turn tethering on and off before running them.
-        final Context ctx = InstrumentationRegistry.getInstrumentation().getContext();
-        final CtsTetheringUtils utils = new CtsTetheringUtils(ctx);
-        final TestTetheringEventCallback callback = utils.registerTetheringEventCallback();
+        MyTetheringEventCallback callback = null;
+        TestNetworkInterface testIface = null;
         try {
-            if (!callback.isWifiTetheringSupported(ctx)) return;
+            // If the physical ethernet interface is available, do nothing.
+            if (isInterfaceForTetheringAvailable()) return;
 
-            callback.expectNoTetheringActive();
+            testIface = createTestInterface();
+            setIncludeTestInterfaces(true);
 
-            utils.startWifiTethering(callback);
-            callback.getCurrentValidUpstream();
-            utils.stopWifiTethering(callback);
+            callback = enableEthernetTethering(testIface.getInterfaceName(), null);
+            callback.awaitUpstreamChanged(true /* throwTimeoutException */);
+        } catch (TimeoutException e) {
+            Log.d(TAG, "WARNNING " + e);
         } finally {
-            utils.unregisterTetheringEventCallback(callback);
+            maybeCloseTestInterface(testIface);
+            maybeUnregisterTetheringEventCallback(callback);
+
+            setIncludeTestInterfaces(false);
         }
     }
 
@@ -195,13 +200,13 @@
         mRunTests = isEthernetTetheringSupported();
         assumeTrue(mRunTests);
 
-        mTetheredInterfaceRequester = new TetheredInterfaceRequester(mHandler, mEm);
+        mTetheredInterfaceRequester = new TetheredInterfaceRequester();
     }
 
     private boolean isEthernetTetheringSupported() throws Exception {
-        if (mEm == null) return false;
+        if (sEm == null) return false;
 
-        return runAsShell(NETWORK_SETTINGS, TETHER_PRIVILEGED, () -> mTm.isTetheringSupported());
+        return runAsShell(NETWORK_SETTINGS, TETHER_PRIVILEGED, () -> sTm.isTetheringSupported());
     }
 
     protected void maybeStopTapPacketReader(final TapPacketReader tapPacketReader)
@@ -212,7 +217,7 @@
         }
     }
 
-    protected void maybeCloseTestInterface(final TestNetworkInterface testInterface)
+    protected static void maybeCloseTestInterface(final TestNetworkInterface testInterface)
             throws Exception {
         if (testInterface != null) {
             testInterface.getFileDescriptor().close();
@@ -220,8 +225,8 @@
         }
     }
 
-    protected void maybeUnregisterTetheringEventCallback(final MyTetheringEventCallback callback)
-            throws Exception {
+    protected static void maybeUnregisterTetheringEventCallback(
+            final MyTetheringEventCallback callback) throws Exception {
         if (callback != null) {
             callback.awaitInterfaceUntethered();
             callback.unregister();
@@ -230,7 +235,7 @@
 
     protected void stopEthernetTethering(final MyTetheringEventCallback callback) {
         runAsShell(TETHER_PRIVILEGED, () -> {
-            mTm.stopTethering(TETHERING_ETHERNET);
+            sTm.stopTethering(TETHERING_ETHERNET);
             maybeUnregisterTetheringEventCallback(callback);
         });
     }
@@ -277,18 +282,18 @@
         }
     }
 
-    protected boolean isInterfaceForTetheringAvailable() throws Exception {
+    protected static boolean isInterfaceForTetheringAvailable() throws Exception {
         // Before T, all ethernet interfaces could be used for server mode. Instead of
         // waiting timeout, just checking whether the system currently has any
         // ethernet interface is more reliable.
         if (!SdkLevel.isAtLeastT()) {
-            return runAsShell(CONNECTIVITY_USE_RESTRICTED_NETWORKS, () -> mEm.isAvailable());
+            return runAsShell(CONNECTIVITY_USE_RESTRICTED_NETWORKS, () -> sEm.isAvailable());
         }
 
         // If previous test case doesn't release tethering interface successfully, the other tests
         // after that test may be skipped as unexcepted.
         // TODO: figure out a better way to check default tethering interface existenion.
-        final TetheredInterfaceRequester requester = new TetheredInterfaceRequester(mHandler, mEm);
+        final TetheredInterfaceRequester requester = new TetheredInterfaceRequester();
         try {
             // Use short timeout (200ms) for requesting an existing interface, if any, because
             // it should reurn faster than requesting a new tethering interface. Using default
@@ -306,15 +311,15 @@
         }
     }
 
-    protected void setIncludeTestInterfaces(boolean include) {
+    protected static void setIncludeTestInterfaces(boolean include) {
         runAsShell(NETWORK_SETTINGS, () -> {
-            mEm.setIncludeTestInterfaces(include);
+            sEm.setIncludeTestInterfaces(include);
         });
     }
 
-    protected void setPreferTestNetworks(boolean prefer) {
+    protected static void setPreferTestNetworks(boolean prefer) {
         runAsShell(NETWORK_SETTINGS, () -> {
-            mTm.setPreferTestNetworks(prefer);
+            sTm.setPreferTestNetworks(prefer);
         });
     }
 
@@ -344,7 +349,6 @@
 
 
     protected static final class MyTetheringEventCallback implements TetheringEventCallback {
-        private final TetheringManager mTm;
         private final CountDownLatch mTetheringStartedLatch = new CountDownLatch(1);
         private final CountDownLatch mTetheringStoppedLatch = new CountDownLatch(1);
         private final CountDownLatch mLocalOnlyStartedLatch = new CountDownLatch(1);
@@ -355,7 +359,7 @@
         private final TetheringInterface mIface;
         private final Network mExpectedUpstream;
 
-        private boolean mAcceptAnyUpstream = false;
+        private final boolean mAcceptAnyUpstream;
 
         private volatile boolean mInterfaceWasTethered = false;
         private volatile boolean mInterfaceWasLocalOnly = false;
@@ -368,19 +372,21 @@
         // seconds. See b/289881008.
         private static final int EXPANDED_TIMEOUT_MS = 30000;
 
-        MyTetheringEventCallback(TetheringManager tm, String iface) {
-            this(tm, iface, null);
+        MyTetheringEventCallback(String iface) {
+            mIface = new TetheringInterface(TETHERING_ETHERNET, iface);
+            mExpectedUpstream = null;
             mAcceptAnyUpstream = true;
         }
 
-        MyTetheringEventCallback(TetheringManager tm, String iface, Network expectedUpstream) {
-            mTm = tm;
+        MyTetheringEventCallback(String iface, @NonNull Network expectedUpstream) {
+            Objects.requireNonNull(expectedUpstream);
             mIface = new TetheringInterface(TETHERING_ETHERNET, iface);
             mExpectedUpstream = expectedUpstream;
+            mAcceptAnyUpstream = false;
         }
 
         public void unregister() {
-            mTm.unregisterTetheringEventCallback(this);
+            sTm.unregisterTetheringEventCallback(this);
             mUnregistered = true;
         }
         @Override
@@ -504,6 +510,11 @@
 
             Log.d(TAG, "Got upstream changed: " + network);
             mUpstream = network;
+            // The callback always updates the current tethering status when it's first registered.
+            // If the caller registers the callback before tethering starts, the null upstream
+            // would be updated. Filtering out the null case because it's not a valid upstream that
+            // we care about.
+            if (mUpstream == null) return;
             if (mAcceptAnyUpstream || Objects.equals(mUpstream, mExpectedUpstream)) {
                 mUpstreamLatch.countDown();
             }
@@ -525,18 +536,18 @@
         }
     }
 
-    protected MyTetheringEventCallback enableEthernetTethering(String iface,
+    protected static MyTetheringEventCallback enableEthernetTethering(String iface,
             TetheringRequest request, Network expectedUpstream) throws Exception {
         // Enable ethernet tethering with null expectedUpstream means the test accept any upstream
         // after etherent tethering started.
         final MyTetheringEventCallback callback;
         if (expectedUpstream != null) {
-            callback = new MyTetheringEventCallback(mTm, iface, expectedUpstream);
+            callback = new MyTetheringEventCallback(iface, expectedUpstream);
         } else {
-            callback = new MyTetheringEventCallback(mTm, iface);
+            callback = new MyTetheringEventCallback(iface);
         }
         runAsShell(NETWORK_SETTINGS, () -> {
-            mTm.registerTetheringEventCallback(mHandler::post, callback);
+            sTm.registerTetheringEventCallback(c -> c.run() /* executor */, callback);
             // Need to hold the shell permission until callback is registered. This helps to avoid
             // the test become flaky.
             callback.awaitCallbackRegistered();
@@ -556,7 +567,7 @@
         };
         Log.d(TAG, "Starting Ethernet tethering");
         runAsShell(TETHER_PRIVILEGED, () -> {
-            mTm.startTethering(request, mHandler::post /* executor */, startTetheringCallback);
+            sTm.startTethering(request, c -> c.run() /* executor */, startTetheringCallback);
             // Binder call is an async call. Need to hold the shell permission until tethering
             // started. This helps to avoid the test become flaky.
             if (!tetheringStartedLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
@@ -579,7 +590,7 @@
         return callback;
     }
 
-    protected MyTetheringEventCallback enableEthernetTethering(String iface,
+    protected static MyTetheringEventCallback enableEthernetTethering(String iface,
             Network expectedUpstream) throws Exception {
         return enableEthernetTethering(iface,
                 new TetheringRequest.Builder(TETHERING_ETHERNET)
@@ -605,17 +616,9 @@
     }
 
     protected static final class TetheredInterfaceRequester implements TetheredInterfaceCallback {
-        private final Handler mHandler;
-        private final EthernetManager mEm;
-
         private TetheredInterfaceRequest mRequest;
         private final CompletableFuture<String> mFuture = new CompletableFuture<>();
 
-        TetheredInterfaceRequester(Handler handler, EthernetManager em) {
-            mHandler = handler;
-            mEm = em;
-        }
-
         @Override
         public void onAvailable(String iface) {
             Log.d(TAG, "Ethernet interface available: " + iface);
@@ -631,7 +634,7 @@
             assertNull("BUG: more than one tethered interface request", mRequest);
             Log.d(TAG, "Requesting tethered interface");
             mRequest = runAsShell(NETWORK_SETTINGS, () ->
-                    mEm.requestTetheredInterface(mHandler::post, this));
+                    sEm.requestTetheredInterface(c -> c.run() /* executor */, this));
             return mFuture;
         }
 
@@ -652,9 +655,9 @@
         }
     }
 
-    protected TestNetworkInterface createTestInterface() throws Exception {
+    protected static TestNetworkInterface createTestInterface() throws Exception {
         TestNetworkManager tnm = runAsShell(MANAGE_TEST_NETWORKS, () ->
-                mContext.getSystemService(TestNetworkManager.class));
+                sContext.getSystemService(TestNetworkManager.class));
         TestNetworkInterface iface = runAsShell(MANAGE_TEST_NETWORKS, () ->
                 tnm.createTapInterface());
         Log.d(TAG, "Created test interface " + iface.getInterfaceName());
@@ -669,7 +672,7 @@
         lp.setLinkAddresses(addresses);
         lp.setDnsServers(dnses);
 
-        return runAsShell(MANAGE_TEST_NETWORKS, () -> initTestNetwork(mContext, lp, TIMEOUT_MS));
+        return runAsShell(MANAGE_TEST_NETWORKS, () -> initTestNetwork(sContext, lp, TIMEOUT_MS));
     }
 
     protected void sendDownloadPacketUdp(@NonNull final InetAddress srcIp,
@@ -851,7 +854,7 @@
     private void maybeRetryTestedUpstreamChanged(final Network expectedUpstream,
             final TimeoutException fallbackException) throws Exception {
         // Fall back original exception because no way to reselect if there is no WIFI feature.
-        assertTrue(fallbackException.toString(), mPackageManager.hasSystemFeature(FEATURE_WIFI));
+        assertTrue(fallbackException.toString(), sPackageManager.hasSystemFeature(FEATURE_WIFI));
 
         // Try to toggle wifi network, if any, to reselect upstream network via default network
         // switching. Because test network has higher priority than internet network, this can
@@ -867,7 +870,7 @@
         // See Tethering#chooseUpstreamType, CtsNetUtils#toggleWifi.
         // TODO: toggle cellular network if the device has no WIFI feature.
         Log.d(TAG, "Toggle WIFI to retry upstream selection");
-        mCtsNetUtils.toggleWifi();
+        sCtsNetUtils.toggleWifi();
 
         // Wait for expected upstream.
         final CompletableFuture<Network> future = new CompletableFuture<>();
@@ -881,14 +884,14 @@
             }
         };
         try {
-            mTm.registerTetheringEventCallback(mHandler::post, callback);
+            sTm.registerTetheringEventCallback(mHandler::post, callback);
             assertEquals("onUpstreamChanged for unexpected network", expectedUpstream,
                     future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS));
         } catch (TimeoutException e) {
             throw new AssertionError("Did not receive upstream " + expectedUpstream
                     + " callback after " + TIMEOUT_MS + "ms");
         } finally {
-            mTm.unregisterTetheringEventCallback(callback);
+            sTm.unregisterTetheringEventCallback(callback);
         }
     }
 
@@ -925,7 +928,7 @@
         mDownstreamReader = makePacketReader(mDownstreamIface);
         mUpstreamReader = makePacketReader(mUpstreamTracker.getTestIface());
 
-        final ConnectivityManager cm = mContext.getSystemService(ConnectivityManager.class);
+        final ConnectivityManager cm = sContext.getSystemService(ConnectivityManager.class);
         // Currently tethering don't have API to tell when ipv6 tethering is available. Thus, make
         // sure tethering already have ipv6 connectivity before testing.
         if (cm.getLinkProperties(mUpstreamTracker.getNetwork()).hasGlobalIpv6Address()) {
diff --git a/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
index d89964d..d7cff2c 100644
--- a/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
+++ b/framework-t/src/android/net/ConnectivityFrameworkInitializerTiramisu.java
@@ -27,6 +27,8 @@
 import android.net.thread.IThreadNetworkManager;
 import android.net.thread.ThreadNetworkManager;
 
+import com.android.modules.utils.build.SdkLevel;
+
 /**
  * Class for performing registration for Connectivity services which are exposed via updatable APIs
  * since Android T.
@@ -83,14 +85,17 @@
                 }
         );
 
-        SystemServiceRegistry.registerStaticService(
-                MDnsManager.MDNS_SERVICE,
-                MDnsManager.class,
-                (serviceBinder) -> {
-                    IMDns service = IMDns.Stub.asInterface(serviceBinder);
-                    return new MDnsManager(service);
-                }
-        );
+        // mdns service is removed from Netd from Android V.
+        if (!SdkLevel.isAtLeastV()) {
+            SystemServiceRegistry.registerStaticService(
+                    MDnsManager.MDNS_SERVICE,
+                    MDnsManager.class,
+                    (serviceBinder) -> {
+                        IMDns service = IMDns.Stub.asInterface(serviceBinder);
+                        return new MDnsManager(service);
+                    }
+            );
+        }
 
         SystemServiceRegistry.registerContextAwareService(
                 ThreadNetworkManager.SERVICE_NAME,
diff --git a/framework-t/src/android/net/nsd/AdvertisingRequest.java b/framework-t/src/android/net/nsd/AdvertisingRequest.java
new file mode 100644
index 0000000..b1ef98f
--- /dev/null
+++ b/framework-t/src/android/net/nsd/AdvertisingRequest.java
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.nsd;
+
+import android.annotation.LongDef;
+import android.annotation.NonNull;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Objects;
+
+/**
+ * Encapsulates parameters for {@link NsdManager#registerService}.
+ * @hide
+ */
+//@FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
+public final class AdvertisingRequest implements Parcelable {
+
+    /**
+     * Only update the registration without sending exit and re-announcement.
+     */
+    public static final int NSD_ADVERTISING_UPDATE_ONLY = 1;
+
+
+    @NonNull
+    public static final Creator<AdvertisingRequest> CREATOR =
+            new Creator<>() {
+                @Override
+                public AdvertisingRequest createFromParcel(Parcel in) {
+                    final NsdServiceInfo serviceInfo = in.readParcelable(
+                            NsdServiceInfo.class.getClassLoader(), NsdServiceInfo.class);
+                    final int protocolType = in.readInt();
+                    final long advertiseConfig = in.readLong();
+                    return new AdvertisingRequest(serviceInfo, protocolType, advertiseConfig);
+                }
+
+                @Override
+                public AdvertisingRequest[] newArray(int size) {
+                    return new AdvertisingRequest[size];
+                }
+            };
+    @NonNull
+    private final NsdServiceInfo mServiceInfo;
+    private final int mProtocolType;
+    // Bitmask of @AdvertisingConfig flags. Uses a long to allow 64 possible flags in the future.
+    private final long mAdvertisingConfig;
+
+    /**
+     * @hide
+     */
+    @Retention(RetentionPolicy.SOURCE)
+    @LongDef(flag = true, prefix = {"NSD_ADVERTISING"}, value = {
+            NSD_ADVERTISING_UPDATE_ONLY,
+    })
+    @interface AdvertisingConfig {}
+
+    /**
+     * The constructor for the advertiseRequest
+     */
+    private AdvertisingRequest(@NonNull NsdServiceInfo serviceInfo, int protocolType,
+            long advertisingConfig) {
+        mServiceInfo = serviceInfo;
+        mProtocolType = protocolType;
+        mAdvertisingConfig = advertisingConfig;
+    }
+
+    /**
+     * Returns the {@link NsdServiceInfo}
+     */
+    @NonNull
+    public NsdServiceInfo getServiceInfo() {
+        return mServiceInfo;
+    }
+
+    /**
+     * Returns the service advertise protocol
+     */
+    public int getProtocolType() {
+        return mProtocolType;
+    }
+
+    /**
+     * Returns the advertising config.
+     */
+    public long getAdvertisingConfig() {
+        return mAdvertisingConfig;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("serviceInfo: ").append(mServiceInfo)
+                .append(", protocolType: ").append(mProtocolType)
+                .append(", advertisingConfig: ").append(mAdvertisingConfig);
+        return sb.toString();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+        if (this == other) {
+            return true;
+        } else if (!(other instanceof AdvertisingRequest)) {
+            return false;
+        } else {
+            final AdvertisingRequest otherRequest = (AdvertisingRequest) other;
+            return mServiceInfo.equals(otherRequest.mServiceInfo)
+                    && mProtocolType == otherRequest.mProtocolType
+                    && mAdvertisingConfig == otherRequest.mAdvertisingConfig;
+        }
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mServiceInfo, mProtocolType, mAdvertisingConfig);
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(@NonNull Parcel dest, int flags) {
+        dest.writeParcelable(mServiceInfo, flags);
+        dest.writeInt(mProtocolType);
+        dest.writeLong(mAdvertisingConfig);
+    }
+
+//    @FlaggedApi(NsdManager.Flags.ADVERTISE_REQUEST_API)
+    /**
+     * The builder for creating new {@link AdvertisingRequest} objects.
+     * @hide
+     */
+    public static final class Builder {
+        @NonNull
+        private final NsdServiceInfo mServiceInfo;
+        private final int mProtocolType;
+        private long mAdvertisingConfig;
+        /**
+         * Creates a new {@link Builder} object.
+         */
+        public Builder(@NonNull NsdServiceInfo serviceInfo, int protocolType) {
+            mServiceInfo = serviceInfo;
+            mProtocolType = protocolType;
+        }
+
+        /**
+         * Sets advertising configuration flags.
+         *
+         * @param advertisingConfigFlags Bitmask of {@code AdvertisingConfig} flags.
+         */
+        @NonNull
+        public Builder setAdvertisingConfig(long advertisingConfigFlags) {
+            mAdvertisingConfig = advertisingConfigFlags;
+            return this;
+        }
+
+
+        /** Creates a new {@link AdvertisingRequest} object. */
+        @NonNull
+        public AdvertisingRequest build() {
+            return new AdvertisingRequest(mServiceInfo, mProtocolType, mAdvertisingConfig);
+        }
+    }
+}
diff --git a/framework-t/src/android/net/nsd/INsdServiceConnector.aidl b/framework-t/src/android/net/nsd/INsdServiceConnector.aidl
index e671db1..b03eb29 100644
--- a/framework-t/src/android/net/nsd/INsdServiceConnector.aidl
+++ b/framework-t/src/android/net/nsd/INsdServiceConnector.aidl
@@ -16,6 +16,7 @@
 
 package android.net.nsd;
 
+import android.net.nsd.AdvertisingRequest;
 import android.net.nsd.INsdManagerCallback;
 import android.net.nsd.IOffloadEngine;
 import android.net.nsd.NsdServiceInfo;
@@ -27,7 +28,7 @@
  * {@hide}
  */
 interface INsdServiceConnector {
-    void registerService(int listenerKey, in NsdServiceInfo serviceInfo);
+    void registerService(int listenerKey, in AdvertisingRequest advertisingRequest);
     void unregisterService(int listenerKey);
     void discoverServices(int listenerKey, in NsdServiceInfo serviceInfo);
     void stopDiscovery(int listenerKey);
diff --git a/framework-t/src/android/net/nsd/NsdManager.java b/framework-t/src/android/net/nsd/NsdManager.java
index fcf79eb..b4f2be9 100644
--- a/framework-t/src/android/net/nsd/NsdManager.java
+++ b/framework-t/src/android/net/nsd/NsdManager.java
@@ -46,10 +46,12 @@
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
+import android.util.Pair;
 import android.util.SparseArray;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.build.SdkLevel;
 import com.android.net.module.util.CollectionUtils;
 
 import java.lang.annotation.Retention;
@@ -57,6 +59,8 @@
 import java.util.ArrayList;
 import java.util.Objects;
 import java.util.concurrent.Executor;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /**
  * The Network Service Discovery Manager class provides the API to discover services
@@ -152,9 +156,38 @@
                 "com.android.net.flags.register_nsd_offload_engine_api";
         static final String NSD_SUBTYPES_SUPPORT_ENABLED =
                 "com.android.net.flags.nsd_subtypes_support_enabled";
+        static final String ADVERTISE_REQUEST_API =
+                "com.android.net.flags.advertise_request_api";
     }
 
     /**
+     * A regex for the acceptable format of a type or subtype label.
+     * @hide
+     */
+    public static final String TYPE_SUBTYPE_LABEL_REGEX = "_[a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]";
+
+    /**
+     * A regex for the acceptable format of a service type specification.
+     *
+     * When it matches, matcher group 1 is an optional leading subtype when using legacy dot syntax
+     * (_subtype._type._tcp). Matcher group 2 is the actual type, and matcher group 3 contains
+     * optional comma-separated subtypes.
+     * @hide
+     */
+    public static final String TYPE_REGEX =
+            // Optional leading subtype (_subtype._type._tcp)
+            // (?: xxx) is a non-capturing parenthesis, don't capture the dot
+            "^(?:(" + TYPE_SUBTYPE_LABEL_REGEX + ")\\.)?"
+                    // Actual type (_type._tcp.local)
+                    + "(" + TYPE_SUBTYPE_LABEL_REGEX + "\\._(?:tcp|udp))"
+                    // Drop '.' at the end of service type that is compatible with old backend.
+                    // e.g. allow "_type._tcp.local."
+                    + "\\.?"
+                    // Optional subtype after comma, for "_type._tcp,_subtype1,_subtype2" format
+                    + "((?:," + TYPE_SUBTYPE_LABEL_REGEX + ")*)"
+                    + "$";
+
+    /**
      * Broadcast intent action to indicate whether network service discovery is
      * enabled or disabled. An extra {@link #EXTRA_NSD_STATE} provides the state
      * information as int.
@@ -656,9 +689,12 @@
             throw new RuntimeException("Failed to connect to NsdService");
         }
 
-        // Only proactively start the daemon if the target SDK < S, otherwise the internal service
-        // would automatically start/stop the native daemon as needed.
-        if (!CompatChanges.isChangeEnabled(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)) {
+        // Only proactively start the daemon if the target SDK < S AND platform < V, For target
+        // SDK >= S AND platform < V, the internal service would automatically start/stop the native
+        // daemon as needed. For platform >= V, no action is required because the native daemon is
+        // completely removed.
+        if (!CompatChanges.isChangeEnabled(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
+                && !SdkLevel.isAtLeastV()) {
             try {
                 mService.startDaemon();
             } catch (RemoteException e) {
@@ -1098,6 +1134,16 @@
         return key;
     }
 
+    private int updateRegisteredListener(Object listener, Executor e, NsdServiceInfo s) {
+        final int key;
+        synchronized (mMapLock) {
+            key = getListenerKey(listener);
+            mServiceMap.put(key, s);
+            mExecutorMap.put(key, e);
+        }
+        return key;
+    }
+
     private void removeListener(int key) {
         synchronized (mMapLock) {
             mListenerMap.remove(key);
@@ -1162,14 +1208,111 @@
      */
     public void registerService(@NonNull NsdServiceInfo serviceInfo, int protocolType,
             @NonNull Executor executor, @NonNull RegistrationListener listener) {
+        checkServiceInfo(serviceInfo);
+        checkProtocol(protocolType);
+        final AdvertisingRequest.Builder builder = new AdvertisingRequest.Builder(serviceInfo,
+                protocolType);
+        // Optionally assume that the request is an update request if it uses subtypes and the same
+        // listener. This is not documented behavior as support for advertising subtypes via
+        // "_servicename,_sub1,_sub2" has never been documented in the first place, and using
+        // multiple subtypes was broken in T until a later module update. Subtype registration is
+        // documented in the NsdServiceInfo.setSubtypes API instead, but this provides a limited
+        // option for users of the older undocumented behavior, only for subtype changes.
+        if (isSubtypeUpdateRequest(serviceInfo, listener)) {
+            builder.setAdvertisingConfig(AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY);
+        }
+        registerService(builder.build(), executor, listener);
+    }
+
+    private boolean isSubtypeUpdateRequest(@NonNull NsdServiceInfo serviceInfo, @NonNull
+            RegistrationListener listener) {
+        // If the listener is the same object, serviceInfo is for the same service name and
+        // type (outside of subtypes), and either of them use subtypes, treat the request as a
+        // subtype update request.
+        synchronized (mMapLock) {
+            int valueIndex = mListenerMap.indexOfValue(listener);
+            if (valueIndex == -1) {
+                return false;
+            }
+            final int key = mListenerMap.keyAt(valueIndex);
+            NsdServiceInfo existingService = mServiceMap.get(key);
+            if (existingService == null) {
+                return false;
+            }
+            final Pair<String, String> existingTypeSubtype = getTypeAndSubtypes(
+                    existingService.getServiceType());
+            final Pair<String, String> newTypeSubtype = getTypeAndSubtypes(
+                    serviceInfo.getServiceType());
+            if (existingTypeSubtype == null || newTypeSubtype == null) {
+                return false;
+            }
+            final boolean existingHasNoSubtype = TextUtils.isEmpty(existingTypeSubtype.second);
+            final boolean updatedHasNoSubtype = TextUtils.isEmpty(newTypeSubtype.second);
+            if (existingHasNoSubtype && updatedHasNoSubtype) {
+                // Only allow subtype changes when subtypes are used. This ensures that this
+                // behavior does not affect most requests.
+                return false;
+            }
+
+            return Objects.equals(existingService.getServiceName(), serviceInfo.getServiceName())
+                    && Objects.equals(existingTypeSubtype.first, newTypeSubtype.first);
+        }
+    }
+
+    /**
+     * Get the base type from a type specification with "_type._tcp,sub1,sub2" syntax.
+     *
+     * <p>This rejects specifications using dot syntax to specify subtypes ("_sub1._type._tcp").
+     *
+     * @return Type and comma-separated list of subtypes, or null if invalid format.
+     */
+    @Nullable
+    private static Pair<String, String> getTypeAndSubtypes(@NonNull String typeWithSubtype) {
+        final Matcher matcher = Pattern.compile(TYPE_REGEX).matcher(typeWithSubtype);
+        if (!matcher.matches()) return null;
+        // Reject specifications using leading subtypes with a dot
+        if (!TextUtils.isEmpty(matcher.group(1))) return null;
+        return new Pair<>(matcher.group(2), matcher.group(3));
+    }
+
+    /**
+     * Register a service to be discovered by other services.
+     *
+     * <p> The function call immediately returns after sending a request to register service
+     * to the framework. The application is notified of a successful registration
+     * through the callback {@link RegistrationListener#onServiceRegistered} or a failure
+     * through {@link RegistrationListener#onRegistrationFailed}.
+     *
+     * <p> The application should call {@link #unregisterService} when the service
+     * registration is no longer required, and/or whenever the application is stopped.
+     * @param  advertisingRequest service being registered
+     * @param executor Executor to run listener callbacks with
+     * @param listener The listener notifies of a successful registration and is used to
+     * unregister this service through a call on {@link #unregisterService}. Cannot be null.
+     *
+     * @hide
+     */
+//    @FlaggedApi(Flags.ADVERTISE_REQUEST_API)
+    public void registerService(@NonNull AdvertisingRequest advertisingRequest,
+            @NonNull Executor executor,
+            @NonNull RegistrationListener listener) {
+        final NsdServiceInfo serviceInfo = advertisingRequest.getServiceInfo();
+        final int protocolType = advertisingRequest.getProtocolType();
         if (serviceInfo.getPort() <= 0) {
             throw new IllegalArgumentException("Invalid port number");
         }
         checkServiceInfo(serviceInfo);
         checkProtocol(protocolType);
-        int key = putListener(listener, executor, serviceInfo);
+        final int key;
+        // For update only request, the old listener has to be reused
+        if ((advertisingRequest.getAdvertisingConfig()
+                & AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY) > 0) {
+            key = updateRegisteredListener(listener, executor, serviceInfo);
+        } else {
+            key = putListener(listener, executor, serviceInfo);
+        }
         try {
-            mService.registerService(key, serviceInfo);
+            mService.registerService(key, advertisingRequest);
         } catch (RemoteException e) {
             e.rethrowFromSystemServer();
         }
diff --git a/framework/aidl-export/android/net/nsd/AdvertisingRequest.aidl b/framework/aidl-export/android/net/nsd/AdvertisingRequest.aidl
new file mode 100644
index 0000000..2848074
--- /dev/null
+++ b/framework/aidl-export/android/net/nsd/AdvertisingRequest.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.nsd;
+
+@JavaOnlyStableParcelable parcelable AdvertisingRequest;
\ No newline at end of file
diff --git a/nearby/README.md b/nearby/README.md
index 81d2199..8dac61c 100644
--- a/nearby/README.md
+++ b/nearby/README.md
@@ -55,6 +55,7 @@
 $ adb install /tmp/tethering.apex
 $ adb reboot
 
+NOTE: Developers should use AOSP by default, udc-mainline-prod should not be used unless for Google internal features.
 For udc-mainline-prod on Google internal host
 Build unbundled module using banchan
 $ source build/envsetup.sh
diff --git a/nearby/tests/unit/Android.bp b/nearby/tests/unit/Android.bp
index 112c751..bbf42c7 100644
--- a/nearby/tests/unit/Android.bp
+++ b/nearby/tests/unit/Android.bp
@@ -43,7 +43,6 @@
         "platform-test-annotations",
         "service-nearby-pre-jarjar",
         "truth",
-        // "Robolectric_all-target",
     ],
     // these are needed for Extended Mockito
     jni_libs: [
diff --git a/service-t/jni/com_android_server_net_NetworkStatsService.cpp b/service-t/jni/com_android_server_net_NetworkStatsService.cpp
index bdbb655..81912ae 100644
--- a/service-t/jni/com_android_server_net_NetworkStatsService.cpp
+++ b/service-t/jni/com_android_server_net_NetworkStatsService.cpp
@@ -34,77 +34,64 @@
 
 using android::bpf::bpfGetUidStats;
 using android::bpf::bpfGetIfaceStats;
-using android::bpf::bpfGetIfIndexStats;
 using android::bpf::NetworkTraceHandler;
 
 namespace android {
 
-// NOTE: keep these in sync with TrafficStats.java
-static const uint64_t UNKNOWN = -1;
-
-enum StatsType {
-    RX_BYTES = 0,
-    RX_PACKETS = 1,
-    TX_BYTES = 2,
-    TX_PACKETS = 3,
-};
-
-static uint64_t getStatsType(StatsValue* stats, StatsType type) {
-    switch (type) {
-        case RX_BYTES:
-            return stats->rxBytes;
-        case RX_PACKETS:
-            return stats->rxPackets;
-        case TX_BYTES:
-            return stats->txBytes;
-        case TX_PACKETS:
-            return stats->txPackets;
-        default:
-            return UNKNOWN;
+static jobject statsValueToEntry(JNIEnv* env, StatsValue* stats) {
+    // Find the Java class that represents the structure
+    jclass gEntryClass = env->FindClass("android/net/NetworkStats$Entry");
+    if (gEntryClass == nullptr) {
+        return nullptr;
     }
+
+    // Create a new instance of the Java class
+    jobject result = env->AllocObject(gEntryClass);
+    if (result == nullptr) {
+        return nullptr;
+    }
+
+    // Set the values of the structure fields in the Java object
+    env->SetLongField(result, env->GetFieldID(gEntryClass, "rxBytes", "J"), stats->rxBytes);
+    env->SetLongField(result, env->GetFieldID(gEntryClass, "txBytes", "J"), stats->txBytes);
+    env->SetLongField(result, env->GetFieldID(gEntryClass, "rxPackets", "J"), stats->rxPackets);
+    env->SetLongField(result, env->GetFieldID(gEntryClass, "txPackets", "J"), stats->txPackets);
+
+    return result;
 }
 
-static jlong nativeGetTotalStat(JNIEnv* env, jclass clazz, jint type) {
+static jobject nativeGetTotalStat(JNIEnv* env, jclass clazz) {
     StatsValue stats = {};
 
     if (bpfGetIfaceStats(NULL, &stats) == 0) {
-        return getStatsType(&stats, (StatsType) type);
+        return statsValueToEntry(env, &stats);
     } else {
-        return UNKNOWN;
+        return nullptr;
     }
 }
 
-static jlong nativeGetIfaceStat(JNIEnv* env, jclass clazz, jstring iface, jint type) {
+static jobject nativeGetIfaceStat(JNIEnv* env, jclass clazz, jstring iface) {
     ScopedUtfChars iface8(env, iface);
     if (iface8.c_str() == NULL) {
-        return UNKNOWN;
+        return nullptr;
     }
 
     StatsValue stats = {};
 
     if (bpfGetIfaceStats(iface8.c_str(), &stats) == 0) {
-        return getStatsType(&stats, (StatsType) type);
+        return statsValueToEntry(env, &stats);
     } else {
-        return UNKNOWN;
+        return nullptr;
     }
 }
 
-static jlong nativeGetIfIndexStat(JNIEnv* env, jclass clazz, jint ifindex, jint type) {
-    StatsValue stats = {};
-    if (bpfGetIfIndexStats(ifindex, &stats) == 0) {
-        return getStatsType(&stats, (StatsType) type);
-    } else {
-        return UNKNOWN;
-    }
-}
-
-static jlong nativeGetUidStat(JNIEnv* env, jclass clazz, jint uid, jint type) {
+static jobject nativeGetUidStat(JNIEnv* env, jclass clazz, jint uid) {
     StatsValue stats = {};
 
     if (bpfGetUidStats(uid, &stats) == 0) {
-        return getStatsType(&stats, (StatsType) type);
+        return statsValueToEntry(env, &stats);
     } else {
-        return UNKNOWN;
+        return nullptr;
     }
 }
 
@@ -113,11 +100,26 @@
 }
 
 static const JNINativeMethod gMethods[] = {
-        {"nativeGetTotalStat", "(I)J", (void*)nativeGetTotalStat},
-        {"nativeGetIfaceStat", "(Ljava/lang/String;I)J", (void*)nativeGetIfaceStat},
-        {"nativeGetIfIndexStat", "(II)J", (void*)nativeGetIfIndexStat},
-        {"nativeGetUidStat", "(II)J", (void*)nativeGetUidStat},
-        {"nativeInitNetworkTracing", "()V", (void*)nativeInitNetworkTracing},
+        {
+            "nativeGetTotalStat",
+            "()Landroid/net/NetworkStats$Entry;",
+            (void*)nativeGetTotalStat
+        },
+        {
+            "nativeGetIfaceStat",
+            "(Ljava/lang/String;)Landroid/net/NetworkStats$Entry;",
+            (void*)nativeGetIfaceStat
+        },
+        {
+            "nativeGetUidStat",
+            "(I)Landroid/net/NetworkStats$Entry;",
+            (void*)nativeGetUidStat
+        },
+        {
+            "nativeInitNetworkTracing",
+            "()V",
+            (void*)nativeInitNetworkTracing
+        },
 };
 
 int register_android_server_net_NetworkStatsService(JNIEnv* env) {
diff --git a/service-t/src/com/android/server/NsdService.java b/service-t/src/com/android/server/NsdService.java
index 630fa37..76481c8 100644
--- a/service-t/src/com/android/server/NsdService.java
+++ b/service-t/src/com/android/server/NsdService.java
@@ -26,6 +26,8 @@
 import static android.net.nsd.NsdManager.MDNS_DISCOVERY_MANAGER_EVENT;
 import static android.net.nsd.NsdManager.MDNS_SERVICE_EVENT;
 import static android.net.nsd.NsdManager.RESOLVE_SERVICE_SUCCEEDED;
+import static android.net.nsd.NsdManager.TYPE_REGEX;
+import static android.net.nsd.NsdManager.TYPE_SUBTYPE_LABEL_REGEX;
 import static android.provider.DeviceConfig.NAMESPACE_TETHERING;
 
 import static com.android.modules.utils.build.SdkLevel.isAtLeastU;
@@ -51,6 +53,7 @@
 import android.net.mdns.aidl.IMDnsEventListener;
 import android.net.mdns.aidl.RegistrationInfo;
 import android.net.mdns.aidl.ResolutionInfo;
+import android.net.nsd.AdvertisingRequest;
 import android.net.nsd.INsdManager;
 import android.net.nsd.INsdManagerCallback;
 import android.net.nsd.INsdServiceConnector;
@@ -173,7 +176,7 @@
             "mdns_advertiser_allowlist_";
     private static final String MDNS_ALLOWLIST_FLAG_SUFFIX = "_version";
 
-    private static final String TYPE_SUBTYPE_LABEL_REGEX = "_[a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]";
+
 
     @VisibleForTesting
     static final String MDNS_CONFIG_RUNNING_APP_ACTIVE_IMPORTANCE_CUTOFF =
@@ -196,7 +199,8 @@
 
     private final Context mContext;
     private final NsdStateMachine mNsdStateMachine;
-    private final MDnsManager mMDnsManager;
+    // It can be null on V+ device since mdns native service provided by netd is removed.
+    private final @Nullable MDnsManager mMDnsManager;
     private final MDnsEventCallback mMDnsEventCallback;
     @NonNull
     private final Dependencies mDeps;
@@ -538,6 +542,11 @@
         }
 
         private void maybeStartDaemon() {
+            if (mMDnsManager == null) {
+                Log.wtf(TAG, "maybeStartDaemon: mMDnsManager is null");
+                return;
+            }
+
             if (mIsDaemonStarted) {
                 if (DBG) Log.d(TAG, "Daemon is already started.");
                 return;
@@ -550,6 +559,11 @@
         }
 
         private void maybeStopDaemon() {
+            if (mMDnsManager == null) {
+                Log.wtf(TAG, "maybeStopDaemon: mMDnsManager is null");
+                return;
+            }
+
             if (!mIsDaemonStarted) {
                 if (DBG) Log.d(TAG, "Daemon has not been started.");
                 return;
@@ -728,12 +742,11 @@
                 final ClientInfo clientInfo;
                 final int transactionId;
                 final int clientRequestId = msg.arg2;
-                final ListenerArgs args;
                 final OffloadEngineInfo offloadEngineInfo;
                 switch (msg.what) {
                     case NsdManager.DISCOVER_SERVICES: {
                         if (DBG) Log.d(TAG, "Discover services");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -809,7 +822,7 @@
                     }
                     case NsdManager.STOP_DISCOVERY: {
                         if (DBG) Log.d(TAG, "Stop service discovery");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -847,7 +860,7 @@
                     }
                     case NsdManager.REGISTER_SERVICE: {
                         if (DBG) Log.d(TAG, "Register service");
-                        args = (ListenerArgs) msg.obj;
+                        final AdvertisingArgs args = (AdvertisingArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -862,9 +875,12 @@
                                     NsdManager.FAILURE_MAX_LIMIT, true /* isLegacy */);
                             break;
                         }
-
-                        transactionId = getUniqueId();
-                        final NsdServiceInfo serviceInfo = args.serviceInfo;
+                        final AdvertisingRequest advertisingRequest = args.advertisingRequest;
+                        if (advertisingRequest == null) {
+                            Log.e(TAG, "Unknown advertisingRequest in registration");
+                            break;
+                        }
+                        final NsdServiceInfo serviceInfo = advertisingRequest.getServiceInfo();
                         final String serviceType = serviceInfo.getServiceType();
                         final Pair<String, List<String>> typeSubtype = parseTypeAndSubtype(
                                 serviceType);
@@ -879,6 +895,23 @@
                                         NsdManager.FAILURE_INTERNAL_ERROR, false /* isLegacy */);
                                 break;
                             }
+                            boolean isUpdateOnly = (advertisingRequest.getAdvertisingConfig()
+                                    & AdvertisingRequest.NSD_ADVERTISING_UPDATE_ONLY) > 0;
+                            // If it is an update request, then reuse the old transactionId
+                            if (isUpdateOnly) {
+                                final ClientRequest existingClientRequest =
+                                        clientInfo.mClientRequests.get(clientRequestId);
+                                if (existingClientRequest == null) {
+                                    Log.e(TAG, "Invalid update on requestId: " + clientRequestId);
+                                    clientInfo.onRegisterServiceFailedImmediately(clientRequestId,
+                                            NsdManager.FAILURE_INTERNAL_ERROR,
+                                            false /* isLegacy */);
+                                    break;
+                                }
+                                transactionId = existingClientRequest.mTransactionId;
+                            } else {
+                                transactionId = getUniqueId();
+                            }
                             serviceInfo.setServiceType(registerServiceType);
                             serviceInfo.setServiceName(truncateServiceName(
                                     serviceInfo.getServiceName()));
@@ -899,12 +932,16 @@
 
                             serviceInfo.setSubtypes(subtypes);
                             maybeStartMonitoringSockets();
+                            final MdnsAdvertisingOptions mdnsAdvertisingOptions =
+                                    MdnsAdvertisingOptions.newBuilder().setIsOnlyUpdate(
+                                            isUpdateOnly).build();
                             mAdvertiser.addOrUpdateService(transactionId, serviceInfo,
-                                    MdnsAdvertisingOptions.newBuilder().build());
+                                    mdnsAdvertisingOptions);
                             storeAdvertiserRequestMap(clientRequestId, transactionId, clientInfo,
                                     serviceInfo.getNetwork());
                         } else {
                             maybeStartDaemon();
+                            transactionId = getUniqueId();
                             if (registerService(transactionId, serviceInfo)) {
                                 if (DBG) {
                                     Log.d(TAG, "Register " + clientRequestId
@@ -924,7 +961,7 @@
                     }
                     case NsdManager.UNREGISTER_SERVICE: {
                         if (DBG) Log.d(TAG, "unregister service");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -967,7 +1004,7 @@
                     }
                     case NsdManager.RESOLVE_SERVICE: {
                         if (DBG) Log.d(TAG, "Resolve service");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -1029,7 +1066,7 @@
                     }
                     case NsdManager.STOP_RESOLUTION: {
                         if (DBG) Log.d(TAG, "Stop service resolution");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -1068,7 +1105,7 @@
                     }
                     case NsdManager.REGISTER_SERVICE_CALLBACK: {
                         if (DBG) Log.d(TAG, "Register a service callback");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -1111,7 +1148,7 @@
                     }
                     case NsdManager.UNREGISTER_SERVICE_CALLBACK: {
                         if (DBG) Log.d(TAG, "Unregister a service callback");
-                        args = (ListenerArgs) msg.obj;
+                        final ListenerArgs args = (ListenerArgs) msg.obj;
                         clientInfo = mClients.get(args.connector);
                         // If the binder death notification for a INsdManagerCallback was received
                         // before any calls are received by NsdService, the clientInfo would be
@@ -1638,20 +1675,7 @@
     @Nullable
     public static Pair<String, List<String>> parseTypeAndSubtype(String serviceType) {
         if (TextUtils.isEmpty(serviceType)) return null;
-
-        final String regexString =
-                // Optional leading subtype (_subtype._type._tcp)
-                // (?: xxx) is a non-capturing parenthesis, don't capture the dot
-                "^(?:(" + TYPE_SUBTYPE_LABEL_REGEX + ")\\.)?"
-                        // Actual type (_type._tcp.local)
-                        + "(" + TYPE_SUBTYPE_LABEL_REGEX + "\\._(?:tcp|udp))"
-                        // Drop '.' at the end of service type that is compatible with old backend.
-                        // e.g. allow "_type._tcp.local."
-                        + "\\.?"
-                        // Optional subtype after comma, for "_type._tcp,_subtype1,_subtype2" format
-                        + "((?:," + TYPE_SUBTYPE_LABEL_REGEX + ")*)"
-                        + "$";
-        final Pattern serviceTypePattern = Pattern.compile(regexString);
+        final Pattern serviceTypePattern = Pattern.compile(TYPE_REGEX);
         final Matcher matcher = serviceTypePattern.matcher(serviceType);
         if (!matcher.matches()) return null;
         final String queryType = matcher.group(2);
@@ -1685,7 +1709,8 @@
         mContext = ctx;
         mNsdStateMachine = new NsdStateMachine(TAG, handler);
         mNsdStateMachine.start();
-        mMDnsManager = ctx.getSystemService(MDnsManager.class);
+        // It can fail on V+ device since mdns native service provided by netd is removed.
+        mMDnsManager = SdkLevel.isAtLeastV() ? null : ctx.getSystemService(MDnsManager.class);
         mMDnsEventCallback = new MDnsEventCallback(mNsdStateMachine);
         mDeps = deps;
 
@@ -2079,20 +2104,33 @@
         }
     }
 
+    private static class AdvertisingArgs {
+        public final NsdServiceConnector connector;
+        public final AdvertisingRequest advertisingRequest;
+
+        AdvertisingArgs(NsdServiceConnector connector, AdvertisingRequest advertisingRequest) {
+            this.connector = connector;
+            this.advertisingRequest = advertisingRequest;
+        }
+    }
+
     private class NsdServiceConnector extends INsdServiceConnector.Stub
             implements IBinder.DeathRecipient  {
+
         @Override
-        public void registerService(int listenerKey, NsdServiceInfo serviceInfo) {
+        public void registerService(int listenerKey, AdvertisingRequest advertisingRequest)
+                throws RemoteException {
             mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
                     NsdManager.REGISTER_SERVICE, 0, listenerKey,
-                    new ListenerArgs(this, serviceInfo)));
+                    new AdvertisingArgs(this, advertisingRequest)
+            ));
         }
 
         @Override
         public void unregisterService(int listenerKey) {
             mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
                     NsdManager.UNREGISTER_SERVICE, 0, listenerKey,
-                    new ListenerArgs(this, null)));
+                    new ListenerArgs(this, (NsdServiceInfo) null)));
         }
 
         @Override
@@ -2104,8 +2142,8 @@
 
         @Override
         public void stopDiscovery(int listenerKey) {
-            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
-                    NsdManager.STOP_DISCOVERY, 0, listenerKey, new ListenerArgs(this, null)));
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(NsdManager.STOP_DISCOVERY,
+                    0, listenerKey, new ListenerArgs(this, (NsdServiceInfo) null)));
         }
 
         @Override
@@ -2117,8 +2155,8 @@
 
         @Override
         public void stopResolution(int listenerKey) {
-            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
-                    NsdManager.STOP_RESOLUTION, 0, listenerKey, new ListenerArgs(this, null)));
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(NsdManager.STOP_RESOLUTION,
+                    0, listenerKey, new ListenerArgs(this, (NsdServiceInfo) null)));
         }
 
         @Override
@@ -2132,13 +2170,13 @@
         public void unregisterServiceInfoCallback(int listenerKey) {
             mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
                     NsdManager.UNREGISTER_SERVICE_CALLBACK, 0, listenerKey,
-                    new ListenerArgs(this, null)));
+                    new ListenerArgs(this, (NsdServiceInfo) null)));
         }
 
         @Override
         public void startDaemon() {
-            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(
-                    NsdManager.DAEMON_STARTUP, new ListenerArgs(this, null)));
+            mNsdStateMachine.sendMessage(mNsdStateMachine.obtainMessage(NsdManager.DAEMON_STARTUP,
+                    new ListenerArgs(this, (NsdServiceInfo) null)));
         }
 
         @Override
@@ -2174,25 +2212,24 @@
                 throw new SecurityException("API is not available in before API level 33");
             }
 
-            // REGISTER_NSD_OFFLOAD_ENGINE was only added to the SDK in V.
-            if (SdkLevel.isAtLeastV() && PermissionUtils.checkAnyPermissionOf(context,
-                    REGISTER_NSD_OFFLOAD_ENGINE)) {
-                return;
+            final ArrayList<String> permissionsList = new ArrayList<>(Arrays.asList(NETWORK_STACK,
+                    PERMISSION_MAINLINE_NETWORK_STACK, NETWORK_SETTINGS));
+
+            if (SdkLevel.isAtLeastV()) {
+                // REGISTER_NSD_OFFLOAD_ENGINE was only added to the SDK in V.
+                permissionsList.add(REGISTER_NSD_OFFLOAD_ENGINE);
+            } else if (SdkLevel.isAtLeastU()) {
+                // REGISTER_NSD_OFFLOAD_ENGINE cannot be backport to U. In U, check the DEVICE_POWER
+                // permission instead.
+                permissionsList.add(DEVICE_POWER);
             }
 
-            // REGISTER_NSD_OFFLOAD_ENGINE cannot be backport to U. In U, check the DEVICE_POWER
-            // permission instead.
-            if (!SdkLevel.isAtLeastV() && SdkLevel.isAtLeastU()
-                    && PermissionUtils.checkAnyPermissionOf(context, DEVICE_POWER)) {
-                return;
-            }
-            if (PermissionUtils.checkAnyPermissionOf(context, NETWORK_STACK,
-                    PERMISSION_MAINLINE_NETWORK_STACK, NETWORK_SETTINGS)) {
+            if (PermissionUtils.checkAnyPermissionOf(context,
+                    permissionsList.toArray(new String[0]))) {
                 return;
             }
             throw new SecurityException("Requires one of the following permissions: "
-                    + String.join(", ", List.of(REGISTER_NSD_OFFLOAD_ENGINE, NETWORK_STACK,
-                    PERMISSION_MAINLINE_NETWORK_STACK, NETWORK_SETTINGS)) + ".");
+                    + String.join(", ", permissionsList) + ".");
         }
     }
 
@@ -2210,6 +2247,11 @@
     }
 
     private boolean registerService(int transactionId, NsdServiceInfo service) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "registerService: mMDnsManager is null");
+            return false;
+        }
+
         if (DBG) {
             Log.d(TAG, "registerService: " + transactionId + " " + service);
         }
@@ -2227,10 +2269,19 @@
     }
 
     private boolean unregisterService(int transactionId) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "unregisterService: mMDnsManager is null");
+            return false;
+        }
         return mMDnsManager.stopOperation(transactionId);
     }
 
     private boolean discoverServices(int transactionId, NsdServiceInfo serviceInfo) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "discoverServices: mMDnsManager is null");
+            return false;
+        }
+
         final String type = serviceInfo.getServiceType();
         final int discoverInterface = getNetworkInterfaceIndex(serviceInfo);
         if (serviceInfo.getNetwork() != null && discoverInterface == IFACE_IDX_ANY) {
@@ -2241,10 +2292,18 @@
     }
 
     private boolean stopServiceDiscovery(int transactionId) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "stopServiceDiscovery: mMDnsManager is null");
+            return false;
+        }
         return mMDnsManager.stopOperation(transactionId);
     }
 
     private boolean resolveService(int transactionId, NsdServiceInfo service) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "resolveService: mMDnsManager is null");
+            return false;
+        }
         final String name = service.getServiceName();
         final String type = service.getServiceType();
         final int resolveInterface = getNetworkInterfaceIndex(service);
@@ -2318,14 +2377,26 @@
     }
 
     private boolean stopResolveService(int transactionId) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "stopResolveService: mMDnsManager is null");
+            return false;
+        }
         return mMDnsManager.stopOperation(transactionId);
     }
 
     private boolean getAddrInfo(int transactionId, String hostname, int interfaceIdx) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "getAddrInfo: mMDnsManager is null");
+            return false;
+        }
         return mMDnsManager.getServiceAddress(transactionId, hostname, interfaceIdx);
     }
 
     private boolean stopGetAddrInfo(int transactionId) {
+        if (mMDnsManager == null) {
+            Log.wtf(TAG, "stopGetAddrInfo: mMDnsManager is null");
+            return false;
+        }
         return mMDnsManager.stopOperation(transactionId);
     }
 
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
index d46a7b5..6b6632c 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -46,6 +46,7 @@
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -140,6 +141,9 @@
          * Last time (as per SystemClock.elapsedRealtime) when sent via unicast or multicast,
          * 0 if never
          */
+        // FIXME: the `lastSentTimeMs` and `lastAdvertisedTimeMs` should be maintained separately
+        // for IPv4 and IPv6, because neither IPv4 nor and IPv6 clients can receive replies in
+        // different address space.
         public long lastSentTimeMs;
 
         RecordInfo(NsdServiceInfo serviceInfo, T record, boolean sharedName) {
@@ -317,7 +321,6 @@
      *
      * @param serviceId An existing service ID.
      * @param subtypes New subtypes
-     * @return
      */
     public void updateService(int serviceId, @NonNull Set<String> subtypes) {
         final ServiceRegistration existingRegistration = mServices.get(serviceId);
@@ -497,13 +500,17 @@
     public MdnsReplyInfo getReply(MdnsPacket packet, InetSocketAddress src) {
         final long now = SystemClock.elapsedRealtime();
         final boolean replyUnicast = (packet.flags & MdnsConstants.QCLASS_UNICAST) != 0;
-        final ArrayList<MdnsRecord> additionalAnswerRecords = new ArrayList<>();
-        final ArrayList<RecordInfo<?>> answerInfo = new ArrayList<>();
+
+        // Use LinkedHashSet for preserving the insert order of the RRs, so that RRs of the same
+        // service or host are grouped together (which is more developer-friendly).
+        final Set<RecordInfo<?>> answerInfo = new LinkedHashSet<>();
+        final Set<RecordInfo<?>> additionalAnswerInfo = new LinkedHashSet<>();
+
         for (MdnsRecord question : packet.questions) {
             // Add answers from general records
             addReplyFromService(question, mGeneralRecords, null /* servicePtrRecord */,
                     null /* serviceSrvRecord */, null /* serviceTxtRecord */, replyUnicast, now,
-                    answerInfo, additionalAnswerRecords, Collections.emptyList());
+                    answerInfo, additionalAnswerInfo, Collections.emptyList());
 
             // Add answers from each service
             for (int i = 0; i < mServices.size(); i++) {
@@ -511,13 +518,33 @@
                 if (registration.exiting || registration.isProbing) continue;
                 if (addReplyFromService(question, registration.allRecords, registration.ptrRecords,
                         registration.srvRecord, registration.txtRecord, replyUnicast, now,
-                        answerInfo, additionalAnswerRecords, packet.answers)) {
+                        answerInfo, additionalAnswerInfo, packet.answers)) {
                     registration.repliedServiceCount++;
                     registration.sentPacketCount++;
                 }
             }
         }
 
+        // If any record was already in the answer section, remove it from the additional answer
+        // section. This can typically happen when there are both queries for
+        // SRV / TXT / A / AAAA and PTR (which can cause SRV / TXT / A / AAAA records being added
+        // to the additional answer section).
+        additionalAnswerInfo.removeAll(answerInfo);
+
+        final List<MdnsRecord> additionalAnswerRecords =
+                new ArrayList<>(additionalAnswerInfo.size());
+        for (RecordInfo<?> info : additionalAnswerInfo) {
+            additionalAnswerRecords.add(info.record);
+        }
+
+        // RFC6762 6.1: negative responses
+        // "On receipt of a question for a particular name, rrtype, and rrclass, for which a
+        // responder does have one or more unique answers, the responder MAY also include an NSEC
+        // record in the Additional Record Section indicating the nonexistence of other rrtypes
+        // for that name and rrclass."
+        addNsecRecordsForUniqueNames(additionalAnswerRecords,
+                answerInfo.iterator(), additionalAnswerInfo.iterator());
+
         if (answerInfo.size() == 0 && additionalAnswerRecords.size() == 0) {
             return null;
         }
@@ -581,15 +608,15 @@
             @Nullable List<RecordInfo<MdnsPointerRecord>> servicePtrRecords,
             @Nullable RecordInfo<MdnsServiceRecord> serviceSrvRecord,
             @Nullable RecordInfo<MdnsTextRecord> serviceTxtRecord,
-            boolean replyUnicast, long now, @NonNull List<RecordInfo<?>> answerInfo,
-            @NonNull List<MdnsRecord> additionalAnswerRecords,
+            boolean replyUnicast, long now, @NonNull Set<RecordInfo<?>> answerInfo,
+            @NonNull Set<RecordInfo<?>> additionalAnswerInfo,
             @NonNull List<MdnsRecord> knownAnswerRecords) {
         boolean hasDnsSdPtrRecordAnswer = false;
         boolean hasDnsSdSrvRecordAnswer = false;
         boolean hasFullyOwnedNameMatch = false;
         boolean hasKnownAnswer = false;
 
-        final int answersStartIndex = answerInfo.size();
+        final int answersStartSize = answerInfo.size();
         for (RecordInfo<?> info : serviceRecords) {
 
              /* RFC6762 6.: the record name must match the question name, the record rrtype
@@ -645,7 +672,7 @@
         // ownership, for a type for which that name has no records, the responder MUST [...]
         // respond asserting the nonexistence of that record"
         if (hasFullyOwnedNameMatch && !hasKnownAnswer) {
-            additionalAnswerRecords.add(new MdnsNsecRecord(
+            MdnsNsecRecord nsecRecord = new MdnsNsecRecord(
                     question.getName(),
                     0L /* receiptTimeMillis */,
                     true /* cacheFlush */,
@@ -653,13 +680,14 @@
                     // be the same as the TTL that the record would have had, had it existed."
                     NAME_RECORDS_TTL_MILLIS,
                     question.getName(),
-                    new int[] { question.getType() }));
+                    new int[] { question.getType() });
+            additionalAnswerInfo.add(
+                    new RecordInfo<>(null /* serviceInfo */, nsecRecord, false /* isSharedName */));
         }
 
         // No more records to add if no answer
-        if (answerInfo.size() == answersStartIndex) return false;
+        if (answerInfo.size() == answersStartSize) return false;
 
-        final List<RecordInfo<?>> additionalAnswerInfo = new ArrayList<>();
         // RFC6763 12.1: if including PTR record, include the SRV and TXT records it names
         if (hasDnsSdPtrRecordAnswer) {
             if (serviceTxtRecord != null) {
@@ -678,15 +706,6 @@
                 }
             }
         }
-
-        for (RecordInfo<?> info : additionalAnswerInfo) {
-            additionalAnswerRecords.add(info.record);
-        }
-
-        // RFC6762 6.1: negative responses
-        addNsecRecordsForUniqueNames(additionalAnswerRecords,
-                answerInfo.listIterator(answersStartIndex),
-                additionalAnswerInfo.listIterator());
         return true;
     }
 
@@ -703,7 +722,7 @@
      *                      answer and additionalAnswer sections)
      */
     @SafeVarargs
-    private static void addNsecRecordsForUniqueNames(
+    private void addNsecRecordsForUniqueNames(
             List<MdnsRecord> destinationList,
             Iterator<RecordInfo<?>>... answerRecords) {
         // Group unique records by name. Use a TreeMap with comparator as arrays don't implement
@@ -719,6 +738,12 @@
 
         for (String[] nsecName : namesInAddedOrder) {
             final List<MdnsRecord> entryRecords = nsecByName.get(nsecName);
+
+            // Add NSEC records only when the answers include all unique records of this name
+            if (entryRecords.size() != countUniqueRecords(nsecName)) {
+                continue;
+            }
+
             long minTtl = Long.MAX_VALUE;
             final Set<Integer> types = new ArraySet<>(entryRecords.size());
             for (MdnsRecord record : entryRecords) {
@@ -736,6 +761,27 @@
         }
     }
 
+    /** Returns the number of unique records on this device for a given {@code name}. */
+    private int countUniqueRecords(String[] name) {
+        int cnt = countUniqueRecords(mGeneralRecords, name);
+
+        for (int i = 0; i < mServices.size(); i++) {
+            final ServiceRegistration registration = mServices.valueAt(i);
+            cnt += countUniqueRecords(registration.allRecords, name);
+        }
+        return cnt;
+    }
+
+    private static int countUniqueRecords(List<RecordInfo<?>> records, String[] name) {
+        int cnt = 0;
+        for (RecordInfo<?> record : records) {
+            if (!record.isSharedName && Arrays.equals(name, record.record.getName())) {
+                cnt++;
+            }
+        }
+        return cnt;
+    }
+
     /**
      * Add non-shared records to a map listing them by record name, and to a list of names that
      * remembers the adding order.
@@ -750,10 +796,10 @@
     private static void addNonSharedRecordsToMap(
             Iterator<RecordInfo<?>> records,
             Map<String[], List<MdnsRecord>> dest,
-            List<String[]> namesInAddedOrder) {
+            @Nullable List<String[]> namesInAddedOrder) {
         while (records.hasNext()) {
             final RecordInfo<?> record = records.next();
-            if (record.isSharedName) continue;
+            if (record.isSharedName || record.record instanceof MdnsNsecRecord) continue;
             final List<MdnsRecord> recordsForName = dest.computeIfAbsent(record.record.name,
                     key -> {
                         namesInAddedOrder.add(key);
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
index 32f604e..df0a040 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsServiceTypeClient.java
@@ -541,6 +541,9 @@
             }
 
             if (response.isComplete()) {
+                // There is a bug here: the newServiceFound is global right now. The state needs
+                // to be per listener because of the  responseMatchesOptions() filter.
+                // Otherwise, it won't handle the subType update properly.
                 if (newServiceFound || serviceBecomesComplete) {
                     sharedLog.log("onServiceFound: " + serviceInfo);
                     listener.onServiceFound(serviceInfo, false /* isServiceFromCache */);
diff --git a/service-t/src/com/android/server/net/NetworkStatsService.java b/service-t/src/com/android/server/net/NetworkStatsService.java
index 3ac5e29..eb75461 100644
--- a/service-t/src/com/android/server/net/NetworkStatsService.java
+++ b/service-t/src/com/android/server/net/NetworkStatsService.java
@@ -1981,36 +1981,56 @@
         if (callingUid != android.os.Process.SYSTEM_UID && callingUid != uid) {
             return UNSUPPORTED;
         }
-        return nativeGetUidStat(uid, type);
+        return getEntryValueForType(nativeGetUidStat(uid), type);
     }
 
     @Override
     public long getIfaceStats(@NonNull String iface, int type) {
         Objects.requireNonNull(iface);
-        long nativeIfaceStats = nativeGetIfaceStat(iface, type);
-        if (nativeIfaceStats == -1) {
-            return nativeIfaceStats;
+        final NetworkStats.Entry entry = nativeGetIfaceStat(iface);
+        final long value = getEntryValueForType(entry, type);
+        if (value == UNSUPPORTED) {
+            return UNSUPPORTED;
         } else {
             // When tethering offload is in use, nativeIfaceStats does not contain usage from
             // offload, add it back here. Note that the included statistics might be stale
             // since polling newest stats from hardware might impact system health and not
             // suitable for TrafficStats API use cases.
-            return nativeIfaceStats + getProviderIfaceStats(iface, type);
+            entry.add(getProviderIfaceStats(iface));
+            return getEntryValueForType(entry, type);
+        }
+    }
+
+    private long getEntryValueForType(@Nullable NetworkStats.Entry entry, int type) {
+        if (entry == null) return UNSUPPORTED;
+        switch (type) {
+            case TrafficStats.TYPE_RX_BYTES:
+                return entry.rxBytes;
+            case TrafficStats.TYPE_TX_BYTES:
+                return entry.txBytes;
+            case TrafficStats.TYPE_RX_PACKETS:
+                return entry.rxPackets;
+            case TrafficStats.TYPE_TX_PACKETS:
+                return entry.txPackets;
+            default:
+                return UNSUPPORTED;
         }
     }
 
     @Override
     public long getTotalStats(int type) {
-        long nativeTotalStats = nativeGetTotalStat(type);
-        if (nativeTotalStats == -1) {
-            return nativeTotalStats;
+        final NetworkStats.Entry entry = nativeGetTotalStat();
+        final long value = getEntryValueForType(entry, type);
+        if (value == UNSUPPORTED) {
+            return UNSUPPORTED;
         } else {
             // Refer to comment in getIfaceStats
-            return nativeTotalStats + getProviderIfaceStats(IFACE_ALL, type);
+            entry.add(getProviderIfaceStats(IFACE_ALL));
+            return getEntryValueForType(entry, type);
         }
     }
 
-    private long getProviderIfaceStats(@Nullable String iface, int type) {
+    private NetworkStats.Entry getProviderIfaceStats(@Nullable String iface) {
         final NetworkStats providerSnapshot = getNetworkStatsFromProviders(STATS_PER_IFACE);
         final HashSet<String> limitIfaces;
         if (iface == IFACE_ALL) {
@@ -2019,19 +2039,7 @@
             limitIfaces = new HashSet<>();
             limitIfaces.add(iface);
         }
-        final NetworkStats.Entry entry = providerSnapshot.getTotal(null, limitIfaces);
-        switch (type) {
-            case TrafficStats.TYPE_RX_BYTES:
-                return entry.rxBytes;
-            case TrafficStats.TYPE_RX_PACKETS:
-                return entry.rxPackets;
-            case TrafficStats.TYPE_TX_BYTES:
-                return entry.txBytes;
-            case TrafficStats.TYPE_TX_PACKETS:
-                return entry.txPackets;
-            default:
-                return 0;
-        }
+        return providerSnapshot.getTotal(null, limitIfaces);
     }
 
     /**
@@ -3398,10 +3406,13 @@
         }
     }
 
-    private static native long nativeGetTotalStat(int type);
-    private static native long nativeGetIfaceStat(String iface, int type);
-    private static native long nativeGetIfIndexStat(int ifindex, int type);
-    private static native long nativeGetUidStat(int uid, int type);
+    // TODO: Read stats by using BpfNetMapsReader.
+    @Nullable
+    private static native NetworkStats.Entry nativeGetTotalStat();
+    @Nullable
+    private static native NetworkStats.Entry nativeGetIfaceStat(String iface);
+    @Nullable
+    private static native NetworkStats.Entry nativeGetUidStat(int uid);
 
     /** Initializes and registers the Perfetto Network Trace data source */
     public static native void nativeInitNetworkTracing();
diff --git a/service/ServiceConnectivityResources/res/values/config_thread.xml b/service/ServiceConnectivityResources/res/values/config_thread.xml
new file mode 100644
index 0000000..14b5427
--- /dev/null
+++ b/service/ServiceConnectivityResources/res/values/config_thread.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<!-- These resources are around just to allow their values to be customized
+     for different hardware and product builds for Thread Network. All
+	 configuration names should use the "config_thread" prefix.
+-->
+
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <!-- Whether to use location APIs in the algorithm to determine country code or not.
+    If disabled, will use other sources (telephony, wifi, etc) to determine device location for
+    Thread Network regulatory purposes.
+    -->
+    <bool name="config_thread_location_use_for_country_code_enabled">true</bool>
+
+</resources>
diff --git a/service/ServiceConnectivityResources/res/values/overlayable.xml b/service/ServiceConnectivityResources/res/values/overlayable.xml
index 4c85e8c..1c07599 100644
--- a/service/ServiceConnectivityResources/res/values/overlayable.xml
+++ b/service/ServiceConnectivityResources/res/values/overlayable.xml
@@ -43,6 +43,9 @@
             <item type="string" name="config_ethernet_iface_regex"/>
             <item type="integer" name="config_validationFailureAfterRoamIgnoreTimeMillis" />
             <item type="integer" name="config_netstats_validate_import" />
+
+            <!-- Configuration values for ThreadNetworkService -->
+            <item type="bool" name="config_thread_location_use_for_country_code_enabled" />
         </policy>
     </overlayable>
 </resources>
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 31b14ef..3b31ed2 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -3534,7 +3534,7 @@
                 NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK);
     }
 
-    private boolean checkStatusBarServicePermission(int pid, int uid) {
+    private boolean checkSystemBarServicePermission(int pid, int uid) {
         return checkAnyPermissionOf(mContext, pid, uid,
                 android.Manifest.permission.STATUS_BAR_SERVICE);
     }
@@ -11723,7 +11723,7 @@
             return true;
         }
         if (mAllowSysUiConnectivityReports
-                && checkStatusBarServicePermission(callbackPid, callbackUid)) {
+                && checkSystemBarServicePermission(callbackPid, callbackUid)) {
             return true;
         }
 
diff --git a/service/src/com/android/server/connectivity/RoutingCoordinatorService.java b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
index 3350d2d..742a2cc 100644
--- a/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
+++ b/service/src/com/android/server/connectivity/RoutingCoordinatorService.java
@@ -171,7 +171,8 @@
             }
             final ForwardingPair fwp = new ForwardingPair(fromIface, toIface);
             if (mForwardedInterfaces.contains(fwp)) {
-                throw new IllegalStateException("Forward already exists between ifaces "
+                // TODO: remove if no reports are observed from the below log
+                Log.wtf(TAG, "Forward already exists between ifaces "
                         + fromIface + " → " + toIface);
             }
             mForwardedInterfaces.add(fwp);
diff --git a/staticlibs/Android.bp b/staticlibs/Android.bp
index 04eb15c..8f018c0 100644
--- a/staticlibs/Android.bp
+++ b/staticlibs/Android.bp
@@ -296,6 +296,10 @@
             "-Xep:NullablePrimitive:ERROR",
         ],
     },
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.tethering",
+    ],
 }
 
 java_library {
diff --git a/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java b/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
index e0fe929..ceb48d4 100644
--- a/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
+++ b/tests/cts/net/src/android/net/cts/ConnectivityDiagnosticsManagerTest.java
@@ -298,17 +298,6 @@
                 },
                 android.Manifest.permission.MODIFY_PHONE_STATE);
 
-        // TODO(b/157779832): This should use android.permission.CHANGE_NETWORK_STATE. However, the
-        // shell does not have CHANGE_NETWORK_STATE, so use CONNECTIVITY_INTERNAL until the shell
-        // permissions are updated.
-        runWithShellPermissionIdentity(
-                () -> mConnectivityManager.requestNetwork(
-                        CELLULAR_NETWORK_REQUEST, testNetworkCallback),
-                android.Manifest.permission.CONNECTIVITY_INTERNAL);
-
-        final Network network = testNetworkCallback.waitForAvailable();
-        assertNotNull(network);
-
         assertTrue("Didn't receive broadcast for ACTION_CARRIER_CONFIG_CHANGED for subId=" + subId,
                 carrierConfigReceiver.waitForCarrierConfigChanged());
 
@@ -324,6 +313,17 @@
 
         Thread.sleep(5_000);
 
+        // TODO(b/157779832): This should use android.permission.CHANGE_NETWORK_STATE. However, the
+        // shell does not have CHANGE_NETWORK_STATE, so use CONNECTIVITY_INTERNAL until the shell
+        // permissions are updated.
+        runWithShellPermissionIdentity(
+                () -> mConnectivityManager.requestNetwork(
+                        CELLULAR_NETWORK_REQUEST, testNetworkCallback),
+                android.Manifest.permission.CONNECTIVITY_INTERNAL);
+
+        final Network network = testNetworkCallback.waitForAvailable();
+        assertNotNull(network);
+
         // TODO(b/217559768): Receiving carrier config change and immediately checking carrier
         //  privileges is racy, as the CP status is updated after receiving the same signal. Move
         //  the CP check after sleep to temporarily reduce the flakiness. This will soon be fixed
diff --git a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
index 6b7954a..f6a025a 100644
--- a/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
+++ b/tests/cts/net/src/android/net/cts/Ikev2VpnTest.java
@@ -648,7 +648,7 @@
                         testIpv6Only, requiresValidation, testSessionKey , testIkeTunConnParams)));
     }
 
-    @Test
+    @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileV4() throws Exception {
         doTestStartStopVpnProfile(false /* testIpv6Only */, false /* requiresValidation */,
                 false /* testSessionKey */, false /* testIkeTunConnParams */);
@@ -660,7 +660,7 @@
                 false /* testSessionKey */, false /* testIkeTunConnParams */);
     }
 
-    @Test
+    @Test @IgnoreUpTo(SC_V2)
     public void testStartStopVpnProfileV6() throws Exception {
         doTestStartStopVpnProfile(true /* testIpv6Only */, false /* requiresValidation */,
                 false /* testSessionKey */, false /* testIkeTunConnParams */);
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
index f374181..1b1f367 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerDownstreamTetheringTest.kt
@@ -62,7 +62,7 @@
 
     @Test
     fun testMdnsDiscoveryCanSendPacketOnLocalOnlyDownstreamTetheringInterface() {
-        assumeFalse(isInterfaceForTetheringAvailable)
+        assumeFalse(isInterfaceForTetheringAvailable())
 
         var downstreamIface: TestNetworkInterface? = null
         var tetheringEventCallback: MyTetheringEventCallback? = null
@@ -104,7 +104,7 @@
 
     @Test
     fun testMdnsDiscoveryWorkOnTetheringInterface() {
-        assumeFalse(isInterfaceForTetheringAvailable)
+        assumeFalse(isInterfaceForTetheringAvailable())
         setIncludeTestInterfaces(true)
 
         var downstreamIface: TestNetworkInterface? = null
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index e0387c9..a040201 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -1064,6 +1064,32 @@
     }
 
     @Test
+    fun testMultipleSubTypeAdvertisingAndDiscovery_withUpdate() {
+        val si1 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceType += ",_subtype1"
+        }
+        val si2 = makeTestServiceInfo(network = testNetwork1.network).apply {
+            serviceType += ",_subtype2"
+        }
+        val registrationRecord = NsdRegistrationRecord()
+        val subtype3DiscoveryRecord = NsdDiscoveryRecord()
+        tryTest {
+            registerService(registrationRecord, si1)
+            updateService(registrationRecord, si2)
+            nsdManager.discoverServices("_subtype2.$serviceType",
+                    NsdManager.PROTOCOL_DNS_SD, testNetwork1.network,
+                    { it.run() }, subtype3DiscoveryRecord)
+            subtype3DiscoveryRecord.waitForServiceDiscovered(serviceName,
+                    serviceType, testNetwork1.network)
+        } cleanupStep {
+            nsdManager.stopServiceDiscovery(subtype3DiscoveryRecord)
+            subtype3DiscoveryRecord.expectCallback<DiscoveryStopped>()
+        } cleanup {
+            nsdManager.unregisterService(registrationRecord)
+        }
+    }
+
+    @Test
     fun testSubtypeAdvertising_tooManySubtypes_returnsFailureBadParameters() {
         val si = makeTestServiceInfo(network = testNetwork1.network)
         // Sets 101 subtypes in total
@@ -1404,6 +1430,18 @@
         return cb.serviceInfo
     }
 
+    /**
+     * Update a service.
+     */
+    private fun updateService(
+            record: NsdRegistrationRecord,
+            si: NsdServiceInfo,
+            executor: Executor = Executor { it.run() }
+    ) {
+        nsdManager.registerService(si, NsdManager.PROTOCOL_DNS_SD, executor, record)
+        // TODO: add the callback check for the update.
+    }
+
     private fun resolveService(discoveredInfo: NsdServiceInfo): NsdServiceInfo {
         val record = NsdResolveRecord()
         nsdManager.resolveService(discoveredInfo, Executor { it.run() }, record)
diff --git a/tests/unit/java/android/net/nsd/NsdManagerTest.java b/tests/unit/java/android/net/nsd/NsdManagerTest.java
index 550a9ee..461ead8 100644
--- a/tests/unit/java/android/net/nsd/NsdManagerTest.java
+++ b/tests/unit/java/android/net/nsd/NsdManagerTest.java
@@ -38,6 +38,7 @@
 
 import androidx.test.filters.SmallTest;
 
+import com.android.modules.utils.build.SdkLevel;
 import com.android.testutils.DevSdkIgnoreRule;
 import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.FunctionalUtils.ThrowingConsumer;
@@ -86,73 +87,81 @@
     @Test
     @EnableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testResolveServiceS() throws Exception {
-        verify(mServiceConn, never()).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ false);
         doTestResolveService();
     }
 
     @Test
     @DisableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testResolveServicePreS() throws Exception {
-        verify(mServiceConn).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ true);
         doTestResolveService();
     }
 
     @Test
     @EnableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testDiscoverServiceS() throws Exception {
-        verify(mServiceConn, never()).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ false);
         doTestDiscoverService();
     }
 
     @Test
     @DisableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testDiscoverServicePreS() throws Exception {
-        verify(mServiceConn).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ true);
         doTestDiscoverService();
     }
 
     @Test
     @EnableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testParallelResolveServiceS() throws Exception {
-        verify(mServiceConn, never()).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ false);
         doTestParallelResolveService();
     }
 
     @Test
     @DisableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testParallelResolveServicePreS() throws Exception {
-        verify(mServiceConn).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ true);
         doTestParallelResolveService();
     }
 
     @Test
     @EnableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testInvalidCallsS() throws Exception {
-        verify(mServiceConn, never()).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ false);
         doTestInvalidCalls();
     }
 
     @Test
     @DisableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testInvalidCallsPreS() throws Exception {
-        verify(mServiceConn).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ true);
         doTestInvalidCalls();
     }
 
     @Test
     @EnableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testRegisterServiceS() throws Exception {
-        verify(mServiceConn, never()).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ false);
         doTestRegisterService();
     }
 
     @Test
     @DisableCompatChanges(ConnectivityCompatChanges.RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     public void testRegisterServicePreS() throws Exception {
-        verify(mServiceConn).startDaemon();
+        verifyDaemonStarted(/* targetSdkPreS= */ true);
         doTestRegisterService();
     }
 
+    private void verifyDaemonStarted(boolean targetSdkPreS) throws Exception {
+        if (targetSdkPreS && !SdkLevel.isAtLeastV()) {
+            verify(mServiceConn).startDaemon();
+        } else {
+            verify(mServiceConn, never()).startDaemon();
+        }
+    }
+
     private void doTestResolveService() throws Exception {
         NsdManager manager = mManager;
 
@@ -196,6 +205,22 @@
         verify(listener2, timeout(mTimeoutMs).times(1)).onServiceResolved(reply);
     }
 
+    @Test
+    public void testRegisterServiceWithAdvertisingRequest() throws Exception {
+        final NsdManager manager = mManager;
+        final NsdServiceInfo request = new NsdServiceInfo("another_name2", "another_type2");
+        request.setPort(2203);
+        final AdvertisingRequest advertisingRequest = new AdvertisingRequest.Builder(request,
+                PROTOCOL).build();
+        final NsdManager.RegistrationListener listener = mock(
+                NsdManager.RegistrationListener.class);
+
+        manager.registerService(advertisingRequest, Runnable::run, listener);
+        int key4 = getRequestKey(req -> verify(mServiceConn).registerService(req.capture(), any()));
+        mCallback.onRegisterServiceSucceeded(key4, request);
+        verify(listener, timeout(mTimeoutMs).times(1)).onServiceRegistered(request);
+    }
+
     private void doTestRegisterService() throws Exception {
         NsdManager manager = mManager;
 
@@ -346,8 +371,19 @@
         NsdManager.ResolveListener listener3 = mock(NsdManager.ResolveListener.class);
 
         NsdServiceInfo invalidService = new NsdServiceInfo(null, null);
-        NsdServiceInfo validService = new NsdServiceInfo("a_name", "a_type");
+        NsdServiceInfo validService = new NsdServiceInfo("a_name", "_a_type._tcp");
+        NsdServiceInfo otherServiceWithSubtype = new NsdServiceInfo("b_name", "_a_type._tcp,_sub1");
+        NsdServiceInfo validServiceDuplicate = new NsdServiceInfo("a_name", "_a_type._tcp");
+        NsdServiceInfo validServiceSubtypeUpdate = new NsdServiceInfo("a_name",
+                "_a_type._tcp,_sub1,_s2");
+        NsdServiceInfo otherSubtypeUpdate = new NsdServiceInfo("a_name", "_a_type._tcp,_sub1,_s3");
+        NsdServiceInfo dotSyntaxSubtypeUpdate = new NsdServiceInfo("a_name", "_sub1._a_type._tcp");
         validService.setPort(2222);
+        otherServiceWithSubtype.setPort(2222);
+        validServiceDuplicate.setPort(2222);
+        validServiceSubtypeUpdate.setPort(2222);
+        otherSubtypeUpdate.setPort(2222);
+        dotSyntaxSubtypeUpdate.setPort(2222);
 
         // Service registration
         //  - invalid arguments
@@ -358,7 +394,21 @@
         mustFail(() -> { manager.registerService(validService, -1, listener1); });
         mustFail(() -> { manager.registerService(validService, PROTOCOL, null); });
         manager.registerService(validService, PROTOCOL, listener1);
-        //  - listener already registered
+        //  - update without subtype is not allowed
+        mustFail(() -> { manager.registerService(validServiceDuplicate, PROTOCOL, listener1); });
+        //  - update with subtype is allowed
+        manager.registerService(validServiceSubtypeUpdate, PROTOCOL, listener1);
+        //  - re-updating to the same subtype is allowed
+        manager.registerService(validServiceSubtypeUpdate, PROTOCOL, listener1);
+        //  - updating to other subtypes is allowed
+        manager.registerService(otherSubtypeUpdate, PROTOCOL, listener1);
+        //  - update back to the service without subtype is allowed
+        manager.registerService(validService, PROTOCOL, listener1);
+        //  - updating to a subtype with _sub._type syntax is not allowed
+        mustFail(() -> { manager.registerService(dotSyntaxSubtypeUpdate, PROTOCOL, listener1); });
+        //  - updating to a different service name is not allowed
+        mustFail(() -> { manager.registerService(otherServiceWithSubtype, PROTOCOL, listener1); });
+        //  - listener already registered, and not using subtypes
         mustFail(() -> { manager.registerService(validService, PROTOCOL, listener1); });
         manager.unregisterService(listener1);
         // TODO: make listener immediately reusable
diff --git a/tests/unit/java/com/android/server/NsdServiceTest.java b/tests/unit/java/com/android/server/NsdServiceTest.java
index 4dc96f1..87e7967 100644
--- a/tests/unit/java/com/android/server/NsdServiceTest.java
+++ b/tests/unit/java/com/android/server/NsdServiceTest.java
@@ -150,6 +150,9 @@
 @SmallTest
 @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.S_V2)
 public class NsdServiceTest {
+    @Rule
+    public final DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+
     static final int PROTOCOL = NsdManager.PROTOCOL_DNS_SD;
     private static final long CLEANUP_DELAY_MS = 500;
     private static final long TIMEOUT_MS = 500;
@@ -255,6 +258,8 @@
         }
     }
 
+    // Native mdns provided by Netd is removed after U.
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     @Test
     @DisableCompatChanges({
             RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER,
@@ -287,6 +292,7 @@
     @Test
     @EnableCompatChanges(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testNoDaemonStartedWhenClientsConnect() throws Exception {
         // Creating an NsdManager will not cause daemon startup.
         connectClient(mService);
@@ -322,6 +328,7 @@
     @Test
     @EnableCompatChanges(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testClientRequestsAreGCedAtDisconnection() throws Exception {
         final NsdManager client = connectClient(mService);
         final INsdManagerCallback cb1 = getCallback();
@@ -366,6 +373,7 @@
     @Test
     @EnableCompatChanges(RUN_NATIVE_NSD_ONLY_IF_LEGACY_APPS_T_AND_LATER)
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testCleanupDelayNoRequestActive() throws Exception {
         final NsdManager client = connectClient(mService);
 
@@ -402,6 +410,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testDiscoverOnTetheringDownstream() throws Exception {
         final NsdManager client = connectClient(mService);
         final int interfaceIdx = 123;
@@ -500,6 +509,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testDiscoverOnBlackholeNetwork() throws Exception {
         final NsdManager client = connectClient(mService);
         final DiscoveryListener discListener = mock(DiscoveryListener.class);
@@ -532,6 +542,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testServiceRegistrationSuccessfulAndFailed() throws Exception {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -586,6 +597,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testServiceDiscoveryFailed() throws Exception {
         final NsdManager client = connectClient(mService);
         final DiscoveryListener discListener = mock(DiscoveryListener.class);
@@ -618,6 +630,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testServiceResolutionFailed() throws Exception {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -653,6 +666,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testGettingAddressFailed() throws Exception {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -704,6 +718,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testNoCrashWhenProcessResolutionAfterBinderDied() throws Exception {
         final NsdManager client = connectClient(mService);
         final INsdManagerCallback cb = getCallback();
@@ -724,6 +739,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testStopServiceResolution() {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -750,6 +766,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testStopResolutionFailed() {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -775,6 +792,7 @@
 
     @Test @DevSdkIgnoreRule.IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testStopResolutionDuringGettingAddress() throws RemoteException {
         final NsdManager client = connectClient(mService);
         final NsdServiceInfo request = new NsdServiceInfo(SERVICE_NAME, SERVICE_TYPE);
@@ -956,6 +974,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testMdnsDiscoveryManagerFeature() {
         // Create NsdService w/o feature enabled.
         final NsdManager client = connectClient(mService);
@@ -1203,6 +1222,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testMdnsAdvertiserFeatureFlagging() {
         // Create NsdService w/o feature enabled.
         final NsdManager client = connectClient(mService);
@@ -1241,6 +1261,7 @@
 
     @Test
     @DisableCompatChanges(ENABLE_PLATFORM_MDNS_BACKEND)
+    @DevSdkIgnoreRule.IgnoreAfter(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
     public void testTypeSpecificFeatureFlagging() {
         doReturn("_type1._tcp:flag1,_type2._tcp:flag2").when(mDeps).getTypeAllowlistFlags();
         doReturn(true).when(mDeps).isFeatureEnabled(any(),
diff --git a/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
index 12758c6..4e15d5f 100644
--- a/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/RoutingCoordinatorServiceTest.kt
@@ -18,14 +18,17 @@
 
 import android.net.INetd
 import android.os.Build
+import android.util.Log
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.tryTest
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.test.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.mockito.ArgumentMatchers.any
 import org.mockito.Mockito.inOrder
 import org.mockito.Mockito.mock
-import kotlin.test.assertFailsWith
 
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
@@ -46,9 +49,15 @@
         inOrder.verify(mNetd).tetherAddForward("from2", "to1")
         inOrder.verify(mNetd).ipfwdAddInterfaceForward("from2", "to1")
 
-        assertFailsWith<IllegalStateException> {
-            // Can't add the same pair again
+        val hasFailed = AtomicBoolean(false)
+        val prevHandler = Log.setWtfHandler { tag, what, system ->
+            hasFailed.set(true)
+        }
+        tryTest {
             mService.addInterfaceForward("from2", "to1")
+            assertTrue(hasFailed.get())
+        } cleanup {
+            Log.setWtfHandler(prevHandler)
         }
 
         mService.removeInterfaceForward("from1", "to1")
diff --git a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
index 196f73f..4b1f166 100644
--- a/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/mdns/MdnsRecordRepositoryTest.kt
@@ -22,6 +22,12 @@
 import android.os.Build
 import android.os.HandlerThread
 import com.android.server.connectivity.mdns.MdnsAnnouncer.AnnouncementInfo
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_A
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_AAAA
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_ANY
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_PTR
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_SRV
+import com.android.server.connectivity.mdns.MdnsRecord.TYPE_TXT
 import com.android.server.connectivity.mdns.MdnsRecordRepository.Dependencies
 import com.android.server.connectivity.mdns.MdnsRecordRepository.getReverseDnsAddress
 import com.android.server.connectivity.mdns.MdnsServiceInfo.TextEntry
@@ -38,6 +44,7 @@
 import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.assertTrue
+import kotlin.test.fail
 import org.junit.After
 import org.junit.Before
 import org.junit.Test
@@ -398,31 +405,31 @@
                         true /* cacheFlush */,
                         120000L /* ttlMillis */,
                         v4AddrRev,
-                        intArrayOf(MdnsRecord.TYPE_PTR)),
+                        intArrayOf(TYPE_PTR)),
                 MdnsNsecRecord(TEST_HOSTNAME,
                         0L /* receiptTimeMillis */,
                         true /* cacheFlush */,
                         120000L /* ttlMillis */,
                         TEST_HOSTNAME,
-                        intArrayOf(MdnsRecord.TYPE_A, MdnsRecord.TYPE_AAAA)),
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
                 MdnsNsecRecord(v6Addr1Rev,
                         0L /* receiptTimeMillis */,
                         true /* cacheFlush */,
                         120000L /* ttlMillis */,
                         v6Addr1Rev,
-                        intArrayOf(MdnsRecord.TYPE_PTR)),
+                        intArrayOf(TYPE_PTR)),
                 MdnsNsecRecord(v6Addr2Rev,
                         0L /* receiptTimeMillis */,
                         true /* cacheFlush */,
                         120000L /* ttlMillis */,
                         v6Addr2Rev,
-                        intArrayOf(MdnsRecord.TYPE_PTR)),
+                        intArrayOf(TYPE_PTR)),
                 MdnsNsecRecord(serviceName,
                         0L /* receiptTimeMillis */,
                         true /* cacheFlush */,
                         4500000L /* ttlMillis */,
                         serviceName,
-                        intArrayOf(MdnsRecord.TYPE_TXT, MdnsRecord.TYPE_SRV))
+                        intArrayOf(TYPE_TXT, TYPE_SRV))
         ), packet.additionalRecords)
     }
 
@@ -504,94 +511,276 @@
         assertEquals(7, replyCaseInsensitive.additionalAnswers.size)
     }
 
-    @Test
-    fun testGetReply() {
-        doGetReplyTest(queryWithSubtype = false)
-    }
-
-    @Test
-    fun testGetReply_WithSubtype() {
-        doGetReplyTest(queryWithSubtype = true)
-    }
-
-    private fun doGetReplyTest(queryWithSubtype: Boolean) {
-        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
-        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1,
-                setOf(TEST_SUBTYPE, TEST_SUBTYPE2))
-        val queriedName = if (!queryWithSubtype) arrayOf("_testservice", "_tcp", "local")
-                else arrayOf(TEST_SUBTYPE, "_sub", "_testservice", "_tcp", "local")
-
-        val questions = listOf(MdnsPointerRecord(queriedName, false /* isUnicast */))
-        val query = MdnsPacket(0 /* flags */, questions, listOf() /* answers */,
+    /**
+     * Creates mDNS query packet with given query names and types.
+     */
+    private fun makeQuery(vararg queries: Pair<Int, Array<String>>): MdnsPacket {
+        val questions = queries.map { (type, name) -> makeQuestionRecord(name, type) }
+        return MdnsPacket(0 /* flags */, questions, listOf() /* answers */,
                 listOf() /* authorityRecords */, listOf() /* additionalRecords */)
+    }
+
+    private fun makeQuestionRecord(name: Array<String>, type: Int): MdnsRecord {
+        when (type) {
+            TYPE_PTR -> return MdnsPointerRecord(name, false /* isUnicast */)
+            TYPE_SRV -> return MdnsServiceRecord(name, false /* isUnicast */)
+            TYPE_TXT -> return MdnsTextRecord(name, false /* isUnicast */)
+            TYPE_A, TYPE_AAAA -> return MdnsInetAddressRecord(name, type, false /* isUnicast */)
+            else -> fail("Unexpected question type: $type")
+        }
+    }
+
+    @Test
+    fun testGetReply_singlePtrQuestion_returnsSrvTxtAddressNsecRecords() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
         val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(TYPE_PTR to arrayOf("_testservice", "_tcp", "local"))
         val reply = repository.getReply(query, src)
 
         assertNotNull(reply)
-        // Source address is IPv4
-        assertEquals(MdnsConstants.getMdnsIPv4Address(), reply.destination.address)
-        assertEquals(MdnsConstants.MDNS_PORT, reply.destination.port)
-
-        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
-
         assertEquals(listOf(
                 MdnsPointerRecord(
-                        queriedName,
-                        0L /* receiptTimeMillis */,
-                        false /* cacheFlush */,
-                        LONG_TTL,
-                        serviceName),
-        ), reply.answers)
-
+                    arrayOf("_testservice", "_tcp", "local"), 0L, false, LONG_TTL, serviceName)),
+            reply.answers)
         assertEquals(listOf(
-            MdnsTextRecord(
-                    serviceName,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    LONG_TTL,
-                    listOf() /* entries */),
-            MdnsServiceRecord(
-                    serviceName,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    SHORT_TTL,
-                    0 /* servicePriority */,
-                    0 /* serviceWeight */,
-                    TEST_PORT,
-                    TEST_HOSTNAME),
-            MdnsInetAddressRecord(
-                    TEST_HOSTNAME,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    SHORT_TTL,
-                    TEST_ADDRESSES[0].address),
-            MdnsInetAddressRecord(
-                    TEST_HOSTNAME,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    SHORT_TTL,
-                    TEST_ADDRESSES[1].address),
-            MdnsInetAddressRecord(
-                    TEST_HOSTNAME,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    SHORT_TTL,
-                    TEST_ADDRESSES[2].address),
-            MdnsNsecRecord(
-                    serviceName,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    LONG_TTL,
-                    serviceName /* nextDomain */,
-                    intArrayOf(MdnsRecord.TYPE_TXT, MdnsRecord.TYPE_SRV)),
-            MdnsNsecRecord(
-                    TEST_HOSTNAME,
-                    0L /* receiptTimeMillis */,
-                    true /* cacheFlush */,
-                    SHORT_TTL,
-                    TEST_HOSTNAME /* nextDomain */,
-                    intArrayOf(MdnsRecord.TYPE_A, MdnsRecord.TYPE_AAAA)),
-        ), reply.additionalAnswers)
+                MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf()),
+                MdnsServiceRecord(serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[0].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[1].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[2].address),
+                MdnsNsecRecord(serviceName, 0L, true, LONG_TTL, serviceName /* nextDomain */,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)),
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
+            ), reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_singleSubtypePtrQuestion_returnsSrvTxtAddressNsecRecords() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(
+                TYPE_PTR to arrayOf(TEST_SUBTYPE, "_sub", "_testservice", "_tcp", "local"))
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(
+                MdnsPointerRecord(
+                    arrayOf(TEST_SUBTYPE, "_sub", "_testservice", "_tcp", "local"), 0L, false,
+                    LONG_TTL, serviceName)),
+            reply.answers)
+        assertEquals(listOf(
+                MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf()),
+                MdnsServiceRecord(serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[0].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[1].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[2].address),
+                MdnsNsecRecord(serviceName, 0L, true, LONG_TTL, serviceName /* nextDomain */,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)),
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
+            ), reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_duplicatePtrQuestions_doesNotReturnDuplicateRecords() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(
+                TYPE_PTR to arrayOf("_testservice", "_tcp", "local"),
+                TYPE_PTR to arrayOf("_testservice", "_tcp", "local"))
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(
+                MdnsPointerRecord(
+                    arrayOf("_testservice", "_tcp", "local"), 0L, false, LONG_TTL, serviceName)),
+            reply.answers)
+        assertEquals(listOf(
+                MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf()),
+                MdnsServiceRecord(serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[0].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[1].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[2].address),
+                MdnsNsecRecord(serviceName, 0L, true, LONG_TTL, serviceName /* nextDomain */,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)),
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
+            ), reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_multiplePtrQuestionsWithSubtype_doesNotReturnDuplicateRecords() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(
+                TYPE_PTR to arrayOf("_testservice", "_tcp", "local"),
+                TYPE_PTR to arrayOf(TEST_SUBTYPE, "_sub", "_testservice", "_tcp", "local"))
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(
+                MdnsPointerRecord(
+                    arrayOf("_testservice", "_tcp", "local"), 0L, false, LONG_TTL, serviceName),
+                MdnsPointerRecord(
+                    arrayOf(TEST_SUBTYPE, "_sub", "_testservice", "_tcp", "local"),
+                    0L, false, LONG_TTL, serviceName)),
+            reply.answers)
+        assertEquals(listOf(
+                MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf()),
+                MdnsServiceRecord(serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[0].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[1].address),
+                MdnsInetAddressRecord(
+                    TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[2].address),
+                MdnsNsecRecord(serviceName, 0L, true, LONG_TTL, serviceName /* nextDomain */,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)),
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_A, TYPE_AAAA)),
+            ), reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_txtQuestion_returnsNoNsecRecord() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(TYPE_TXT to serviceName)
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf())),
+                reply.answers)
+        // No NSEC records because the reply doesn't include the SRV record
+        assertTrue(reply.additionalAnswers.isEmpty())
+    }
+
+    @Test
+    fun testGetReply_AAAAQuestionButNoIpv6Address_returnsNsecRecord() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(
+                TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE),
+                listOf(LinkAddress(parseNumericAddress("192.0.2.111"), 24)))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+
+        val query = makeQuery(TYPE_AAAA to TEST_HOSTNAME)
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertTrue(reply.answers.isEmpty())
+        assertEquals(listOf(
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, LONG_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_AAAA))),
+            reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_ptrAndSrvQuestions_doesNotReturnSrvRecordInAdditionalAnswerSection() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(
+                TYPE_PTR to arrayOf("_testservice", "_tcp", "local"),
+                TYPE_SRV to serviceName)
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(
+                MdnsPointerRecord(
+                    arrayOf("_testservice", "_tcp", "local"), 0L, false, LONG_TTL, serviceName),
+                MdnsServiceRecord(
+                    serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME)),
+            reply.answers)
+        assertFalse(reply.additionalAnswers.any { it -> it is MdnsServiceRecord })
+    }
+
+    @Test
+    fun testGetReply_srvTxtAddressQuestions_returnsAllRecordsInAnswerSectionExceptNsec() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val src = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val serviceName = arrayOf("MyTestService", "_testservice", "_tcp", "local")
+
+        val query = makeQuery(
+                TYPE_SRV to serviceName,
+                TYPE_TXT to serviceName,
+                TYPE_SRV to serviceName,
+                TYPE_A to TEST_HOSTNAME,
+                TYPE_AAAA to TEST_HOSTNAME)
+        val reply = repository.getReply(query, src)
+
+        assertNotNull(reply)
+        assertEquals(listOf(
+                MdnsServiceRecord(serviceName, 0L, true, SHORT_TTL, 0, 0, TEST_PORT, TEST_HOSTNAME),
+                MdnsTextRecord(serviceName, 0L, true, LONG_TTL, listOf()),
+                MdnsInetAddressRecord(
+                        TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[0].address),
+                MdnsInetAddressRecord(
+                        TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[1].address),
+                MdnsInetAddressRecord(
+                        TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_ADDRESSES[2].address)),
+            reply.answers)
+        assertEquals(listOf(
+                MdnsNsecRecord(serviceName, 0L, true, LONG_TTL, serviceName /* nextDomain */,
+                        intArrayOf(TYPE_TXT, TYPE_SRV)),
+                MdnsNsecRecord(TEST_HOSTNAME, 0L, true, SHORT_TTL, TEST_HOSTNAME /* nextDomain */,
+                        intArrayOf(TYPE_A, TYPE_AAAA))),
+            reply.additionalAnswers)
+    }
+
+    @Test
+    fun testGetReply_queryWithIpv4Address_replyWithIpv4Address() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val query = makeQuery(TYPE_PTR to arrayOf("_testservice", "_tcp", "local"))
+
+        val srcIpv4 = InetSocketAddress(parseNumericAddress("192.0.2.123"), 5353)
+        val replyIpv4 = repository.getReply(query, srcIpv4)
+
+        assertNotNull(replyIpv4)
+        assertEquals(MdnsConstants.getMdnsIPv4Address(), replyIpv4.destination.address)
+        assertEquals(MdnsConstants.MDNS_PORT, replyIpv4.destination.port)
+    }
+
+    @Test
+    fun testGetReply_queryWithIpv6Address_replyWithIpv6Address() {
+        val repository = MdnsRecordRepository(thread.looper, deps, TEST_HOSTNAME, flags)
+        repository.initWithService(TEST_SERVICE_ID_1, TEST_SERVICE_1, setOf(TEST_SUBTYPE))
+        val query = makeQuery(TYPE_PTR to arrayOf("_testservice", "_tcp", "local"))
+
+        val srcIpv6 = InetSocketAddress(parseNumericAddress("2001:db8::123"), 5353)
+        val replyIpv6 = repository.getReply(query, srcIpv6)
+
+        assertNotNull(replyIpv6)
+        assertEquals(MdnsConstants.getMdnsIPv6Address(), replyIpv6.destination.address)
+        assertEquals(MdnsConstants.MDNS_PORT, replyIpv6.destination.port)
     }
 
     @Test
@@ -1015,13 +1204,6 @@
                         SHORT_TTL,
                         TEST_ADDRESSES[2].address),
                 MdnsNsecRecord(
-                        serviceName,
-                        0L /* receiptTimeMillis */,
-                        true /* cacheFlush */,
-                        LONG_TTL,
-                        serviceName /* nextDomain */,
-                        intArrayOf(MdnsRecord.TYPE_SRV)),
-                MdnsNsecRecord(
                         TEST_HOSTNAME,
                         0L /* receiptTimeMillis */,
                         true /* cacheFlush */,
@@ -1064,8 +1246,9 @@
     serviceId: Int,
     serviceInfo: NsdServiceInfo,
     subtypes: Set<String> = setOf(),
+    addresses: List<LinkAddress> = TEST_ADDRESSES
 ): AnnouncementInfo {
-    updateAddresses(TEST_ADDRESSES)
+    updateAddresses(addresses)
     serviceInfo.setSubtypes(subtypes)
     addService(serviceId, serviceInfo)
     val probingInfo = setServiceProbing(serviceId)
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index d3765f1..6a5ea4b 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -2,9 +2,7 @@
   "presubmit": [
     {
       "name": "CtsThreadNetworkTestCases"
-    }
-  ],
-  "presubmit": [
+    },
     {
       "name": "ThreadNetworkUnitTests"
     }
diff --git a/thread/flags/Android.bp b/thread/flags/Android.bp
new file mode 100644
index 0000000..225022c
--- /dev/null
+++ b/thread/flags/Android.bp
@@ -0,0 +1,35 @@
+//
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+aconfig_declarations {
+    name: "thread_aconfig_flags",
+    package: "com.android.net.thread.flags",
+    srcs: ["thread_base.aconfig"],
+}
+
+java_aconfig_library {
+    name: "thread_aconfig_flags_lib",
+    aconfig_declarations: "thread_aconfig_flags",
+    min_sdk_version: "30",
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.tethering",
+    ],
+}
diff --git a/thread/flags/thread_base.aconfig b/thread/flags/thread_base.aconfig
index bf1f288..f73ea6b 100644
--- a/thread/flags/thread_base.aconfig
+++ b/thread/flags/thread_base.aconfig
@@ -6,3 +6,10 @@
     description: "Controls whether the Android Thread feature is enabled"
     bug: "301473012"
 }
+
+flag {
+    name: "thread_user_restriction_enabled"
+    namespace: "thread_network"
+    description: "Controls whether user restriction on thread networks is enabled"
+    bug: "307679182"
+}
diff --git a/thread/service/Android.bp b/thread/service/Android.bp
index 35ae3c2..69295cc 100644
--- a/thread/service/Android.bp
+++ b/thread/service/Android.bp
@@ -35,9 +35,13 @@
     libs: [
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
+        "framework-location.stubs.module_lib",
+        "framework-wifi",
         "service-connectivity-pre-jarjar",
+        "ServiceConnectivityResources",
     ],
     static_libs: [
+        "modules-utils-shell-command-handler",
         "net-utils-device-common",
         "net-utils-device-common-netlink",
         "ot-daemon-aidl-java",
diff --git a/thread/service/java/com/android/server/thread/InfraInterfaceController.java b/thread/service/java/com/android/server/thread/InfraInterfaceController.java
index d7c49a0..be54cbc 100644
--- a/thread/service/java/com/android/server/thread/InfraInterfaceController.java
+++ b/thread/service/java/com/android/server/thread/InfraInterfaceController.java
@@ -36,8 +36,7 @@
      * @return an ICMPv6 socket file descriptor on the Infrastructure network interface.
      * @throws IOException when fails to create the socket.
      */
-    public static ParcelFileDescriptor createIcmp6Socket(String infraInterfaceName)
-            throws IOException {
+    public ParcelFileDescriptor createIcmp6Socket(String infraInterfaceName) throws IOException {
         return ParcelFileDescriptor.adoptFd(nativeCreateIcmp6Socket(infraInterfaceName));
     }
 
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 1d8cd73..cd59e4e 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -36,7 +36,6 @@
 import static android.net.thread.ThreadNetworkException.ERROR_RESPONSE_BAD_FORMAT;
 import static android.net.thread.ThreadNetworkException.ERROR_TIMEOUT;
 import static android.net.thread.ThreadNetworkException.ERROR_UNSUPPORTED_CHANNEL;
-import static android.net.thread.ThreadNetworkException.ErrorCode;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
@@ -54,6 +53,8 @@
 import android.Manifest.permission;
 import android.annotation.NonNull;
 import android.annotation.Nullable;
+import android.annotation.RequiresPermission;
+import android.annotation.TargetApi;
 import android.content.Context;
 import android.net.ConnectivityManager;
 import android.net.IpPrefix;
@@ -82,6 +83,8 @@
 import android.net.thread.PendingOperationalDataset;
 import android.net.thread.ThreadNetworkController;
 import android.net.thread.ThreadNetworkController.DeviceRole;
+import android.net.thread.ThreadNetworkException.ErrorCode;
+import android.os.Build;
 import android.os.Handler;
 import android.os.HandlerThread;
 import android.os.IBinder;
@@ -117,10 +120,11 @@
  *
  * <p>Threading model: This class is not Thread-safe and should only be accessed from the
  * ThreadNetworkService class. Additional attention should be paid to handle the threading code
- * correctly: 1. All member fields other than `mHandler` and `mContext` MUST be accessed from
- * `mHandlerThread` 2. In the @Override methods, the actual work MUST be dispatched to the
+ * correctly: 1. All member fields other than `mHandler` and `mContext` MUST be accessed from the
+ * thread of `mHandler` 2. In the @Override methods, the actual work MUST be dispatched to the
  * HandlerThread except for arguments or permissions checking
  */
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
 final class ThreadNetworkControllerService extends IThreadNetworkController.Stub {
     private static final String TAG = "ThreadNetworkService";
 
@@ -129,23 +133,25 @@
     private final Context mContext;
     private final Handler mHandler;
 
-    // Below member fields can only be accessed from the handler thread (`mHandlerThread`). In
+    // Below member fields can only be accessed from the handler thread (`mHandler`). In
     // particular, the constructor does not run on the handler thread, so it must not touch any of
     // the non-final fields, nor must it mutate any of the non-final fields inside these objects.
 
-    private final HandlerThread mHandlerThread;
     private final NetworkProvider mNetworkProvider;
     private final Supplier<IOtDaemon> mOtDaemonSupplier;
     private final ConnectivityManager mConnectivityManager;
     private final TunInterfaceController mTunIfController;
+    private final InfraInterfaceController mInfraIfController;
     private final LinkProperties mLinkProperties = new LinkProperties();
     private final OtDaemonCallbackProxy mOtDaemonCallbackProxy = new OtDaemonCallbackProxy();
 
     // TODO(b/308310823): read supported channel from Thread dameon
     private final int mSupportedChannelMask = 0x07FFF800; // from channel 11 to 26
 
-    private IOtDaemon mOtDaemon;
-    private NetworkAgent mNetworkAgent;
+    @Nullable private IOtDaemon mOtDaemon;
+    @Nullable private NetworkAgent mNetworkAgent;
+    @Nullable private NetworkAgent mTestNetworkAgent;
+
     private MulticastRoutingConfig mUpstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
     private MulticastRoutingConfig mDownstreamMulticastRoutingConfig = CONFIG_FORWARD_NONE;
     private Network mUpstreamNetwork;
@@ -159,18 +165,19 @@
     @VisibleForTesting
     ThreadNetworkControllerService(
             Context context,
-            HandlerThread handlerThread,
+            Handler handler,
             NetworkProvider networkProvider,
             Supplier<IOtDaemon> otDaemonSupplier,
             ConnectivityManager connectivityManager,
-            TunInterfaceController tunIfController) {
+            TunInterfaceController tunIfController,
+            InfraInterfaceController infraIfController) {
         mContext = context;
-        mHandlerThread = handlerThread;
-        mHandler = new Handler(handlerThread.getLooper());
+        mHandler = handler;
         mNetworkProvider = networkProvider;
         mOtDaemonSupplier = otDaemonSupplier;
         mConnectivityManager = connectivityManager;
         mTunIfController = tunIfController;
+        mInfraIfController = infraIfController;
         mUpstreamNetworkRequest = newUpstreamNetworkRequest();
         mNetworkToInterface = new HashMap<Network, String>();
         mBorderRouterConfig = new BorderRouterConfigurationParcel();
@@ -184,19 +191,12 @@
 
         return new ThreadNetworkControllerService(
                 context,
-                handlerThread,
+                new Handler(handlerThread.getLooper()),
                 networkProvider,
                 () -> IOtDaemon.Stub.asInterface(ServiceManagerWrapper.waitForService("ot_daemon")),
                 context.getSystemService(ConnectivityManager.class),
-                new TunInterfaceController(TUN_IF_NAME));
-    }
-
-    private static NetworkCapabilities newNetworkCapabilities() {
-        return new NetworkCapabilities.Builder()
-                .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
-                .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
-                .build();
+                new TunInterfaceController(TUN_IF_NAME),
+                new InfraInterfaceController());
     }
 
     private static Inet6Address bytesToInet6Address(byte[] addressBytes) {
@@ -256,7 +256,7 @@
     @Override
     public void setTestNetworkAsUpstream(
             @Nullable String testNetworkInterfaceName, @NonNull IOperationReceiver receiver) {
-        enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         Log.i(TAG, "setTestNetworkAsUpstream: " + testNetworkInterfaceName);
         mHandler.post(() -> setTestNetworkAsUpstreamInternal(testNetworkInterfaceName, receiver));
@@ -294,6 +294,8 @@
     }
 
     private IOtDaemon getOtDaemon() throws RemoteException {
+        checkOnHandlerThread();
+
         if (mOtDaemon != null) {
             return mOtDaemon;
         }
@@ -302,9 +304,9 @@
         if (otDaemon == null) {
             throw new RemoteException("Internal error: failed to start OT daemon");
         }
-        otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         otDaemon.initialize(mTunIfController.getTunFd());
         otDaemon.registerStateCallback(mOtDaemonCallbackProxy, -1);
+        otDaemon.asBinder().linkToDeath(() -> mHandler.post(this::onOtDaemonDied), 0);
         mOtDaemon = otDaemon;
         return mOtDaemon;
     }
@@ -331,6 +333,7 @@
                     mLinkProperties.setMtu(TunInterfaceController.MTU);
                     mConnectivityManager.registerNetworkProvider(mNetworkProvider);
                     requestUpstreamNetwork();
+                    requestThreadNetwork();
 
                     initializeOtDaemon();
                 });
@@ -411,35 +414,54 @@
     private void requestThreadNetwork() {
         mConnectivityManager.registerNetworkCallback(
                 new NetworkRequest.Builder()
+                        // clearCapabilities() is needed to remove forbidden capabilities and UID
+                        // requirement.
                         .clearCapabilities()
                         .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
-                        .removeForbiddenCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
                         .build(),
                 new ThreadNetworkCallback(),
                 mHandler);
     }
 
+    /** Injects a {@link NetworkAgent} for testing. */
+    @VisibleForTesting
+    void setTestNetworkAgent(@Nullable NetworkAgent testNetworkAgent) {
+        mTestNetworkAgent = testNetworkAgent;
+    }
+
+    private NetworkAgent newNetworkAgent() {
+        if (mTestNetworkAgent != null) {
+            return mTestNetworkAgent;
+        }
+
+        final NetworkCapabilities netCaps =
+                new NetworkCapabilities.Builder()
+                        .addTransportType(NetworkCapabilities.TRANSPORT_THREAD)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_LOCAL_NETWORK)
+                        .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED)
+                        .build();
+        final NetworkScore score =
+                new NetworkScore.Builder()
+                        .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK)
+                        .build();
+        return new NetworkAgent(
+                mContext,
+                mHandler.getLooper(),
+                TAG,
+                netCaps,
+                mLinkProperties,
+                newLocalNetworkConfig(),
+                score,
+                new NetworkAgentConfig.Builder().build(),
+                mNetworkProvider) {};
+    }
+
     private void registerThreadNetwork() {
         if (mNetworkAgent != null) {
             return;
         }
-        NetworkCapabilities netCaps = newNetworkCapabilities();
-        NetworkScore score =
-                new NetworkScore.Builder()
-                        .setKeepConnectedReason(NetworkScore.KEEP_CONNECTED_LOCAL_NETWORK)
-                        .build();
-        requestThreadNetwork();
-        mNetworkAgent =
-                new NetworkAgent(
-                        mContext,
-                        mHandlerThread.getLooper(),
-                        TAG,
-                        netCaps,
-                        mLinkProperties,
-                        newLocalNetworkConfig(),
-                        score,
-                        new NetworkAgentConfig.Builder().build(),
-                        mNetworkProvider) {};
+
+        mNetworkAgent = newNetworkAgent();
         mNetworkAgent.register();
         mNetworkAgent.markConnected();
         Log.i(TAG, "Registered Thread network");
@@ -590,29 +612,29 @@
         return -1;
     }
 
-    private void enforceAllCallingPermissionsGranted(String... permissions) {
+    private void enforceAllPermissionsGranted(String... permissions) {
         for (String permission : permissions) {
-            mContext.enforceCallingPermission(
+            mContext.enforceCallingOrSelfPermission(
                     permission, "Permission " + permission + " is missing");
         }
     }
 
     @Override
     public void registerStateCallback(IStateCallback stateCallback) throws RemoteException {
-        enforceAllCallingPermissionsGranted(permission.ACCESS_NETWORK_STATE);
+        enforceAllPermissionsGranted(permission.ACCESS_NETWORK_STATE);
         mHandler.post(() -> mOtDaemonCallbackProxy.registerStateCallback(stateCallback));
     }
 
     @Override
     public void unregisterStateCallback(IStateCallback stateCallback) throws RemoteException {
-        enforceAllCallingPermissionsGranted(permission.ACCESS_NETWORK_STATE);
+        enforceAllPermissionsGranted(permission.ACCESS_NETWORK_STATE);
         mHandler.post(() -> mOtDaemonCallbackProxy.unregisterStateCallback(stateCallback));
     }
 
     @Override
     public void registerOperationalDatasetCallback(IOperationalDatasetCallback callback)
             throws RemoteException {
-        enforceAllCallingPermissionsGranted(
+        enforceAllPermissionsGranted(
                 permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
         mHandler.post(() -> mOtDaemonCallbackProxy.registerDatasetCallback(callback));
     }
@@ -620,13 +642,13 @@
     @Override
     public void unregisterOperationalDatasetCallback(IOperationalDatasetCallback callback)
             throws RemoteException {
-        enforceAllCallingPermissionsGranted(
+        enforceAllPermissionsGranted(
                 permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
         mHandler.post(() -> mOtDaemonCallbackProxy.unregisterDatasetCallback(callback));
     }
 
     private void checkOnHandlerThread() {
-        if (Looper.myLooper() != mHandlerThread.getLooper()) {
+        if (Looper.myLooper() != mHandler.getLooper()) {
             Log.wtf(TAG, "Must be on the handler thread!");
         }
     }
@@ -675,7 +697,7 @@
     @Override
     public void join(
             @NonNull ActiveOperationalDataset activeDataset, @NonNull IOperationReceiver receiver) {
-        enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         OperationReceiverWrapper receiverWrapper = new OperationReceiverWrapper(receiver);
         mHandler.post(() -> joinInternal(activeDataset, receiverWrapper));
@@ -699,7 +721,7 @@
     public void scheduleMigration(
             @NonNull PendingOperationalDataset pendingDataset,
             @NonNull IOperationReceiver receiver) {
-        enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         OperationReceiverWrapper receiverWrapper = new OperationReceiverWrapper(receiver);
         mHandler.post(() -> scheduleMigrationInternal(pendingDataset, receiverWrapper));
@@ -722,7 +744,7 @@
 
     @Override
     public void leave(@NonNull IOperationReceiver receiver) throws RemoteException {
-        enforceAllCallingPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         mHandler.post(() -> leaveInternal(new OperationReceiverWrapper(receiver)));
     }
@@ -738,6 +760,32 @@
         }
     }
 
+    /**
+     * Sets the country code.
+     *
+     * @param countryCode 2 characters string country code (as defined in ISO 3166) to set.
+     * @param receiver the receiver to receive result of this operation
+     */
+    @RequiresPermission(PERMISSION_THREAD_NETWORK_PRIVILEGED)
+    public void setCountryCode(@NonNull String countryCode, @NonNull IOperationReceiver receiver) {
+        enforceAllPermissionsGranted(PERMISSION_THREAD_NETWORK_PRIVILEGED);
+
+        OperationReceiverWrapper receiverWrapper = new OperationReceiverWrapper(receiver);
+        mHandler.post(() -> setCountryCodeInternal(countryCode, receiverWrapper));
+    }
+
+    private void setCountryCodeInternal(
+            String countryCode, @NonNull OperationReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().setCountryCode(countryCode, newOtStatusReceiver(receiver));
+        } catch (RemoteException e) {
+            Log.e(TAG, "otDaemon.setCountryCode failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
     private void enableBorderRouting(String infraIfName) {
         if (mBorderRouterConfig.isBorderRoutingEnabled
                 && infraIfName.equals(mBorderRouterConfig.infraInterfaceName)) {
@@ -747,7 +795,7 @@
         try {
             mBorderRouterConfig.infraInterfaceName = infraIfName;
             mBorderRouterConfig.infraInterfaceIcmp6Socket =
-                    InfraInterfaceController.createIcmp6Socket(infraIfName);
+                    mInfraIfController.createIcmp6Socket(infraIfName);
             mBorderRouterConfig.isBorderRoutingEnabled = true;
 
             mOtDaemon.configureBorderRouter(
@@ -916,8 +964,8 @@
     }
 
     /**
-     * Handles and forwards Thread daemon callbacks. This class must be accessed from the {@code
-     * mHandlerThread}.
+     * Handles and forwards Thread daemon callbacks. This class must be accessed from the thread of
+     * {@code mHandler}.
      */
     private final class OtDaemonCallbackProxy extends IOtDaemonCallback.Stub {
         private final Map<IStateCallback, CallbackMetadata> mStateCallbacks = new HashMap<>();
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
new file mode 100644
index 0000000..b7b6233
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkCountryCode.java
@@ -0,0 +1,543 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.annotation.StringDef;
+import android.annotation.TargetApi;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.location.Address;
+import android.location.Geocoder;
+import android.location.Location;
+import android.location.LocationManager;
+import android.net.thread.IOperationReceiver;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.ActiveCountryCodeChangedCallback;
+import android.os.Build;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.connectivity.resources.R;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.connectivity.ConnectivityResources;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.time.Instant;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Provide functions for making changes to Thread Network country code. This Country Code is from
+ * location, WiFi or telephony configuration. This class sends Country Code to Thread Network native
+ * layer.
+ *
+ * <p>This class is thread-safe.
+ */
+@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+public class ThreadNetworkCountryCode {
+    private static final String TAG = "ThreadNetworkCountryCode";
+    // To be used when there is no country code available.
+    @VisibleForTesting public static final String DEFAULT_COUNTRY_CODE = "WW";
+
+    // Wait 1 hour between updates.
+    private static final long TIME_BETWEEN_LOCATION_UPDATES_MS = 1000L * 60 * 60 * 1;
+    // Minimum distance before an update is triggered, in meters. We don't need this to be too
+    // exact because all we care about is what country the user is in.
+    private static final float DISTANCE_BETWEEN_LOCALTION_UPDATES_METERS = 5_000.0f;
+
+    /** List of country code sources. */
+    @Retention(RetentionPolicy.SOURCE)
+    @StringDef(
+            prefix = "COUNTRY_CODE_SOURCE_",
+            value = {
+                COUNTRY_CODE_SOURCE_DEFAULT,
+                COUNTRY_CODE_SOURCE_LOCATION,
+                COUNTRY_CODE_SOURCE_OVERRIDE,
+                COUNTRY_CODE_SOURCE_TELEPHONY,
+                COUNTRY_CODE_SOURCE_TELEPHONY_LAST,
+                COUNTRY_CODE_SOURCE_WIFI,
+            })
+    private @interface CountryCodeSource {}
+
+    private static final String COUNTRY_CODE_SOURCE_DEFAULT = "Default";
+    private static final String COUNTRY_CODE_SOURCE_LOCATION = "Location";
+    private static final String COUNTRY_CODE_SOURCE_OVERRIDE = "Override";
+    private static final String COUNTRY_CODE_SOURCE_TELEPHONY = "Telephony";
+    private static final String COUNTRY_CODE_SOURCE_TELEPHONY_LAST = "TelephonyLast";
+    private static final String COUNTRY_CODE_SOURCE_WIFI = "Wifi";
+
+    private static final CountryCodeInfo DEFAULT_COUNTRY_CODE_INFO =
+            new CountryCodeInfo(DEFAULT_COUNTRY_CODE, COUNTRY_CODE_SOURCE_DEFAULT);
+
+    private final ConnectivityResources mResources;
+    private final Context mContext;
+    private final LocationManager mLocationManager;
+    @Nullable private final Geocoder mGeocoder;
+    private final ThreadNetworkControllerService mThreadNetworkControllerService;
+    private final WifiManager mWifiManager;
+    private final TelephonyManager mTelephonyManager;
+    private final SubscriptionManager mSubscriptionManager;
+    private final Map<Integer, TelephonyCountryCodeSlotInfo> mTelephonyCountryCodeSlotInfoMap =
+            new ArrayMap();
+
+    @Nullable private CountryCodeInfo mCurrentCountryCodeInfo;
+    @Nullable private CountryCodeInfo mLocationCountryCodeInfo;
+    @Nullable private CountryCodeInfo mOverrideCountryCodeInfo;
+    @Nullable private CountryCodeInfo mWifiCountryCodeInfo;
+    @Nullable private CountryCodeInfo mTelephonyCountryCodeInfo;
+    @Nullable private CountryCodeInfo mTelephonyLastCountryCodeInfo;
+
+    /** Container class to store Thread country code information. */
+    private static final class CountryCodeInfo {
+        private String mCountryCode;
+        @CountryCodeSource private String mSource;
+        private final Instant mUpdatedTimestamp;
+
+        public CountryCodeInfo(
+                String countryCode, @CountryCodeSource String countryCodeSource, Instant instant) {
+            mCountryCode = countryCode;
+            mSource = countryCodeSource;
+            mUpdatedTimestamp = instant;
+        }
+
+        public CountryCodeInfo(String countryCode, @CountryCodeSource String countryCodeSource) {
+            this(countryCode, countryCodeSource, Instant.now());
+        }
+
+        public String getCountryCode() {
+            return mCountryCode;
+        }
+
+        public boolean isCountryCodeMatch(CountryCodeInfo countryCodeInfo) {
+            if (countryCodeInfo == null) {
+                return false;
+            }
+
+            return Objects.equals(countryCodeInfo.mCountryCode, mCountryCode);
+        }
+
+        @Override
+        public String toString() {
+            return "CountryCodeInfo{ mCountryCode: "
+                    + mCountryCode
+                    + ", mSource: "
+                    + mSource
+                    + ", mUpdatedTimestamp: "
+                    + mUpdatedTimestamp
+                    + "}";
+        }
+    }
+
+    /** Container class to store country code per SIM slot. */
+    private static final class TelephonyCountryCodeSlotInfo {
+        public int slotIndex;
+        public String countryCode;
+        public String lastKnownCountryCode;
+        public Instant timestamp;
+
+        @Override
+        public String toString() {
+            return "TelephonyCountryCodeSlotInfo{ slotIndex: "
+                    + slotIndex
+                    + ", countryCode: "
+                    + countryCode
+                    + ", lastKnownCountryCode: "
+                    + lastKnownCountryCode
+                    + ", timestamp: "
+                    + timestamp
+                    + "}";
+        }
+    }
+
+    private boolean isLocationUseForCountryCodeEnabled() {
+        return mResources
+                .get()
+                .getBoolean(R.bool.config_thread_location_use_for_country_code_enabled);
+    }
+
+    public ThreadNetworkCountryCode(
+            LocationManager locationManager,
+            ThreadNetworkControllerService threadNetworkControllerService,
+            @Nullable Geocoder geocoder,
+            ConnectivityResources resources,
+            WifiManager wifiManager,
+            Context context,
+            TelephonyManager telephonyManager,
+            SubscriptionManager subscriptionManager) {
+        mLocationManager = locationManager;
+        mThreadNetworkControllerService = threadNetworkControllerService;
+        mGeocoder = geocoder;
+        mResources = resources;
+        mWifiManager = wifiManager;
+        mContext = context;
+        mTelephonyManager = telephonyManager;
+        mSubscriptionManager = subscriptionManager;
+    }
+
+    public static ThreadNetworkCountryCode newInstance(
+            Context context, ThreadNetworkControllerService controllerService) {
+        return new ThreadNetworkCountryCode(
+                context.getSystemService(LocationManager.class),
+                controllerService,
+                Geocoder.isPresent() ? new Geocoder(context) : null,
+                new ConnectivityResources(context),
+                context.getSystemService(WifiManager.class),
+                context,
+                context.getSystemService(TelephonyManager.class),
+                context.getSystemService(SubscriptionManager.class));
+    }
+
+    /** Sets up this country code module to listen to location country code changes. */
+    public synchronized void initialize() {
+        registerGeocoderCountryCodeCallback();
+        registerWifiCountryCodeCallback();
+        registerTelephonyCountryCodeCallback();
+        updateTelephonyCountryCodeFromSimCard();
+        updateCountryCode(false /* forceUpdate */);
+    }
+
+    private synchronized void registerGeocoderCountryCodeCallback() {
+        if ((mGeocoder != null) && isLocationUseForCountryCodeEnabled()) {
+            mLocationManager.requestLocationUpdates(
+                    LocationManager.PASSIVE_PROVIDER,
+                    TIME_BETWEEN_LOCATION_UPDATES_MS,
+                    DISTANCE_BETWEEN_LOCALTION_UPDATES_METERS,
+                    location -> setCountryCodeFromGeocodingLocation(location));
+        }
+    }
+
+    private synchronized void geocodeListener(List<Address> addresses) {
+        if (addresses != null && !addresses.isEmpty()) {
+            String countryCode = addresses.get(0).getCountryCode();
+
+            if (isValidCountryCode(countryCode)) {
+                Log.d(TAG, "Set location country code to: " + countryCode);
+                mLocationCountryCodeInfo =
+                        new CountryCodeInfo(countryCode, COUNTRY_CODE_SOURCE_LOCATION);
+            } else {
+                Log.d(TAG, "Received invalid location country code");
+                mLocationCountryCodeInfo = null;
+            }
+
+            updateCountryCode(false /* forceUpdate */);
+        }
+    }
+
+    private synchronized void setCountryCodeFromGeocodingLocation(@Nullable Location location) {
+        if ((location == null) || (mGeocoder == null)) return;
+
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
+            Log.wtf(
+                    TAG,
+                    "Unexpected call to set country code from the Geocoding location, "
+                            + "Thread code never runs under T or lower.");
+            return;
+        }
+
+        mGeocoder.getFromLocation(
+                location.getLatitude(),
+                location.getLongitude(),
+                1 /* maxResults */,
+                this::geocodeListener);
+    }
+
+    private synchronized void registerWifiCountryCodeCallback() {
+        if (mWifiManager != null) {
+            mWifiManager.registerActiveCountryCodeChangedCallback(
+                    r -> r.run(), new WifiCountryCodeCallback());
+        }
+    }
+
+    private class WifiCountryCodeCallback implements ActiveCountryCodeChangedCallback {
+        @Override
+        public void onActiveCountryCodeChanged(String countryCode) {
+            Log.d(TAG, "Wifi country code is changed to " + countryCode);
+            synchronized ("ThreadNetworkCountryCode.this") {
+                mWifiCountryCodeInfo = new CountryCodeInfo(countryCode, COUNTRY_CODE_SOURCE_WIFI);
+                updateCountryCode(false /* forceUpdate */);
+            }
+        }
+
+        @Override
+        public void onCountryCodeInactive() {
+            Log.d(TAG, "Wifi country code is inactived");
+            synchronized ("ThreadNetworkCountryCode.this") {
+                mWifiCountryCodeInfo = null;
+                updateCountryCode(false /* forceUpdate */);
+            }
+        }
+    }
+
+    private synchronized void registerTelephonyCountryCodeCallback() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+            Log.wtf(
+                    TAG,
+                    "Unexpected call to register the telephony country code changed callback, "
+                            + "Thread code never runs under T or lower.");
+            return;
+        }
+
+        BroadcastReceiver broadcastReceiver =
+                new BroadcastReceiver() {
+                    @Override
+                    public void onReceive(Context context, Intent intent) {
+                        int slotIndex =
+                                intent.getIntExtra(
+                                        SubscriptionManager.EXTRA_SLOT_INDEX,
+                                        SubscriptionManager.INVALID_SIM_SLOT_INDEX);
+                        String lastKnownCountryCode = null;
+                        String countryCode =
+                                intent.getStringExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY);
+
+                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+                            lastKnownCountryCode =
+                                    intent.getStringExtra(
+                                            TelephonyManager.EXTRA_LAST_KNOWN_NETWORK_COUNTRY);
+                        }
+
+                        setTelephonyCountryCodeAndLastKnownCountryCode(
+                                slotIndex, countryCode, lastKnownCountryCode);
+                    }
+                };
+
+        mContext.registerReceiver(
+                broadcastReceiver,
+                new IntentFilter(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED),
+                Context.RECEIVER_EXPORTED);
+    }
+
+    private synchronized void updateTelephonyCountryCodeFromSimCard() {
+        List<SubscriptionInfo> subscriptionInfoList =
+                mSubscriptionManager.getActiveSubscriptionInfoList();
+
+        if (subscriptionInfoList == null) {
+            Log.d(TAG, "No SIM card is found");
+            return;
+        }
+
+        for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
+            String countryCode;
+            int slotIndex;
+
+            slotIndex = subscriptionInfo.getSimSlotIndex();
+            try {
+                countryCode = mTelephonyManager.getNetworkCountryIso(slotIndex);
+            } catch (IllegalArgumentException e) {
+                Log.e(TAG, "Failed to get country code for slot index:" + slotIndex, e);
+                continue;
+            }
+
+            Log.d(TAG, "Telephony slot " + slotIndex + " country code is " + countryCode);
+            setTelephonyCountryCodeAndLastKnownCountryCode(
+                    slotIndex, countryCode, null /* lastKnownCountryCode */);
+        }
+    }
+
+    private synchronized void setTelephonyCountryCodeAndLastKnownCountryCode(
+            int slotIndex, String countryCode, String lastKnownCountryCode) {
+        Log.d(
+                TAG,
+                "Set telephony country code to: "
+                        + countryCode
+                        + ", last country code to: "
+                        + lastKnownCountryCode
+                        + " for slotIndex: "
+                        + slotIndex);
+
+        TelephonyCountryCodeSlotInfo telephonyCountryCodeInfoSlot =
+                mTelephonyCountryCodeSlotInfoMap.computeIfAbsent(
+                        slotIndex, k -> new TelephonyCountryCodeSlotInfo());
+        telephonyCountryCodeInfoSlot.slotIndex = slotIndex;
+        telephonyCountryCodeInfoSlot.timestamp = Instant.now();
+        telephonyCountryCodeInfoSlot.countryCode = countryCode;
+        telephonyCountryCodeInfoSlot.lastKnownCountryCode = lastKnownCountryCode;
+
+        mTelephonyCountryCodeInfo = null;
+        mTelephonyLastCountryCodeInfo = null;
+
+        for (TelephonyCountryCodeSlotInfo slotInfo : mTelephonyCountryCodeSlotInfoMap.values()) {
+            if ((mTelephonyCountryCodeInfo == null) && isValidCountryCode(slotInfo.countryCode)) {
+                mTelephonyCountryCodeInfo =
+                        new CountryCodeInfo(
+                                slotInfo.countryCode,
+                                COUNTRY_CODE_SOURCE_TELEPHONY,
+                                slotInfo.timestamp);
+            }
+
+            if ((mTelephonyLastCountryCodeInfo == null)
+                    && isValidCountryCode(slotInfo.lastKnownCountryCode)) {
+                mTelephonyLastCountryCodeInfo =
+                        new CountryCodeInfo(
+                                slotInfo.lastKnownCountryCode,
+                                COUNTRY_CODE_SOURCE_TELEPHONY_LAST,
+                                slotInfo.timestamp);
+            }
+        }
+
+        updateCountryCode(false /* forceUpdate */);
+    }
+
+    /**
+     * Priority order of country code sources (we stop at the first known country code source):
+     *
+     * <ul>
+     *   <li>1. Override country code - Country code forced via shell command (local/automated
+     *       testing)
+     *   <li>2. Telephony country code - Current country code retrieved via cellular. If there are
+     *       multiple SIM's, the country code chosen is non-deterministic if they return different
+     *       codes. The first valid country code with the lowest slot number will be used.
+     *   <li>3. Wifi country code - Current country code retrieved via wifi (via 80211.ad).
+     *   <li>4. Last known telephony country code - Last known country code retrieved via cellular.
+     *       If there are multiple SIM's, the country code chosen is non-deterministic if they
+     *       return different codes. The first valid last known country code with the lowest slot
+     *       number will be used.
+     *   <li>5. Location country code - Country code retrieved from LocationManager passive location
+     *       provider.
+     * </ul>
+     *
+     * @return the selected country code information.
+     */
+    private CountryCodeInfo pickCountryCode() {
+        if (mOverrideCountryCodeInfo != null) {
+            return mOverrideCountryCodeInfo;
+        }
+
+        if (mTelephonyCountryCodeInfo != null) {
+            return mTelephonyCountryCodeInfo;
+        }
+
+        if (mWifiCountryCodeInfo != null) {
+            return mWifiCountryCodeInfo;
+        }
+
+        if (mTelephonyLastCountryCodeInfo != null) {
+            return mTelephonyLastCountryCodeInfo;
+        }
+
+        if (mLocationCountryCodeInfo != null) {
+            return mLocationCountryCodeInfo;
+        }
+
+        return DEFAULT_COUNTRY_CODE_INFO;
+    }
+
+    private IOperationReceiver newOperationReceiver(CountryCodeInfo countryCodeInfo) {
+        return new IOperationReceiver.Stub() {
+            @Override
+            public void onSuccess() {
+                synchronized ("ThreadNetworkCountryCode.this") {
+                    mCurrentCountryCodeInfo = countryCodeInfo;
+                }
+            }
+
+            @Override
+            public void onError(int otError, String message) {
+                Log.e(
+                        TAG,
+                        "Error "
+                                + otError
+                                + ": "
+                                + message
+                                + ". Failed to set country code "
+                                + countryCodeInfo);
+            }
+        };
+    }
+
+    /**
+     * Updates country code to the Thread native layer.
+     *
+     * @param forceUpdate Force update the country code even if it was the same as previously cached
+     *     value.
+     */
+    @VisibleForTesting
+    public synchronized void updateCountryCode(boolean forceUpdate) {
+        CountryCodeInfo countryCodeInfo = pickCountryCode();
+
+        if (!forceUpdate && countryCodeInfo.isCountryCodeMatch(mCurrentCountryCodeInfo)) {
+            Log.i(TAG, "Ignoring already set country code " + countryCodeInfo.getCountryCode());
+            return;
+        }
+
+        Log.i(TAG, "Set country code: " + countryCodeInfo);
+        mThreadNetworkControllerService.setCountryCode(
+                countryCodeInfo.getCountryCode().toUpperCase(Locale.ROOT),
+                newOperationReceiver(countryCodeInfo));
+    }
+
+    /** Returns the current country code or {@code null} if no country code is set. */
+    @Nullable
+    public synchronized String getCountryCode() {
+        return (mCurrentCountryCodeInfo != null) ? mCurrentCountryCodeInfo.getCountryCode() : null;
+    }
+
+    /**
+     * Returns {@code true} if {@code countryCode} is a valid country code.
+     *
+     * <p>A country code is valid if it consists of 2 alphabets.
+     */
+    public static boolean isValidCountryCode(String countryCode) {
+        return countryCode != null
+                && countryCode.length() == 2
+                && countryCode.chars().allMatch(Character::isLetter);
+    }
+
+    /**
+     * Overrides any existing country code.
+     *
+     * @param countryCode A 2-Character alphabetical country code (as defined in ISO 3166).
+     * @throws IllegalArgumentException if {@code countryCode} is an invalid country code.
+     */
+    public synchronized void setOverrideCountryCode(String countryCode) {
+        if (!isValidCountryCode(countryCode)) {
+            throw new IllegalArgumentException("The override country code is invalid");
+        }
+
+        mOverrideCountryCodeInfo = new CountryCodeInfo(countryCode, COUNTRY_CODE_SOURCE_OVERRIDE);
+        updateCountryCode(true /* forceUpdate */);
+    }
+
+    /** Clears the country code previously set through {@link #setOverrideCountryCode} method. */
+    public synchronized void clearOverrideCountryCode() {
+        mOverrideCountryCodeInfo = null;
+        updateCountryCode(true /* forceUpdate */);
+    }
+
+    /** Dumps the current state of this ThreadNetworkCountryCode object. */
+    public synchronized void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        pw.println("---- Dump of ThreadNetworkCountryCode begin ----");
+        pw.println("mOverrideCountryCodeInfo: " + mOverrideCountryCodeInfo);
+        pw.println("mTelephonyCountryCodeSlotInfoMap: " + mTelephonyCountryCodeSlotInfoMap);
+        pw.println("mTelephonyCountryCodeInfo: " + mTelephonyCountryCodeInfo);
+        pw.println("mWifiCountryCodeInfo: " + mWifiCountryCodeInfo);
+        pw.println("mTelephonyLastCountryCodeInfo: " + mTelephonyLastCountryCodeInfo);
+        pw.println("mLocationCountryCodeInfo: " + mLocationCountryCodeInfo);
+        pw.println("mCurrentCountryCodeInfo: " + mCurrentCountryCodeInfo);
+        pw.println("---- Dump of ThreadNetworkCountryCode end ------");
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkService.java b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
index cc694a1..53f2d4f 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkService.java
@@ -16,13 +16,20 @@
 
 package com.android.server.thread;
 
+import static android.content.pm.PackageManager.PERMISSION_GRANTED;
+
+import android.annotation.NonNull;
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.IThreadNetworkController;
 import android.net.thread.IThreadNetworkManager;
+import android.os.Binder;
+import android.os.ParcelFileDescriptor;
 
 import com.android.server.SystemService;
 
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
 import java.util.Collections;
 import java.util.List;
 
@@ -31,7 +38,9 @@
  */
 public class ThreadNetworkService extends IThreadNetworkManager.Stub {
     private final Context mContext;
+    @Nullable private ThreadNetworkCountryCode mCountryCode;
     @Nullable private ThreadNetworkControllerService mControllerService;
+    @Nullable private ThreadNetworkShellCommand mShellCommand;
 
     /** Creates a new {@link ThreadNetworkService} object. */
     public ThreadNetworkService(Context context) {
@@ -39,14 +48,21 @@
     }
 
     /**
-     * Called by the service initializer.
+     * Called by {@link com.android.server.ConnectivityServiceInitializer}.
      *
      * @see com.android.server.SystemService#onBootPhase
      */
     public void onBootPhase(int phase) {
-        if (phase == SystemService.PHASE_BOOT_COMPLETED) {
+        if (phase == SystemService.PHASE_SYSTEM_SERVICES_READY) {
             mControllerService = ThreadNetworkControllerService.newInstance(mContext);
             mControllerService.initialize();
+        } else if (phase == SystemService.PHASE_BOOT_COMPLETED) {
+            // Country code initialization is delayed to the BOOT_COMPLETED phase because it will
+            // call into Wi-Fi and Telephony service whose country code module is ready after
+            // PHASE_ACTIVITY_MANAGER_READY and PHASE_THIRD_PARTY_APPS_CAN_START
+            mCountryCode = ThreadNetworkCountryCode.newInstance(mContext, mControllerService);
+            mCountryCode.initialize();
+            mShellCommand = new ThreadNetworkShellCommand(mCountryCode);
         }
     }
 
@@ -57,4 +73,40 @@
         }
         return Collections.singletonList(mControllerService);
     }
+
+    @Override
+    public int handleShellCommand(
+            @NonNull ParcelFileDescriptor in,
+            @NonNull ParcelFileDescriptor out,
+            @NonNull ParcelFileDescriptor err,
+            @NonNull String[] args) {
+        if (mShellCommand == null) {
+            return -1;
+        }
+        return mShellCommand.exec(
+                this,
+                in.getFileDescriptor(),
+                out.getFileDescriptor(),
+                err.getFileDescriptor(),
+                args);
+    }
+
+    @Override
+    protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+        if (mContext.checkCallingOrSelfPermission(android.Manifest.permission.DUMP)
+                != PERMISSION_GRANTED) {
+            pw.println(
+                    "Permission Denial: can't dump ThreadNetworkService from from pid="
+                            + Binder.getCallingPid()
+                            + ", uid="
+                            + Binder.getCallingUid());
+            return;
+        }
+
+        if (mCountryCode != null) {
+            mCountryCode.dump(fd, pw, args);
+        }
+
+        pw.println();
+    }
 }
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
new file mode 100644
index 0000000..c17c5a7
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkShellCommand.java
@@ -0,0 +1,183 @@
+/*
+ * 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 android.annotation.Nullable;
+import android.os.Binder;
+import android.os.Process;
+import android.text.TextUtils;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.modules.utils.BasicShellCommandHandler;
+
+import java.io.PrintWriter;
+import java.util.List;
+
+/**
+ * Interprets and executes 'adb shell cmd thread_network [args]'.
+ *
+ * <p>To add new commands: - onCommand: Add a case "<command>" execute. Return a 0 if command
+ * executed successfully. - onHelp: add a description string.
+ *
+ * <p>Permissions: currently root permission is required for some commands. Others will enforce the
+ * corresponding API permissions.
+ */
+public class ThreadNetworkShellCommand extends BasicShellCommandHandler {
+    private static final String TAG = "ThreadNetworkShellCommand";
+
+    // These don't require root access.
+    private static final List<String> NON_PRIVILEGED_COMMANDS = List.of("help", "get-country-code");
+
+    @Nullable private final ThreadNetworkCountryCode mCountryCode;
+    @Nullable private PrintWriter mOutputWriter;
+    @Nullable private PrintWriter mErrorWriter;
+
+    ThreadNetworkShellCommand(@Nullable ThreadNetworkCountryCode countryCode) {
+        mCountryCode = countryCode;
+    }
+
+    @VisibleForTesting
+    public void setPrintWriters(PrintWriter outputWriter, PrintWriter errorWriter) {
+        mOutputWriter = outputWriter;
+        mErrorWriter = errorWriter;
+    }
+
+    private PrintWriter getOutputWriter() {
+        return (mOutputWriter != null) ? mOutputWriter : getOutPrintWriter();
+    }
+
+    private PrintWriter getErrorWriter() {
+        return (mErrorWriter != null) ? mErrorWriter : getErrPrintWriter();
+    }
+
+    @Override
+    public int onCommand(String cmd) {
+        // Treat no command as help command.
+        if (TextUtils.isEmpty(cmd)) {
+            cmd = "help";
+        }
+
+        final PrintWriter pw = getOutputWriter();
+        final PrintWriter perr = getErrorWriter();
+
+        // Explicit exclusion from root permission
+        if (!NON_PRIVILEGED_COMMANDS.contains(cmd)) {
+            final int uid = Binder.getCallingUid();
+
+            if (uid != Process.ROOT_UID) {
+                perr.println(
+                        "Uid "
+                                + uid
+                                + " does not have access to "
+                                + cmd
+                                + " thread command "
+                                + "(or such command doesn't exist)");
+                return -1;
+            }
+        }
+
+        switch (cmd) {
+            case "force-country-code":
+                boolean enabled;
+
+                if (mCountryCode == null) {
+                    perr.println("Thread country code operations are not supported");
+                    return -1;
+                }
+
+                try {
+                    enabled = getNextArgRequiredTrueOrFalse("enabled", "disabled");
+                } catch (IllegalArgumentException e) {
+                    perr.println("Invalid argument: " + e.getMessage());
+                    return -1;
+                }
+
+                if (enabled) {
+                    String countryCode = getNextArgRequired();
+                    if (!ThreadNetworkCountryCode.isValidCountryCode(countryCode)) {
+                        perr.println(
+                                "Invalid argument: Country code must be a 2-Character"
+                                        + " string. But got country code "
+                                        + countryCode
+                                        + " instead");
+                        return -1;
+                    }
+                    mCountryCode.setOverrideCountryCode(countryCode);
+                    pw.println("Set Thread country code: " + countryCode);
+
+                } else {
+                    mCountryCode.clearOverrideCountryCode();
+                }
+                return 0;
+            case "get-country-code":
+                if (mCountryCode == null) {
+                    perr.println("Thread country code operations are not supported");
+                    return -1;
+                }
+
+                pw.println("Thread country code = " + mCountryCode.getCountryCode());
+                return 0;
+            default:
+                return handleDefaultCommands(cmd);
+        }
+    }
+
+    private static boolean argTrueOrFalse(String arg, String trueString, String falseString) {
+        if (trueString.equals(arg)) {
+            return true;
+        } else if (falseString.equals(arg)) {
+            return false;
+        } else {
+            throw new IllegalArgumentException(
+                    "Expected '"
+                            + trueString
+                            + "' or '"
+                            + falseString
+                            + "' as next arg but got '"
+                            + arg
+                            + "'");
+        }
+    }
+
+    private boolean getNextArgRequiredTrueOrFalse(String trueString, String falseString) {
+        String nextArg = getNextArgRequired();
+        return argTrueOrFalse(nextArg, trueString, falseString);
+    }
+
+    private void onHelpNonPrivileged(PrintWriter pw) {
+        pw.println("  get-country-code");
+        pw.println("    Gets country code as a two-letter string");
+    }
+
+    private void onHelpPrivileged(PrintWriter pw) {
+        pw.println("  force-country-code enabled <two-letter code> | disabled ");
+        pw.println("    Sets country code to <two-letter code> or left for normal value");
+    }
+
+    @Override
+    public void onHelp() {
+        final PrintWriter pw = getOutputWriter();
+        pw.println("Thread network commands:");
+        pw.println("  help or -h");
+        pw.println("    Print this help text.");
+        onHelpNonPrivileged(pw);
+        if (Binder.getCallingUid() == Process.ROOT_UID) {
+            onHelpPrivileged(pw);
+        }
+        pw.println();
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
new file mode 100644
index 0000000..d32f0bf
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -0,0 +1,243 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import android.annotation.Nullable;
+import android.os.PersistableBundle;
+import android.util.AtomicFile;
+import android.util.Log;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Store persistent data for Thread network settings. These are key (string) / value pairs that are
+ * stored in ThreadPersistentSetting.xml file. The values allowed are those that can be serialized
+ * via {@link PersistableBundle}.
+ */
+public class ThreadPersistentSettings {
+    private static final String TAG = "ThreadPersistentSettings";
+    /** File name used for storing settings. */
+    public static final String FILE_NAME = "ThreadPersistentSettings.xml";
+    /** Current config store data version. This will be incremented for any additions. */
+    private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
+    /**
+     * Stores the version of the data. This can be used to handle migration of data if some
+     * non-backward compatible change introduced.
+     */
+    private static final String VERSION_KEY = "version";
+
+    /******** Thread persistent setting keys ***************/
+    /** Stores the Thread feature toggle state, true for enabled and false for disabled. */
+    public static final Key<Boolean> THREAD_ENABLED = new Key<>("Thread_enabled", true);
+
+    /******** Thread persistent setting keys ***************/
+
+    @GuardedBy("mLock")
+    private final AtomicFile mAtomicFile;
+
+    private final Object mLock = new Object();
+
+    @GuardedBy("mLock")
+    private final PersistableBundle mSettings = new PersistableBundle();
+
+    public ThreadPersistentSettings(AtomicFile atomicFile) {
+        mAtomicFile = atomicFile;
+    }
+
+    /** Initialize the settings by reading from the settings file. */
+    public void initialize() {
+        readFromStoreFile();
+        synchronized (mLock) {
+            if (mSettings.isEmpty()) {
+                put(THREAD_ENABLED.key, THREAD_ENABLED.defaultValue);
+            }
+        }
+    }
+
+    private void putObject(String key, @Nullable Object value) {
+        synchronized (mLock) {
+            if (value == null) {
+                mSettings.putString(key, null);
+            } else if (value instanceof Boolean) {
+                mSettings.putBoolean(key, (Boolean) value);
+            } else if (value instanceof Integer) {
+                mSettings.putInt(key, (Integer) value);
+            } else if (value instanceof Long) {
+                mSettings.putLong(key, (Long) value);
+            } else if (value instanceof Double) {
+                mSettings.putDouble(key, (Double) value);
+            } else if (value instanceof String) {
+                mSettings.putString(key, (String) value);
+            } else {
+                throw new IllegalArgumentException("Unsupported type " + value.getClass());
+            }
+        }
+    }
+
+    private <T> T getObject(String key, T defaultValue) {
+        Object value;
+        synchronized (mLock) {
+            if (defaultValue instanceof Boolean) {
+                value = mSettings.getBoolean(key, (Boolean) defaultValue);
+            } else if (defaultValue instanceof Integer) {
+                value = mSettings.getInt(key, (Integer) defaultValue);
+            } else if (defaultValue instanceof Long) {
+                value = mSettings.getLong(key, (Long) defaultValue);
+            } else if (defaultValue instanceof Double) {
+                value = mSettings.getDouble(key, (Double) defaultValue);
+            } else if (defaultValue instanceof String) {
+                value = mSettings.getString(key, (String) defaultValue);
+            } else {
+                throw new IllegalArgumentException("Unsupported type " + defaultValue.getClass());
+            }
+        }
+        return (T) value;
+    }
+
+    /**
+     * Store a value to the stored settings.
+     *
+     * @param key One of the settings keys.
+     * @param value Value to be stored.
+     */
+    public <T> void put(String key, @Nullable T value) {
+        putObject(key, value);
+        writeToStoreFile();
+    }
+
+    /**
+     * Retrieve a value from the stored settings.
+     *
+     * @param key One of the settings keys.
+     * @return value stored in settings, defValue if the key does not exist.
+     */
+    public <T> T get(Key<T> key) {
+        return getObject(key.key, key.defaultValue);
+    }
+
+    /**
+     * Base class to store string key and its default value.
+     *
+     * @param <T> Type of the value.
+     */
+    public static class Key<T> {
+        public final String key;
+        public final T defaultValue;
+
+        private Key(String key, T defaultValue) {
+            this.key = key;
+            this.defaultValue = defaultValue;
+        }
+
+        @Override
+        public String toString() {
+            return "[Key: " + key + ", DefaultValue: " + defaultValue + "]";
+        }
+    }
+
+    private void writeToStoreFile() {
+        try {
+            final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+            final PersistableBundle bundleToWrite;
+            synchronized (mLock) {
+                bundleToWrite = new PersistableBundle(mSettings);
+            }
+            bundleToWrite.putInt(VERSION_KEY, CURRENT_SETTINGS_STORE_DATA_VERSION);
+            bundleToWrite.writeToStream(outputStream);
+            synchronized (mLock) {
+                writeToAtomicFile(mAtomicFile, outputStream.toByteArray());
+            }
+        } catch (IOException e) {
+            Log.wtf(TAG, "Write to store file failed", e);
+        }
+    }
+
+    private void readFromStoreFile() {
+        try {
+            final byte[] readData;
+            synchronized (mLock) {
+                Log.i(TAG, "Reading from store file: " + mAtomicFile.getBaseFile());
+                readData = readFromAtomicFile(mAtomicFile);
+            }
+            final ByteArrayInputStream inputStream = new ByteArrayInputStream(readData);
+            final PersistableBundle bundleRead = PersistableBundle.readFromStream(inputStream);
+            // Version unused for now. May be needed in the future for handling migrations.
+            bundleRead.remove(VERSION_KEY);
+            synchronized (mLock) {
+                mSettings.putAll(bundleRead);
+            }
+        } catch (FileNotFoundException e) {
+            Log.e(TAG, "No store file to read", e);
+        } catch (IOException e) {
+            Log.e(TAG, "Read from store file failed", e);
+        }
+    }
+
+    /**
+     * Read raw data from the atomic file. Note: This is a copy of {@link AtomicFile#readFully()}
+     * modified to use the passed in {@link InputStream} which was returned using {@link
+     * AtomicFile#openRead()}.
+     */
+    private static byte[] readFromAtomicFile(AtomicFile file) throws IOException {
+        FileInputStream stream = null;
+        try {
+            stream = file.openRead();
+            int pos = 0;
+            int avail = stream.available();
+            byte[] data = new byte[avail];
+            while (true) {
+                int amt = stream.read(data, pos, data.length - pos);
+                if (amt <= 0) {
+                    return data;
+                }
+                pos += amt;
+                avail = stream.available();
+                if (avail > data.length - pos) {
+                    byte[] newData = new byte[pos + avail];
+                    System.arraycopy(data, 0, newData, 0, pos);
+                    data = newData;
+                }
+            }
+        } finally {
+            if (stream != null) stream.close();
+        }
+    }
+
+    /** Write the raw data to the atomic file. */
+    private static void writeToAtomicFile(AtomicFile file, byte[] data) throws IOException {
+        // Write the data to the atomic file.
+        FileOutputStream out = null;
+        try {
+            out = file.startWrite();
+            out.write(data);
+            file.finishWrite(out);
+        } catch (IOException e) {
+            if (out != null) {
+                file.failWrite(out);
+            }
+            throw e;
+        }
+    }
+}
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 362ff39..e02e74d 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -521,7 +521,7 @@
     }
 
     @Test
-    public void scheduleMigration_withPrivilegedPermission_success() throws Exception {
+    public void scheduleMigration_withPrivilegedPermission_newDatasetApplied() throws Exception {
         grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
@@ -548,11 +548,32 @@
 
             controller.scheduleMigration(
                     pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture));
-
             migrateFuture.get();
-            Thread.sleep(35 * 1000);
-            assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2);
-            assertThat(getPendingOperationalDataset(controller)).isNull();
+
+            SettableFuture<Boolean> dataset2IsApplied = SettableFuture.create();
+            SettableFuture<Boolean> pendingDatasetIsRemoved = SettableFuture.create();
+            OperationalDatasetCallback datasetCallback =
+                    new OperationalDatasetCallback() {
+                        @Override
+                        public void onActiveOperationalDatasetChanged(
+                                ActiveOperationalDataset activeDataset) {
+                            if (activeDataset.equals(activeDataset2)) {
+                                dataset2IsApplied.set(true);
+                            }
+                        }
+
+                        @Override
+                        public void onPendingOperationalDatasetChanged(
+                                PendingOperationalDataset pendingDataset) {
+                            if (pendingDataset == null) {
+                                pendingDatasetIsRemoved.set(true);
+                            }
+                        }
+                    };
+            controller.registerOperationalDatasetCallback(directExecutor(), datasetCallback);
+            assertThat(dataset2IsApplied.get()).isTrue();
+            assertThat(pendingDatasetIsRemoved.get()).isTrue();
+            controller.unregisterOperationalDatasetCallback(datasetCallback);
         }
     }
 
@@ -629,7 +650,8 @@
     }
 
     @Test
-    public void scheduleMigration_secondRequestHasLargerTimestamp_success() throws Exception {
+    public void scheduleMigration_secondRequestHasLargerTimestamp_newDatasetApplied()
+            throws Exception {
         grantPermissions(permission.ACCESS_NETWORK_STATE, PERMISSION_THREAD_NETWORK_PRIVILEGED);
 
         for (ThreadNetworkController controller : getAllControllers()) {
@@ -669,11 +691,32 @@
             migrateFuture1.get();
             controller.scheduleMigration(
                     pendingDataset2, mExecutor, newOutcomeReceiver(migrateFuture2));
-
             migrateFuture2.get();
-            Thread.sleep(35 * 1000);
-            assertThat(getActiveOperationalDataset(controller)).isEqualTo(activeDataset2);
-            assertThat(getPendingOperationalDataset(controller)).isNull();
+
+            SettableFuture<Boolean> dataset2IsApplied = SettableFuture.create();
+            SettableFuture<Boolean> pendingDatasetIsRemoved = SettableFuture.create();
+            OperationalDatasetCallback datasetCallback =
+                    new OperationalDatasetCallback() {
+                        @Override
+                        public void onActiveOperationalDatasetChanged(
+                                ActiveOperationalDataset activeDataset) {
+                            if (activeDataset.equals(activeDataset2)) {
+                                dataset2IsApplied.set(true);
+                            }
+                        }
+
+                        @Override
+                        public void onPendingOperationalDatasetChanged(
+                                PendingOperationalDataset pendingDataset) {
+                            if (pendingDataset == null) {
+                                pendingDatasetIsRemoved.set(true);
+                            }
+                        }
+                    };
+            controller.registerOperationalDatasetCallback(directExecutor(), datasetCallback);
+            assertThat(dataset2IsApplied.get()).isTrue();
+            assertThat(pendingDatasetIsRemoved.get()).isTrue();
+            controller.unregisterOperationalDatasetCallback(datasetCallback);
         }
     }
 
diff --git a/thread/tests/unit/Android.bp b/thread/tests/unit/Android.bp
index 8092693..291475e 100644
--- a/thread/tests/unit/Android.bp
+++ b/thread/tests/unit/Android.bp
@@ -31,20 +31,35 @@
         "general-tests",
     ],
     static_libs: [
-        "androidx.test.ext.junit",
-        "compatibility-device-util-axt",
+        "frameworks-base-testutils",
         "framework-connectivity-pre-jarjar",
         "framework-connectivity-t-pre-jarjar",
+        "framework-location.stubs.module_lib",
         "guava",
         "guava-android-testlib",
-        "mockito-target-minus-junit4",
+        "mockito-target-extended-minus-junit4",
         "net-tests-utils",
+        "ot-daemon-aidl-java",
+        "ot-daemon-testing",
+        "service-connectivity-pre-jarjar",
+        "service-thread-pre-jarjar",
         "truth",
+        "service-thread-pre-jarjar",
     ],
     libs: [
         "android.test.base",
         "android.test.runner",
+        "ServiceConnectivityResources",
+        "framework-wifi",
     ],
+    jni_libs: [
+        "libservice-thread-jni",
+
+        // these are needed for Extended Mockito
+        "libdexmakerjvmtiagent",
+        "libstaticjvmtiagent",
+    ],
+    jni_uses_platform_apis: true,
     jarjar_rules: ":connectivity-jarjar-rules",
     // Test coverage system runs on different devices. Need to
     // compile for all architectures.
diff --git a/thread/tests/unit/AndroidTest.xml b/thread/tests/unit/AndroidTest.xml
index 597c6a8..26813c1 100644
--- a/thread/tests/unit/AndroidTest.xml
+++ b/thread/tests/unit/AndroidTest.xml
@@ -30,5 +30,8 @@
         <option name="hidden-api-checks" value="false"/>
         <!-- Ignores tests introduced by guava-android-testlib -->
         <option name="exclude-annotation" value="org.junit.Ignore"/>
+        <!-- Ignores tests introduced by frameworks-base-testutils -->
+        <option name="exclude-filter" value="android.os.test.TestLooperTest"/>
+        <option name="exclude-filter" value="com.android.test.filters.SelectTestTests"/>
     </test>
 </configuration>
diff --git a/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
index 7284968..e92dcb9 100644
--- a/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
+++ b/thread/tests/unit/src/android/net/thread/ActiveOperationalDatasetTest.java
@@ -33,12 +33,8 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
-import java.security.SecureRandom;
-import java.util.Random;
-
 /** Unit tests for {@link ActiveOperationalDataset}. */
 @SmallTest
 @RunWith(AndroidJUnit4.class)
@@ -62,9 +58,6 @@
                                     + "642D643961300102D9A00410A245479C836D551B9CA557F7"
                                     + "B9D351B40C0402A0FFF8");
 
-    @Mock private Random mockRandom;
-    @Mock private SecureRandom mockSecureRandom;
-
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
diff --git a/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java b/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
new file mode 100644
index 0000000..11aabb8
--- /dev/null
+++ b/thread/tests/unit/src/android/net/thread/ThreadPersistentSettingsTest.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import static com.android.server.thread.ThreadPersistentSettings.THREAD_ENABLED;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.validateMockitoUsage;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.os.PersistableBundle;
+import android.test.suitebuilder.annotation.SmallTest;
+import android.util.AtomicFile;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+
+/** Unit tests for {@link ThreadPersistentSettings}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ThreadPersistentSettingsTest {
+    @Mock private AtomicFile mAtomicFile;
+
+    private ThreadPersistentSettings mThreadPersistentSetting;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        FileOutputStream fos = mock(FileOutputStream.class);
+        when(mAtomicFile.startWrite()).thenReturn(fos);
+        mThreadPersistentSetting = new ThreadPersistentSettings(mAtomicFile);
+    }
+
+    /** Called after each test */
+    @After
+    public void tearDown() {
+        validateMockitoUsage();
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledTrue_returnsTrue() throws Exception {
+        mThreadPersistentSetting.put(THREAD_ENABLED.key, true);
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isTrue();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void put_ThreadFeatureEnabledFalse_returnsFalse() throws Exception {
+        mThreadPersistentSetting.put(THREAD_ENABLED.key, false);
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
+        // Confirm that file writes have been triggered.
+        verify(mAtomicFile).startWrite();
+        verify(mAtomicFile).finishWrite(any());
+    }
+
+    @Test
+    public void initialize_readsFromFile() throws Exception {
+        byte[] data = createXmlForParsing(THREAD_ENABLED.key, false);
+        setupAtomicFileMockForRead(data);
+
+        // Trigger file read.
+        mThreadPersistentSetting.initialize();
+
+        assertThat(mThreadPersistentSetting.get(THREAD_ENABLED)).isFalse();
+        verify(mAtomicFile, never()).startWrite();
+    }
+
+    private byte[] createXmlForParsing(String key, Boolean value) throws Exception {
+        PersistableBundle bundle = new PersistableBundle();
+        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+        bundle.putBoolean(key, value);
+        bundle.writeToStream(outputStream);
+        return outputStream.toByteArray();
+    }
+
+    private void setupAtomicFileMockForRead(byte[] dataToRead) throws Exception {
+        FileInputStream is = mock(FileInputStream.class);
+        when(mAtomicFile.openRead()).thenReturn(is);
+        when(is.available()).thenReturn(dataToRead.length).thenReturn(0);
+        doAnswer(
+                        invocation -> {
+                            byte[] data = invocation.getArgument(0);
+                            int pos = invocation.getArgument(1);
+                            if (pos == dataToRead.length) return 0; // read complete.
+                            System.arraycopy(dataToRead, 0, data, 0, dataToRead.length);
+                            return dataToRead.length;
+                        })
+                .when(is)
+                .read(any(), anyInt(), anyInt());
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/BinderUtil.java b/thread/tests/unit/src/com/android/server/thread/BinderUtil.java
new file mode 100644
index 0000000..3614bce
--- /dev/null
+++ b/thread/tests/unit/src/com/android/server/thread/BinderUtil.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.thread;
+
+import android.os.Binder;
+
+/** Utilities for faking the calling uid in Binder. */
+public class BinderUtil {
+    /**
+     * Fake the calling uid in Binder.
+     *
+     * @param uid the calling uid that Binder should return from now on
+     */
+    public static void setUid(int uid) {
+        Binder.restoreCallingIdentity((((long) uid) << 32) | Binder.getCallingPid());
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
new file mode 100644
index 0000000..44a8ab7
--- /dev/null
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.io.BaseEncoding.base16;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkAgent;
+import android.net.NetworkProvider;
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IOperationReceiver;
+import android.os.Handler;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import android.os.test.TestLooper;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import com.android.server.thread.openthread.testing.FakeOtDaemon;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/** Unit tests for {@link ThreadNetworkControllerService}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public final class ThreadNetworkControllerServiceTest {
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset new":
+    // Active Timestamp: 1
+    // Channel: 19
+    // Channel Mask: 0x07FFF800
+    // Ext PAN ID: ACC214689BC40BDF
+    // Mesh Local Prefix: fd64:db12:25f4:7e0b::/64
+    // Network Key: F26B3153760F519A63BAFDDFFC80D2AF
+    // Network Name: OpenThread-d9a0
+    // PAN ID: 0xD9A0
+    // PSKc: A245479C836D551B9CA557F7B9D351B4
+    // Security Policy: 672 onrcb
+    private static final byte[] DEFAULT_ACTIVE_DATASET_TLVS =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+    private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET =
+            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_ACTIVE_DATASET_TLVS);
+
+    @Mock private ConnectivityManager mMockConnectivityManager;
+    @Mock private NetworkAgent mMockNetworkAgent;
+    @Mock private TunInterfaceController mMockTunIfController;
+    @Mock private ParcelFileDescriptor mMockTunFd;
+    @Mock private InfraInterfaceController mMockInfraIfController;
+    private Context mContext;
+    private TestLooper mTestLooper;
+    private FakeOtDaemon mFakeOtDaemon;
+    private ThreadNetworkControllerService mService;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mContext = ApplicationProvider.getApplicationContext();
+        mTestLooper = new TestLooper();
+        final Handler handler = new Handler(mTestLooper.getLooper());
+        NetworkProvider networkProvider =
+                new NetworkProvider(mContext, mTestLooper.getLooper(), "ThreadNetworkProvider");
+
+        mFakeOtDaemon = new FakeOtDaemon(handler);
+
+        when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
+
+        mService =
+                new ThreadNetworkControllerService(
+                        ApplicationProvider.getApplicationContext(),
+                        handler,
+                        networkProvider,
+                        () -> mFakeOtDaemon,
+                        mMockConnectivityManager,
+                        mMockTunIfController,
+                        mMockInfraIfController);
+        mService.setTestNetworkAgent(mMockNetworkAgent);
+    }
+
+    @Test
+    public void initialize_tunInterfaceSetToOtDaemon() throws Exception {
+        when(mMockTunIfController.getTunFd()).thenReturn(mMockTunFd);
+
+        mService.initialize();
+        mTestLooper.dispatchAll();
+
+        verify(mMockTunIfController, times(1)).createTunInterface();
+        assertThat(mFakeOtDaemon.getTunFd()).isEqualTo(mMockTunFd);
+    }
+
+    @Test
+    public void join_otDaemonRemoteFailure_returnsInternalError() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+        mFakeOtDaemon.setJoinException(new RemoteException("ot-daemon join() throws"));
+
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver));
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, never()).onSuccess();
+        verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+    }
+
+    @Test
+    public void join_succeed_threadNetworkRegistered() throws Exception {
+        mService.initialize();
+        final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
+
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver));
+        // Here needs to call Testlooper#dispatchAll twices because TestLooper#moveTimeForward
+        // operates on only currently enqueued messages but the delayed message is posted from
+        // another Handler task.
+        mTestLooper.dispatchAll();
+        mTestLooper.moveTimeForward(FakeOtDaemon.JOIN_DELAY.toMillis() + 100);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, times(1)).onSuccess();
+        verify(mMockNetworkAgent, times(1)).register();
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
new file mode 100644
index 0000000..17cdd01
--- /dev/null
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkCountryCodeTest.java
@@ -0,0 +1,409 @@
+/*
+ * 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 com.android.server.thread.ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyDouble;
+import static org.mockito.Mockito.anyFloat;
+import static org.mockito.Mockito.anyInt;
+import static org.mockito.Mockito.anyLong;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.location.Address;
+import android.location.Geocoder;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.net.thread.IOperationReceiver;
+import android.net.wifi.WifiManager;
+import android.net.wifi.WifiManager.ActiveCountryCodeChangedCallback;
+import android.telephony.SubscriptionInfo;
+import android.telephony.SubscriptionManager;
+import android.telephony.TelephonyManager;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.connectivity.resources.R;
+import com.android.server.connectivity.ConnectivityResources;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.stubbing.Answer;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+
+/** Unit tests for {@link ThreadNetworkCountryCode}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ThreadNetworkCountryCodeTest {
+    private static final String TEST_COUNTRY_CODE_US = "US";
+    private static final String TEST_COUNTRY_CODE_CN = "CN";
+    private static final int TEST_SIM_SLOT_INDEX_0 = 0;
+    private static final int TEST_SIM_SLOT_INDEX_1 = 1;
+
+    @Mock Context mContext;
+    @Mock LocationManager mLocationManager;
+    @Mock Geocoder mGeocoder;
+    @Mock ThreadNetworkControllerService mThreadNetworkControllerService;
+    @Mock PackageManager mPackageManager;
+    @Mock Location mLocation;
+    @Mock Resources mResources;
+    @Mock ConnectivityResources mConnectivityResources;
+    @Mock WifiManager mWifiManager;
+    @Mock SubscriptionManager mSubscriptionManager;
+    @Mock TelephonyManager mTelephonyManager;
+    @Mock List<SubscriptionInfo> mSubscriptionInfoList;
+    @Mock SubscriptionInfo mSubscriptionInfo0;
+    @Mock SubscriptionInfo mSubscriptionInfo1;
+
+    private ThreadNetworkCountryCode mThreadNetworkCountryCode;
+    private boolean mErrorSetCountryCode;
+
+    @Captor private ArgumentCaptor<LocationListener> mLocationListenerCaptor;
+    @Captor private ArgumentCaptor<Geocoder.GeocodeListener> mGeocodeListenerCaptor;
+    @Captor private ArgumentCaptor<IOperationReceiver> mOperationReceiverCaptor;
+    @Captor private ArgumentCaptor<ActiveCountryCodeChangedCallback> mWifiCountryCodeReceiverCaptor;
+    @Captor private ArgumentCaptor<BroadcastReceiver> mTelephonyCountryCodeReceiverCaptor;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        when(mConnectivityResources.get()).thenReturn(mResources);
+        when(mResources.getBoolean(anyInt())).thenReturn(true);
+
+        when(mSubscriptionManager.getActiveSubscriptionInfoList())
+                .thenReturn(mSubscriptionInfoList);
+        Iterator<SubscriptionInfo> iteratorMock = mock(Iterator.class);
+        when(mSubscriptionInfoList.size()).thenReturn(2);
+        when(mSubscriptionInfoList.iterator()).thenReturn(iteratorMock);
+        when(iteratorMock.hasNext()).thenReturn(true).thenReturn(true).thenReturn(false);
+        when(iteratorMock.next()).thenReturn(mSubscriptionInfo0).thenReturn(mSubscriptionInfo1);
+        when(mSubscriptionInfo0.getSimSlotIndex()).thenReturn(TEST_SIM_SLOT_INDEX_0);
+        when(mSubscriptionInfo1.getSimSlotIndex()).thenReturn(TEST_SIM_SLOT_INDEX_1);
+
+        when(mLocation.getLatitude()).thenReturn(0.0);
+        when(mLocation.getLongitude()).thenReturn(0.0);
+
+        Answer setCountryCodeCallback =
+                invocation -> {
+                    Object[] args = invocation.getArguments();
+                    IOperationReceiver cb = (IOperationReceiver) args[1];
+
+                    if (mErrorSetCountryCode) {
+                        cb.onError(ERROR_INTERNAL_ERROR, new String("Invalid country code"));
+                    } else {
+                        cb.onSuccess();
+                    }
+                    return new Object();
+                };
+
+        doAnswer(setCountryCodeCallback)
+                .when(mThreadNetworkControllerService)
+                .setCountryCode(any(), any(IOperationReceiver.class));
+
+        mThreadNetworkCountryCode =
+                new ThreadNetworkCountryCode(
+                        mLocationManager,
+                        mThreadNetworkControllerService,
+                        mGeocoder,
+                        mConnectivityResources,
+                        mWifiManager,
+                        mContext,
+                        mTelephonyManager,
+                        mSubscriptionManager);
+    }
+
+    private static Address newAddress(String countryCode) {
+        Address address = new Address(Locale.ROOT);
+        address.setCountryCode(countryCode);
+        return address;
+    }
+
+    @Test
+    public void initialize_defaultCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
+    public void initialize_locationUseIsDisabled_locationFunctionIsNotCalled() {
+        when(mResources.getBoolean(R.bool.config_thread_location_use_for_country_code_enabled))
+                .thenReturn(false);
+
+        mThreadNetworkCountryCode.initialize();
+
+        verifyNoMoreInteractions(mGeocoder);
+        verifyNoMoreInteractions(mLocationManager);
+    }
+
+    @Test
+    public void locationCountryCode_locationChanged_locationCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+
+        verify(mLocationManager)
+                .requestLocationUpdates(
+                        anyString(), anyLong(), anyFloat(), mLocationListenerCaptor.capture());
+        mLocationListenerCaptor.getValue().onLocationChanged(mLocation);
+        verify(mGeocoder)
+                .getFromLocation(
+                        anyDouble(), anyDouble(), anyInt(), mGeocodeListenerCaptor.capture());
+        mGeocodeListenerCaptor.getValue().onGeocode(List.of(newAddress(TEST_COUNTRY_CODE_US)));
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_US);
+    }
+
+    @Test
+    public void wifiCountryCode_bothWifiAndLocationAreAvailable_wifiCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mLocationManager)
+                .requestLocationUpdates(
+                        anyString(), anyLong(), anyFloat(), mLocationListenerCaptor.capture());
+        mLocationListenerCaptor.getValue().onLocationChanged(mLocation);
+        verify(mGeocoder)
+                .getFromLocation(
+                        anyDouble(), anyDouble(), anyInt(), mGeocodeListenerCaptor.capture());
+
+        Address mockAddress = mock(Address.class);
+        when(mockAddress.getCountryCode()).thenReturn(TEST_COUNTRY_CODE_US);
+        List<Address> addresses = List.of(mockAddress);
+        mGeocodeListenerCaptor.getValue().onGeocode(addresses);
+
+        verify(mWifiManager)
+                .registerActiveCountryCodeChangedCallback(
+                        any(), mWifiCountryCodeReceiverCaptor.capture());
+        mWifiCountryCodeReceiverCaptor.getValue().onActiveCountryCodeChanged(TEST_COUNTRY_CODE_CN);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
+    public void wifiCountryCode_wifiCountryCodeIsActive_wifiCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+
+        verify(mWifiManager)
+                .registerActiveCountryCodeChangedCallback(
+                        any(), mWifiCountryCodeReceiverCaptor.capture());
+        mWifiCountryCodeReceiverCaptor.getValue().onActiveCountryCodeChanged(TEST_COUNTRY_CODE_US);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_US);
+    }
+
+    @Test
+    public void wifiCountryCode_wifiCountryCodeIsInactive_defaultCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mWifiManager)
+                .registerActiveCountryCodeChangedCallback(
+                        any(), mWifiCountryCodeReceiverCaptor.capture());
+        mWifiCountryCodeReceiverCaptor.getValue().onActiveCountryCodeChanged(TEST_COUNTRY_CODE_US);
+
+        mWifiCountryCodeReceiverCaptor.getValue().onCountryCodeInactive();
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode())
+                .isEqualTo(ThreadNetworkCountryCode.DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
+    public void telephonyCountryCode_bothTelephonyAndLocationAvailable_telephonyCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mLocationManager)
+                .requestLocationUpdates(
+                        anyString(), anyLong(), anyFloat(), mLocationListenerCaptor.capture());
+        mLocationListenerCaptor.getValue().onLocationChanged(mLocation);
+        verify(mGeocoder)
+                .getFromLocation(
+                        anyDouble(), anyDouble(), anyInt(), mGeocodeListenerCaptor.capture());
+        mGeocodeListenerCaptor.getValue().onGeocode(List.of(newAddress(TEST_COUNTRY_CODE_US)));
+
+        verify(mContext)
+                .registerReceiver(
+                        mTelephonyCountryCodeReceiverCaptor.capture(),
+                        any(),
+                        eq(Context.RECEIVER_EXPORTED));
+        Intent intent =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, TEST_COUNTRY_CODE_CN)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_0);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
+    public void telephonyCountryCode_locationIsAvailable_lastKnownTelephonyCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mLocationManager)
+                .requestLocationUpdates(
+                        anyString(), anyLong(), anyFloat(), mLocationListenerCaptor.capture());
+        mLocationListenerCaptor.getValue().onLocationChanged(mLocation);
+        verify(mGeocoder)
+                .getFromLocation(
+                        anyDouble(), anyDouble(), anyInt(), mGeocodeListenerCaptor.capture());
+        mGeocodeListenerCaptor.getValue().onGeocode(List.of(newAddress(TEST_COUNTRY_CODE_US)));
+
+        verify(mContext)
+                .registerReceiver(
+                        mTelephonyCountryCodeReceiverCaptor.capture(),
+                        any(),
+                        eq(Context.RECEIVER_EXPORTED));
+        Intent intent =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, "")
+                        .putExtra(
+                                TelephonyManager.EXTRA_LAST_KNOWN_NETWORK_COUNTRY,
+                                TEST_COUNTRY_CODE_US)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_0);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_US);
+    }
+
+    @Test
+    public void telephonyCountryCode_lastKnownCountryCodeAvailable_telephonyCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mContext)
+                .registerReceiver(
+                        mTelephonyCountryCodeReceiverCaptor.capture(),
+                        any(),
+                        eq(Context.RECEIVER_EXPORTED));
+        Intent intent0 =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, "")
+                        .putExtra(
+                                TelephonyManager.EXTRA_LAST_KNOWN_NETWORK_COUNTRY,
+                                TEST_COUNTRY_CODE_US)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_0);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent0);
+
+        verify(mContext)
+                .registerReceiver(
+                        mTelephonyCountryCodeReceiverCaptor.capture(),
+                        any(),
+                        eq(Context.RECEIVER_EXPORTED));
+        Intent intent1 =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, TEST_COUNTRY_CODE_CN)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_1);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent1);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
+    public void telephonyCountryCode_multipleSims_firstSimIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        verify(mContext)
+                .registerReceiver(
+                        mTelephonyCountryCodeReceiverCaptor.capture(),
+                        any(),
+                        eq(Context.RECEIVER_EXPORTED));
+        Intent intent1 =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, TEST_COUNTRY_CODE_CN)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_1);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent1);
+
+        Intent intent0 =
+                new Intent(TelephonyManager.ACTION_NETWORK_COUNTRY_CHANGED)
+                        .putExtra(TelephonyManager.EXTRA_NETWORK_COUNTRY, TEST_COUNTRY_CODE_CN)
+                        .putExtra(SubscriptionManager.EXTRA_SLOT_INDEX, TEST_SIM_SLOT_INDEX_0);
+        mTelephonyCountryCodeReceiverCaptor.getValue().onReceive(mContext, intent0);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
+    public void updateCountryCode_noForceUpdateDefaultCountryCode_noCountryCodeIsUpdated() {
+        mThreadNetworkCountryCode.initialize();
+        clearInvocations(mThreadNetworkControllerService);
+
+        mThreadNetworkCountryCode.updateCountryCode(false /* forceUpdate */);
+
+        verify(mThreadNetworkControllerService, never()).setCountryCode(any(), any());
+    }
+
+    @Test
+    public void updateCountryCode_forceUpdateDefaultCountryCode_countryCodeIsUpdated() {
+        mThreadNetworkCountryCode.initialize();
+        clearInvocations(mThreadNetworkControllerService);
+
+        mThreadNetworkCountryCode.updateCountryCode(true /* forceUpdate */);
+
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(DEFAULT_COUNTRY_CODE), mOperationReceiverCaptor.capture());
+    }
+
+    @Test
+    public void setOverrideCountryCode_defaultCountryCodeAvailable_overrideCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(TEST_COUNTRY_CODE_CN);
+    }
+
+    @Test
+    public void clearOverrideCountryCode_defaultCountryCodeAvailable_defaultCountryCodeIsUsed() {
+        mThreadNetworkCountryCode.initialize();
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+
+        mThreadNetworkCountryCode.clearOverrideCountryCode();
+
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+
+    @Test
+    public void setCountryCodeFailed_defaultCountryCodeAvailable_countryCodeIsNotUpdated() {
+        mThreadNetworkCountryCode.initialize();
+
+        mErrorSetCountryCode = true;
+        mThreadNetworkCountryCode.setOverrideCountryCode(TEST_COUNTRY_CODE_CN);
+
+        verify(mThreadNetworkControllerService)
+                .setCountryCode(eq(TEST_COUNTRY_CODE_CN), mOperationReceiverCaptor.capture());
+        assertThat(mThreadNetworkCountryCode.getCountryCode()).isEqualTo(DEFAULT_COUNTRY_CODE);
+    }
+}
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
new file mode 100644
index 0000000..c7e0eca
--- /dev/null
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkShellCommandTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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 org.mockito.Mockito.any;
+import static org.mockito.Mockito.contains;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.validateMockitoUsage;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.os.Binder;
+import android.os.Process;
+
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+
+/** Unit tests for {@link ThreadNetworkShellCommand}. */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class ThreadNetworkShellCommandTest {
+    private static final String TAG = "ThreadNetworkShellCommandTTest";
+    @Mock ThreadNetworkService mThreadNetworkService;
+    @Mock ThreadNetworkCountryCode mThreadNetworkCountryCode;
+    @Mock PrintWriter mErrorWriter;
+    @Mock PrintWriter mOutputWriter;
+
+    ThreadNetworkShellCommand mThreadNetworkShellCommand;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+
+        mThreadNetworkShellCommand = new ThreadNetworkShellCommand(mThreadNetworkCountryCode);
+        mThreadNetworkShellCommand.setPrintWriters(mOutputWriter, mErrorWriter);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        validateMockitoUsage();
+    }
+
+    @Test
+    public void getCountryCode_executeInUnrootedShell_allowed() {
+        BinderUtil.setUid(Process.SHELL_UID);
+        when(mThreadNetworkCountryCode.getCountryCode()).thenReturn("US");
+
+        mThreadNetworkShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"get-country-code"});
+
+        verify(mOutputWriter).println(contains("US"));
+    }
+
+    @Test
+    public void forceSetCountryCodeEnabled_executeInUnrootedShell_notAllowed() {
+        BinderUtil.setUid(Process.SHELL_UID);
+
+        mThreadNetworkShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-country-code", "enabled", "US"});
+
+        verify(mThreadNetworkCountryCode, never()).setOverrideCountryCode(eq("US"));
+        verify(mErrorWriter).println(contains("force-country-code"));
+    }
+
+    @Test
+    public void forceSetCountryCodeEnabled_executeInRootedShell_allowed() {
+        BinderUtil.setUid(Process.ROOT_UID);
+
+        mThreadNetworkShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-country-code", "enabled", "US"});
+
+        verify(mThreadNetworkCountryCode).setOverrideCountryCode(eq("US"));
+    }
+
+    @Test
+    public void forceSetCountryCodeDisabled_executeInUnrootedShell_notAllowed() {
+        BinderUtil.setUid(Process.SHELL_UID);
+
+        mThreadNetworkShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-country-code", "disabled"});
+
+        verify(mThreadNetworkCountryCode, never()).setOverrideCountryCode(any());
+        verify(mErrorWriter).println(contains("force-country-code"));
+    }
+
+    @Test
+    public void forceSetCountryCodeDisabled_executeInRootedShell_allowed() {
+        BinderUtil.setUid(Process.ROOT_UID);
+
+        mThreadNetworkShellCommand.exec(
+                new Binder(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new FileDescriptor(),
+                new String[] {"force-country-code", "disabled"});
+
+        verify(mThreadNetworkCountryCode).clearOverrideCountryCode();
+    }
+}