Merge "Revert "Re-enable the tests for background restrictions"" into main
diff --git a/common/FlaggedApi.bp b/common/FlaggedApi.bp
index c382e76..449d7ae 100644
--- a/common/FlaggedApi.bp
+++ b/common/FlaggedApi.bp
@@ -21,3 +21,11 @@
     srcs: ["flags.aconfig"],
     visibility: ["//packages/modules/Connectivity:__subpackages__"],
 }
+
+aconfig_declarations {
+    name: "nearby_flags",
+    package: "com.android.nearby.flags",
+    container: "system",
+    srcs: ["nearby_flags.aconfig"],
+    visibility: ["//packages/modules/Connectivity:__subpackages__"],
+}
diff --git a/common/nearby_flags.aconfig b/common/nearby_flags.aconfig
new file mode 100644
index 0000000..b957d33
--- /dev/null
+++ b/common/nearby_flags.aconfig
@@ -0,0 +1,9 @@
+package: "com.android.nearby.flags"
+container: "system"
+
+flag {
+    name: "powered_off_finding"
+    namespace: "nearby"
+    description: "Controls whether the Powered Off Finding feature is enabled"
+    bug: "307898240"
+}
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 9203a3e..e40b55c 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -197,6 +197,7 @@
     ],
     aconfig_declarations: [
         "com.android.net.flags-aconfig",
+        "nearby_flags",
     ],
 }
 
diff --git a/framework/src/android/net/ConnectivityManager.java b/framework/src/android/net/ConnectivityManager.java
index 1ea1815..915ec52 100644
--- a/framework/src/android/net/ConnectivityManager.java
+++ b/framework/src/android/net/ConnectivityManager.java
@@ -74,6 +74,7 @@
 import android.util.SparseIntArray;
 
 import com.android.internal.annotations.GuardedBy;
+import com.android.modules.utils.build.SdkLevel;
 
 import libcore.net.event.NetworkEventDispatcher;
 
@@ -6278,9 +6279,13 @@
     // Only the system server process and the network stack have access.
     @FlaggedApi(Flags.SUPPORT_IS_UID_NETWORKING_BLOCKED)
     @SystemApi(client = MODULE_LIBRARIES)
-    @RequiresApi(Build.VERSION_CODES.TIRAMISU)  // BPF maps were only mainlined in T
+    // Note b/326143935 kernel bug can trigger crash on some T device.
+    @RequiresApi(VERSION_CODES.UPSIDE_DOWN_CAKE)
     @RequiresPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK)
     public boolean isUidNetworkingBlocked(int uid, boolean isNetworkMetered) {
+        if (!SdkLevel.isAtLeastU()) {
+            Log.wtf(TAG, "isUidNetworkingBlocked is not supported on pre-U devices");
+        }
         final BpfNetMapsReader reader = BpfNetMapsReader.getInstance();
         // Note that before V, the data saver status in bpf is written by ConnectivityService
         // when receiving {@link #ACTION_RESTRICT_BACKGROUND_CHANGED}. Thus,
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
index fe9bbba..56202fd 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsFeatureFlags.java
@@ -118,6 +118,14 @@
     }
 
     /**
+     * Indicates whether {@link #NSD_KNOWN_ANSWER_SUPPRESSION} is enabled, including for testing.
+     */
+    public boolean isKnownAnswerSuppressionEnabled() {
+        return mIsKnownAnswerSuppressionEnabled
+                || isForceEnabledForTest(NSD_KNOWN_ANSWER_SUPPRESSION);
+    }
+
+    /**
      * The constructor for {@link MdnsFeatureFlags}.
      */
     public MdnsFeatureFlags(boolean isOffloadFeatureEnabled,
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 96a59e2..ed0bde2 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsRecordRepository.java
@@ -538,7 +538,7 @@
     }
 
     private boolean isTruncatedKnownAnswerPacket(MdnsPacket packet) {
-        if (!mMdnsFeatureFlags.mIsKnownAnswerSuppressionEnabled
+        if (!mMdnsFeatureFlags.isKnownAnswerSuppressionEnabled()
                 // Should ignore the response packet.
                 || (packet.flags & MdnsConstants.FLAGS_RESPONSE) != 0) {
             return false;
@@ -745,7 +745,7 @@
             // RR TTL as known by the Multicast DNS responder, the responder MUST
             // send an answer so as to update the querier's cache before the record
             // becomes in danger of expiration.
-            if (mMdnsFeatureFlags.mIsKnownAnswerSuppressionEnabled
+            if (mMdnsFeatureFlags.isKnownAnswerSuppressionEnabled()
                     && isKnownAnswer(info.record, knownAnswerRecords)) {
                 continue;
             }
diff --git a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
index a46be3b..db3845a 100644
--- a/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
+++ b/service-t/src/com/android/server/connectivity/mdns/MdnsReplySender.java
@@ -145,7 +145,7 @@
     public void queueReply(@NonNull MdnsReplyInfo reply) {
         ensureRunningOnHandlerThread(mHandler);
 
-        if (mMdnsFeatureFlags.mIsKnownAnswerSuppressionEnabled) {
+        if (mMdnsFeatureFlags.isKnownAnswerSuppressionEnabled()) {
             mDependencies.removeMessages(mHandler, MSG_SEND, reply.source);
 
             final MdnsReplyInfo queuingReply = mSrcReplies.remove(reply.source);
@@ -231,7 +231,7 @@
         @Override
         public void handleMessage(@NonNull Message msg) {
             final MdnsReplyInfo replyInfo;
-            if (mMdnsFeatureFlags.mIsKnownAnswerSuppressionEnabled) {
+            if (mMdnsFeatureFlags.isKnownAnswerSuppressionEnabled()) {
                 // Retrieve the MdnsReplyInfo from the map via a source address, as the reply info
                 // will be combined or updated.
                 final InetSocketAddress source = (InetSocketAddress) msg.obj;
diff --git a/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
new file mode 100644
index 0000000..8598ac4
--- /dev/null
+++ b/service-t/src/com/android/server/net/TrafficStatsRateLimitCache.java
@@ -0,0 +1,134 @@
+/*
+ * 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.net;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.net.NetworkStats;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.time.Clock;
+import java.util.HashMap;
+import java.util.Objects;
+
+/**
+ * A thread-safe cache for storing and retrieving {@link NetworkStats.Entry} objects,
+ * with an adjustable expiry duration to manage data freshness.
+ */
+class TrafficStatsRateLimitCache {
+    private final Clock mClock;
+    private final long mExpiryDurationMs;
+
+    /**
+     * Constructs a new {@link TrafficStatsRateLimitCache} with the specified expiry duration.
+     *
+     * @param clock The {@link Clock} to use for determining timestamps.
+     * @param expiryDurationMs The expiry duration in milliseconds.
+     */
+    TrafficStatsRateLimitCache(@NonNull Clock clock, long expiryDurationMs) {
+        mClock = clock;
+        mExpiryDurationMs = expiryDurationMs;
+    }
+
+    private static class TrafficStatsCacheKey {
+        @Nullable
+        public final String iface;
+        public final int uid;
+
+        TrafficStatsCacheKey(@Nullable String iface, int uid) {
+            this.iface = iface;
+            this.uid = uid;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (!(o instanceof TrafficStatsCacheKey)) return false;
+            TrafficStatsCacheKey that = (TrafficStatsCacheKey) o;
+            return uid == that.uid && Objects.equals(iface, that.iface);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(iface, uid);
+        }
+    }
+
+    private static class TrafficStatsCacheValue {
+        public final long timestamp;
+        @NonNull
+        public final NetworkStats.Entry entry;
+
+        TrafficStatsCacheValue(long timestamp, NetworkStats.Entry entry) {
+            this.timestamp = timestamp;
+            this.entry = entry;
+        }
+    }
+
+    @GuardedBy("mMap")
+    private final HashMap<TrafficStatsCacheKey, TrafficStatsCacheValue> mMap = new HashMap<>();
+
+    /**
+     * Retrieves a {@link NetworkStats.Entry} from the cache, associated with the given key.
+     *
+     * @param iface The interface name to include in the cache key. Null if not applicable.
+     * @param uid The UID to include in the cache key. {@code UID_ALL} if not applicable.
+     * @return The cached {@link NetworkStats.Entry}, or null if not found or expired.
+     */
+    @Nullable
+    NetworkStats.Entry get(String iface, int uid) {
+        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
+        synchronized (mMap) { // Synchronize for thread-safety
+            final TrafficStatsCacheValue value = mMap.get(key);
+            if (value != null && !isExpired(value.timestamp)) {
+                return value.entry;
+            } else {
+                mMap.remove(key); // Remove expired entries
+                return null;
+            }
+        }
+    }
+
+    /**
+     * Stores a {@link NetworkStats.Entry} in the cache, associated with the given key.
+     *
+     * @param iface The interface name to include in the cache key. Null if not applicable.
+     * @param uid   The UID to include in the cache key. {@code UID_ALL} if not applicable.
+     * @param entry The {@link NetworkStats.Entry} to store in the cache.
+     */
+    void put(String iface, int uid, @NonNull final NetworkStats.Entry entry) {
+        Objects.requireNonNull(entry);
+        final TrafficStatsCacheKey key = new TrafficStatsCacheKey(iface, uid);
+        synchronized (mMap) { // Synchronize for thread-safety
+            mMap.put(key, new TrafficStatsCacheValue(mClock.millis(), entry));
+        }
+    }
+
+    /**
+     * Clear the cache.
+     */
+    void clear() {
+        synchronized (mMap) {
+            mMap.clear();
+        }
+    }
+
+    private boolean isExpired(long timestamp) {
+        return mClock.millis() > timestamp + mExpiryDurationMs;
+    }
+}
diff --git a/service/src/com/android/server/ConnectivityService.java b/service/src/com/android/server/ConnectivityService.java
index 3d646fd..6839c22 100755
--- a/service/src/com/android/server/ConnectivityService.java
+++ b/service/src/com/android/server/ConnectivityService.java
@@ -6009,7 +6009,7 @@
             if (nm == null) return;
 
             if (request == CaptivePortal.APP_REQUEST_REEVALUATION_REQUIRED) {
-                hasNetworkStackPermission();
+                enforceNetworkStackPermission(mContext);
                 nm.forceReevaluation(mDeps.getCallingUid());
             }
         }
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/ExternalPacketForwarder.kt b/staticlibs/testutils/devicetests/com/android/testutils/ExternalPacketForwarder.kt
new file mode 100644
index 0000000..36eb795
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/ExternalPacketForwarder.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.testutils
+
+import java.io.FileDescriptor
+
+class ExternalPacketForwarder(
+    srcFd: FileDescriptor,
+    mtu: Int,
+    dstFd: FileDescriptor,
+    forwardMap: Map<Int, Int>
+) : PacketForwarderBase(srcFd, mtu, dstFd, forwardMap) {
+
+    /**
+     * Prepares a packet for forwarding by potentially updating the
+     * source port based on the specified port remapping rules.
+     *
+     * @param buf The packet data as a byte array.
+     * @param version The IP version of the packet (e.g., 4 for IPv4).
+     */
+    override fun remapPort(buf: ByteArray, version: Int) {
+        val transportOffset = getTransportOffset(version)
+        val intPort = getRemappedPort(buf, transportOffset)
+
+        // Copy remapped source port.
+        if (intPort != 0) {
+            setPortAt(intPort, buf, transportOffset)
+        }
+   }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/InternalPacketForwarder.kt b/staticlibs/testutils/devicetests/com/android/testutils/InternalPacketForwarder.kt
new file mode 100644
index 0000000..58829dc
--- /dev/null
+++ b/staticlibs/testutils/devicetests/com/android/testutils/InternalPacketForwarder.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.testutils
+
+import java.io.FileDescriptor
+
+class InternalPacketForwarder(
+    srcFd: FileDescriptor,
+    mtu: Int,
+    dstFd: FileDescriptor,
+    forwardMap: Map<Int, Int>
+) : PacketForwarderBase(srcFd, mtu, dstFd, forwardMap) {
+    /**
+     * Prepares a packet for forwarding by potentially updating the
+     * destination port based on the specified port remapping rules.
+     *
+     * @param buf The packet data as a byte array.
+     * @param version The IP version of the packet (e.g., 4 for IPv4).
+     */
+    override fun remapPort(buf: ByteArray, version: Int) {
+        val transportOffset = getTransportOffset(version) + DESTINATION_PORT_OFFSET
+        val extPort = getRemappedPort(buf, transportOffset)
+
+        // Copy remapped destination port.
+        if (extPort != 0) {
+            setPortAt(extPort, buf, transportOffset)
+        }
+    }
+}
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PacketBridge.kt b/staticlibs/testutils/devicetests/com/android/testutils/PacketBridge.kt
index 1a2cc88..0b736d1 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/PacketBridge.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PacketBridge.kt
@@ -40,7 +40,8 @@
 class PacketBridge(
     context: Context,
     addresses: List<LinkAddress>,
-    dnsAddr: InetAddress
+    dnsAddr: InetAddress,
+    portMapping: List<Pair<Int, Int>>
 ) {
     private val binder = Binder()
 
@@ -56,6 +57,10 @@
     // Register test networks to ConnectivityService.
     private val internalNetworkCallback: TestableNetworkCallback
     private val externalNetworkCallback: TestableNetworkCallback
+
+    private val internalForwardMap = HashMap<Int, Int>()
+    private val externalForwardMap = HashMap<Int, Int>()
+
     val internalNetwork: Network
     val externalNetwork: Network
     init {
@@ -65,14 +70,28 @@
         externalNetworkCallback = exCb
         internalNetwork = inNet
         externalNetwork = exNet
+        for (mapping in portMapping) {
+            internalForwardMap[mapping.first] = mapping.second
+            externalForwardMap[mapping.second] = mapping.first
+        }
     }
 
     // Set up the packet bridge.
     private val internalFd = internalIface.fileDescriptor.fileDescriptor
     private val externalFd = externalIface.fileDescriptor.fileDescriptor
 
-    private val pr1 = PacketForwarder(internalFd, 1500, externalFd)
-    private val pr2 = PacketForwarder(externalFd, 1500, internalFd)
+    private val pr1 = InternalPacketForwarder(
+        internalFd,
+        1500,
+        externalFd,
+        internalForwardMap
+    )
+    private val pr2 = ExternalPacketForwarder(
+        externalFd,
+        1500,
+        internalFd,
+        externalForwardMap
+    )
 
     fun start() {
         IoUtils.setBlocking(internalFd, true /* blocking */)
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/PacketForwarder.java b/staticlibs/testutils/devicetests/com/android/testutils/PacketForwarderBase.java
similarity index 68%
rename from staticlibs/testutils/devicetests/com/android/testutils/PacketForwarder.java
rename to staticlibs/testutils/devicetests/com/android/testutils/PacketForwarderBase.java
index d8efb7d..5c79eb0 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/PacketForwarder.java
+++ b/staticlibs/testutils/devicetests/com/android/testutils/PacketForwarderBase.java
@@ -32,6 +32,7 @@
 
 import java.io.FileDescriptor;
 import java.io.IOException;
+import java.util.Map;
 import java.util.Objects;
 
 /**
@@ -57,8 +58,9 @@
  * from the http server, the same mechanism is applied but in a different direction,
  * where the source and destination will be swapped.
  */
-public class PacketForwarder extends Thread {
+public abstract class PacketForwarderBase extends Thread {
     private static final String TAG = "PacketForwarder";
+    static final int DESTINATION_PORT_OFFSET = 2;
 
     // The source fd to read packets from.
     @NonNull
@@ -70,8 +72,10 @@
     @NonNull
     final FileDescriptor mDstFd;
 
+    @NonNull
+    final Map<Integer, Integer> mPortRemapRules;
     /**
-     * Construct a {@link PacketForwarder}.
+     * Construct a {@link PacketForwarderBase}.
      *
      * This class reads packets from {@code srcFd} of a {@link TestNetworkInterface}, and
      * forwards them to the {@code dstFd} of another {@link TestNetworkInterface}.
@@ -82,13 +86,49 @@
      * @param srcFd   {@link FileDescriptor} to read packets from.
      * @param mtu     MTU of the test network.
      * @param dstFd   {@link FileDescriptor} to write packets to.
+     * @param portRemapRules    port remap rules
      */
-    public PacketForwarder(@NonNull FileDescriptor srcFd, int mtu,
-                           @NonNull FileDescriptor dstFd) {
+    public PacketForwarderBase(@NonNull FileDescriptor srcFd, int mtu,
+                           @NonNull FileDescriptor dstFd,
+                           @NonNull Map<Integer, Integer> portRemapRules) {
         super(TAG);
         mSrcFd = Objects.requireNonNull(srcFd);
         mBuf = new byte[mtu];
         mDstFd = Objects.requireNonNull(dstFd);
+        mPortRemapRules = Objects.requireNonNull(portRemapRules);
+    }
+
+    /**
+     * A method to prepare forwarding packets between two instances of {@link TestNetworkInterface},
+     * which includes ports mapping.
+     * Subclasses should override this method to implement the needed port remapping.
+     * For internal forwarder will remapped destination port,
+     * external forwarder will remapped source port.
+     * Example:
+     * An outgoing packet from the internal interface with
+     * source 1.2.3.4:1234 and destination 8.8.8.8:80
+     * might be translated to 8.8.8.8:1234 -> 1.2.3.4:8080 before forwarding.
+     * An outgoing packet from the external interface with
+     * source 1.2.3.4:8080 and destination 8.8.8.8:1234
+     * might be translated to 8.8.8.8:80 -> 1.2.3.4:1234 before forwarding.
+     */
+    abstract void remapPort(@NonNull byte[] buf, int version);
+
+    /**
+     * Retrieves a potentially remapped port number from a packet.
+     *
+     * @param buf            The packet data as a byte array.
+     * @param transportOffset The offset within the packet where the transport layer port begins.
+     * @return The remapped port if a mapping exists in the internal forwarding map,
+     *         otherwise returns 0 (indicating no remapping).
+     */
+    int getRemappedPort(@NonNull byte[] buf, int transportOffset) {
+        int port = PacketReflectorUtil.getPortAt(buf, transportOffset);
+        return mPortRemapRules.getOrDefault(port, 0);
+    }
+
+    int getTransportOffset(int version) {
+        return version == 4 ? IPV4_HEADER_LENGTH : IPV6_HEADER_LENGTH;
     }
 
     private void forwardPacket(@NonNull byte[] buf, int len) {
@@ -99,7 +139,13 @@
         }
     }
 
-    // Reads one packet from mSrcFd, and writes the packet to the mDstFd for supported protocols.
+    /**
+     * Reads one packet from mSrcFd, and writes the packet to the mDestFd for supported protocols.
+     * This includes:
+     * 1.Address Swapping: Swaps source and destination IP addresses.
+     * 2.Port Remapping: Remap port if necessary.
+     * 3.Checksum Recalculation: Updates IP and transport layer checksums to reflect changes.
+     */
     private void processPacket() {
         final int len = PacketReflectorUtil.readPacket(mSrcFd, mBuf);
         if (len < 1) {
@@ -142,13 +188,19 @@
         if (len < ipHdrLen + transportHdrLen) {
             throw new IllegalStateException("Unexpected buffer length: " + len);
         }
-        // Swap addresses.
+
+        // Swap source and destination address.
         PacketReflectorUtil.swapAddresses(mBuf, version);
 
+        // Remapping the port.
+        remapPort(mBuf, version);
+
+        // Fix IP and Transport layer checksum.
+        PacketReflectorUtil.fixPacketChecksum(mBuf, len, version, proto);
+
         // Send the packet to the destination fd.
         forwardPacket(mBuf, len);
     }
-
     @Override
     public void run() {
         Log.i(TAG, "starting fd=" + mSrcFd + " valid=" + mSrcFd.valid());
diff --git a/staticlibs/testutils/devicetests/com/android/testutils/TestHttpServer.kt b/staticlibs/testutils/devicetests/com/android/testutils/TestHttpServer.kt
index 740bf63..f1f0c1c 100644
--- a/staticlibs/testutils/devicetests/com/android/testutils/TestHttpServer.kt
+++ b/staticlibs/testutils/devicetests/com/android/testutils/TestHttpServer.kt
@@ -25,8 +25,10 @@
  * A minimal HTTP server running on a random available port.
  *
  * @param host The host to listen to, or null to listen on all hosts
+ * @param port The port to listen to, or 0 to auto select
  */
-class TestHttpServer(host: String? = null) : NanoHTTPD(host, 0 /* auto-select the port */) {
+class TestHttpServer
+    @JvmOverloads constructor(host: String? = null, port: Int = 0) : NanoHTTPD(host, port) {
     // Map of URL path -> HTTP response code
     private val responses = HashMap<Request, Response>()
 
diff --git a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
index 6db372f..ce2c2c1 100644
--- a/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
+++ b/tests/cts/net/src/android/net/cts/NsdManagerTest.kt
@@ -1716,6 +1716,177 @@
         }
     }
 
+    @Test
+    fun testReplyWhenKnownAnswerSuppressionFlagSet() {
+        // The flag may be removed in the future but known-answer suppression should be enabled by
+        // default in that case. The rule will reset flags automatically on teardown.
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_known_answer_suppression", "1")
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_unicast_reply_enabled", "1")
+
+        val si = makeTestServiceInfo(testNetwork1.network)
+
+        // Register service on testNetwork1
+        val registrationRecord = NsdRegistrationRecord()
+        var nsResponder: NSResponder? = null
+        tryTest {
+            registerService(registrationRecord, si)
+            val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                    testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+            packetReader.startAsyncForTest()
+
+            handlerThread.waitForIdle(TIMEOUT_MS)
+            /*
+            Send a query with a known answer. Expect to receive a response containing TXT record
+            only.
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=0, aa=0, qd =
+                    scapy.DNSQR(qname='_nmt123456789._tcp.local', qtype='PTR',
+                            qclass=0x8001) /
+                    scapy.DNSQR(qname='NsdTest123456789._nmt123456789._tcp.local', qtype='TXT',
+                            qclass=0x8001),
+                    an = scapy.DNSRR(rrname='_nmt123456789._tcp.local', type='PTR', ttl=4500,
+                            rdata='NsdTest123456789._nmt123456789._tcp.local')
+            )).hex()
+            */
+            val query = HexDump.hexStringToByteArray("0000000000020001000000000d5f6e6d74313233343" +
+                    "536373839045f746370056c6f63616c00000c8001104e7364546573743132333435363738390" +
+                    "d5f6e6d74313233343536373839045f746370056c6f63616c00001080010d5f6e6d743132333" +
+                    "43536373839045f746370056c6f63616c00000c000100001194002b104e73645465737431323" +
+                    "33435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00")
+            replaceServiceNameAndTypeWithTestSuffix(query)
+
+            val testSrcAddr = makeLinkLocalAddressOfOtherDeviceOnPrefix(testNetwork1.network)
+            nsResponder = NSResponder(packetReader, mapOf(
+                    testSrcAddr to MacAddress.fromString("01:02:03:04:05:06")
+            )).apply { start() }
+
+            packetReader.sendResponse(buildMdnsPacket(query, testSrcAddr))
+            // The reply is sent unicast to the source address. There may be announcements sent
+            // multicast around this time, so filter by destination address.
+            val reply = packetReader.pollForMdnsPacket { pkt ->
+                pkt.isReplyFor("$serviceName.$serviceType.local", DnsResolver.TYPE_TXT) &&
+                        !pkt.isReplyFor("$serviceType.local", DnsResolver.TYPE_PTR) &&
+                        pkt.dstAddr == testSrcAddr
+            }
+            assertNotNull(reply)
+
+            /*
+            Send a query with a known answer (TTL is less than half). Expect to receive a response
+            containing both PTR and TXT records.
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=0, aa=0, qd =
+                    scapy.DNSQR(qname='_nmt123456789._tcp.local', qtype='PTR',
+                            qclass=0x8001) /
+                    scapy.DNSQR(qname='NsdTest123456789._nmt123456789._tcp.local', qtype='TXT',
+                            qclass=0x8001),
+                    an = scapy.DNSRR(rrname='_nmt123456789._tcp.local', type='PTR', ttl=2150,
+                            rdata='NsdTest123456789._nmt123456789._tcp.local')
+            )).hex()
+            */
+            val query2 = HexDump.hexStringToByteArray("0000000000020001000000000d5f6e6d7431323334" +
+                    "3536373839045f746370056c6f63616c00000c8001104e736454657374313233343536373839" +
+                    "0d5f6e6d74313233343536373839045f746370056c6f63616c00001080010d5f6e6d74313233" +
+                    "343536373839045f746370056c6f63616c00000c000100000866002b104e7364546573743132" +
+                    "333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00")
+            replaceServiceNameAndTypeWithTestSuffix(query2)
+
+            packetReader.sendResponse(buildMdnsPacket(query2, testSrcAddr))
+            // The reply is sent unicast to the source address. There may be announcements sent
+            // multicast around this time, so filter by destination address.
+            val reply2 = packetReader.pollForMdnsPacket { pkt ->
+                pkt.isReplyFor("$serviceName.$serviceType.local", DnsResolver.TYPE_TXT) &&
+                        pkt.isReplyFor("$serviceType.local", DnsResolver.TYPE_PTR) &&
+                        pkt.dstAddr == testSrcAddr
+            }
+            assertNotNull(reply2)
+        } cleanup {
+            nsResponder?.stop()
+            nsdManager.unregisterService(registrationRecord)
+            registrationRecord.expectCallback<ServiceUnregistered>()
+        }
+    }
+
+    @Test
+    fun testReplyWithMultipacketWhenKnownAnswerSuppressionFlagSet() {
+        // The flag may be removed in the future but known-answer suppression should be enabled by
+        // default in that case. The rule will reset flags automatically on teardown.
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_known_answer_suppression", "1")
+        deviceConfigRule.setConfig(NAMESPACE_TETHERING, "test_nsd_unicast_reply_enabled", "1")
+
+        val si = makeTestServiceInfo(testNetwork1.network)
+
+        // Register service on testNetwork1
+        val registrationRecord = NsdRegistrationRecord()
+        var nsResponder: NSResponder? = null
+        tryTest {
+            registerService(registrationRecord, si)
+            val packetReader = TapPacketReader(Handler(handlerThread.looper),
+                    testNetwork1.iface.fileDescriptor.fileDescriptor, 1500 /* maxPacketSize */)
+            packetReader.startAsyncForTest()
+
+            handlerThread.waitForIdle(TIMEOUT_MS)
+            /*
+            Send a query with truncated bit set.
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=0, aa=0, tc=1, qd=
+                    scapy.DNSQR(qname='_nmt123456789._tcp.local', qtype='PTR',
+                            qclass=0x8001) /
+                    scapy.DNSQR(qname='NsdTest123456789._nmt123456789._tcp.local', qtype='TXT',
+                            qclass=0x8001)
+            )).hex()
+            */
+            val query = HexDump.hexStringToByteArray("0000020000020000000000000d5f6e6d74313233343" +
+                    "536373839045f746370056c6f63616c00000c8001104e7364546573743132333435363738390" +
+                    "d5f6e6d74313233343536373839045f746370056c6f63616c0000108001")
+            replaceServiceNameAndTypeWithTestSuffix(query)
+            /*
+            Send a known answer packet (other service) with truncated bit set.
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=0, aa=0, tc=1, qd=None,
+                    an = scapy.DNSRR(rrname='_test._tcp.local', type='PTR', ttl=4500,
+                            rdata='NsdTest._test._tcp.local')
+            )).hex()
+            */
+            val knownAnswer1 = HexDump.hexStringToByteArray("000002000000000100000000055f74657374" +
+                    "045f746370056c6f63616c00000c000100001194001a074e736454657374055f74657374045f" +
+                    "746370056c6f63616c00")
+            replaceServiceNameAndTypeWithTestSuffix(knownAnswer1)
+            /*
+            Send a known answer packet.
+            Generated with:
+            scapy.raw(scapy.DNS(rd=0, qr=0, aa=0, qd=None,
+                    an = scapy.DNSRR(rrname='_nmt123456789._tcp.local', type='PTR', ttl=4500,
+                            rdata='NsdTest123456789._nmt123456789._tcp.local')
+            )).hex()
+            */
+            val knownAnswer2 = HexDump.hexStringToByteArray("0000000000000001000000000d5f6e6d7431" +
+                    "3233343536373839045f746370056c6f63616c00000c000100001194002b104e736454657374" +
+                    "3132333435363738390d5f6e6d74313233343536373839045f746370056c6f63616c00")
+            replaceServiceNameAndTypeWithTestSuffix(knownAnswer2)
+
+            val testSrcAddr = makeLinkLocalAddressOfOtherDeviceOnPrefix(testNetwork1.network)
+            nsResponder = NSResponder(packetReader, mapOf(
+                    testSrcAddr to MacAddress.fromString("01:02:03:04:05:06")
+            )).apply { start() }
+
+            packetReader.sendResponse(buildMdnsPacket(query, testSrcAddr))
+            packetReader.sendResponse(buildMdnsPacket(knownAnswer1, testSrcAddr))
+            packetReader.sendResponse(buildMdnsPacket(knownAnswer2, testSrcAddr))
+            // The reply is sent unicast to the source address. There may be announcements sent
+            // multicast around this time, so filter by destination address.
+            val reply = packetReader.pollForMdnsPacket { pkt ->
+                pkt.isReplyFor("$serviceName.$serviceType.local", DnsResolver.TYPE_TXT) &&
+                        !pkt.isReplyFor("$serviceType.local", DnsResolver.TYPE_PTR) &&
+                        pkt.dstAddr == testSrcAddr
+            }
+            assertNotNull(reply)
+        } cleanup {
+            nsResponder?.stop()
+            nsdManager.unregisterService(registrationRecord)
+            registrationRecord.expectCallback<ServiceUnregistered>()
+        }
+    }
+
     private fun makeLinkLocalAddressOfOtherDeviceOnPrefix(network: Network): Inet6Address {
         val lp = cm.getLinkProperties(network) ?: fail("No LinkProperties for net $network")
         // Expect to have a /64 link-local address
diff --git a/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt b/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt
new file mode 100644
index 0000000..be2b29c
--- /dev/null
+++ b/tests/unit/java/com/android/server/connectivityservice/CSCaptivePortalAppTest.kt
@@ -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
+
+import android.Manifest.permission.NETWORK_STACK
+import android.content.Intent
+import android.content.pm.PackageManager.PERMISSION_DENIED
+import android.content.pm.PackageManager.PERMISSION_GRANTED
+import android.net.ConnectivityManager.ACTION_CAPTIVE_PORTAL_SIGN_IN
+import android.net.ConnectivityManager.EXTRA_CAPTIVE_PORTAL
+import android.net.IpPrefix
+import android.net.LinkAddress
+import android.net.LinkProperties
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_VCN_MANAGED
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import android.net.NetworkStack
+import android.net.CaptivePortal
+import android.net.NetworkRequest
+import android.net.NetworkScore
+import android.net.NetworkScore.KEEP_CONNECTED_FOR_TEST
+import android.net.RouteInfo
+import android.os.Build
+import android.os.Bundle
+import androidx.test.filters.SmallTest
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
+import com.android.testutils.DevSdkIgnoreRunner
+import com.android.testutils.assertThrows
+import com.android.testutils.TestableNetworkCallback
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.verify
+import kotlin.test.assertEquals
+
+// This allows keeping all the networks connected without having to file individual requests
+// for them.
+private fun keepScore() = FromS(
+        NetworkScore.Builder().setKeepConnectedReason(KEEP_CONNECTED_FOR_TEST).build()
+)
+
+private fun nc(transport: Int, vararg caps: Int) = NetworkCapabilities.Builder().apply {
+    addTransportType(transport)
+    caps.forEach {
+        addCapability(it)
+    }
+    // Useful capabilities for everybody
+    addCapability(NET_CAPABILITY_NOT_RESTRICTED)
+    addCapability(NET_CAPABILITY_NOT_SUSPENDED)
+    addCapability(NET_CAPABILITY_NOT_ROAMING)
+    addCapability(NET_CAPABILITY_NOT_VCN_MANAGED)
+}.build()
+
+private fun lp(iface: String) = LinkProperties().apply {
+    interfaceName = iface
+    addLinkAddress(LinkAddress(LOCAL_IPV4_ADDRESS, 32))
+    addRoute(RouteInfo(IpPrefix("0.0.0.0/0"), null, null))
+}
+
+@DevSdkIgnoreRunner.MonitorThreadLeak
+@RunWith(DevSdkIgnoreRunner::class)
+@SmallTest
+@IgnoreUpTo(Build.VERSION_CODES.R)
+class CSCaptivePortalAppTest : CSTest() {
+    private val WIFI_IFACE = "wifi0"
+    private val TEST_REDIRECT_URL = "http://example.com/firstPath"
+    private val TIMEOUT_MS = 2_000L
+
+    @Test
+    fun testCaptivePortalApp_Reevaluate_Nopermission() {
+        val captivePortalCallback = TestableNetworkCallback()
+        val captivePortalRequest = NetworkRequest.Builder()
+                .addCapability(NET_CAPABILITY_CAPTIVE_PORTAL).build()
+        cm.registerNetworkCallback(captivePortalRequest, captivePortalCallback)
+        val wifiAgent = createWifiAgent()
+        wifiAgent.connectWithCaptivePortal(TEST_REDIRECT_URL)
+        captivePortalCallback.expectAvailableCallbacksUnvalidated(wifiAgent)
+        val signInIntent = startCaptivePortalApp(wifiAgent)
+        // Remove the granted permissions
+        context.setPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK,
+                PERMISSION_DENIED)
+        context.setPermission(NETWORK_STACK, PERMISSION_DENIED)
+        val captivePortal: CaptivePortal? = signInIntent.getParcelableExtra(EXTRA_CAPTIVE_PORTAL)
+        assertThrows(SecurityException::class.java, { captivePortal?.reevaluateNetwork() })
+    }
+
+    private fun createWifiAgent(): CSAgentWrapper {
+        return Agent(score = keepScore(), lp = lp(WIFI_IFACE),
+                nc = nc(TRANSPORT_WIFI, NET_CAPABILITY_INTERNET))
+    }
+
+    private fun startCaptivePortalApp(networkAgent: CSAgentWrapper): Intent {
+        val network = networkAgent.network
+        cm.startCaptivePortalApp(network)
+        waitForIdle()
+        verify(networkAgent.networkMonitor).launchCaptivePortalApp()
+
+        val testBundle = Bundle()
+        val testKey = "testkey"
+        val testValue = "testvalue"
+        testBundle.putString(testKey, testValue)
+        context.setPermission(NetworkStack.PERMISSION_MAINLINE_NETWORK_STACK, PERMISSION_GRANTED)
+        cm.startCaptivePortalApp(network, testBundle)
+        val signInIntent: Intent = context.expectStartActivityIntent(TIMEOUT_MS)
+        assertEquals(ACTION_CAPTIVE_PORTAL_SIGN_IN, signInIntent.getAction())
+        assertEquals(testValue, signInIntent.getStringExtra(testKey))
+        return signInIntent
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
index d41c742..d7343b1 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSAgentWrapper.kt
@@ -19,6 +19,8 @@
 import android.content.Context
 import android.net.ConnectivityManager
 import android.net.INetworkMonitor
+import android.net.INetworkMonitor.NETWORK_VALIDATION_PROBE_DNS
+import android.net.INetworkMonitor.NETWORK_VALIDATION_PROBE_HTTP
 import android.net.INetworkMonitorCallbacks
 import android.net.LinkProperties
 import android.net.LocalNetworkConfig
@@ -75,10 +77,15 @@
 ) : TestableNetworkCallback.HasNetwork {
     private val TAG = "CSAgent${nextAgentId()}"
     private val VALIDATION_RESULT_INVALID = 0
+    private val NO_PROBE_RESULT = 0
     private val VALIDATION_TIMESTAMP = 1234L
     private val agent: NetworkAgent
     private val nmCallbacks: INetworkMonitorCallbacks
     val networkMonitor = mock<INetworkMonitor>()
+    private var nmValidationRedirectUrl: String? = null
+    private var nmValidationResult = NO_PROBE_RESULT
+    private var nmProbesCompleted = NO_PROBE_RESULT
+    private var nmProbesSucceeded = NO_PROBE_RESULT
 
     override val network: Network get() = agent.network!!
 
@@ -120,10 +127,10 @@
         }
         nmCallbacks.notifyProbeStatusChanged(0 /* completed */, 0 /* succeeded */)
         val p = NetworkTestResultParcelable()
-        p.result = VALIDATION_RESULT_INVALID
-        p.probesAttempted = 0
-        p.probesSucceeded = 0
-        p.redirectUrl = null
+        p.result = nmValidationResult
+        p.probesAttempted = nmProbesCompleted
+        p.probesSucceeded = nmProbesSucceeded
+        p.redirectUrl = nmValidationRedirectUrl
         p.timestampMillis = VALIDATION_TIMESTAMP
         nmCallbacks.notifyNetworkTestedWithExtras(p)
     }
@@ -171,4 +178,26 @@
 
     fun sendLocalNetworkConfig(lnc: LocalNetworkConfig) = agent.sendLocalNetworkConfig(lnc)
     fun sendNetworkCapabilities(nc: NetworkCapabilities) = agent.sendNetworkCapabilities(nc)
+
+    fun connectWithCaptivePortal(redirectUrl: String) {
+        setCaptivePortal(redirectUrl)
+        connect()
+    }
+
+    fun setProbesStatus(probesCompleted: Int, probesSucceeded: Int) {
+        nmProbesCompleted = probesCompleted
+        nmProbesSucceeded = probesSucceeded
+    }
+
+    fun setCaptivePortal(redirectUrl: String) {
+        nmValidationResult = VALIDATION_RESULT_INVALID
+        nmValidationRedirectUrl = redirectUrl
+        // Suppose the portal is found when NetworkMonitor probes NETWORK_VALIDATION_PROBE_HTTP
+        // in the beginning. Because NETWORK_VALIDATION_PROBE_HTTP is the decisive probe for captive
+        // portal, considering the NETWORK_VALIDATION_PROBE_HTTPS hasn't probed yet and set only
+        // DNS and HTTP probes completed.
+        setProbesStatus(
+            NETWORK_VALIDATION_PROBE_DNS or NETWORK_VALIDATION_PROBE_HTTP /* probesCompleted */,
+            VALIDATION_RESULT_INVALID /* probesSucceeded */)
+    }
 }
diff --git a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
index e401434..595ca47 100644
--- a/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
+++ b/tests/unit/java/com/android/server/connectivityservice/base/CSTest.kt
@@ -47,8 +47,10 @@
 import android.os.Bundle
 import android.os.Handler
 import android.os.HandlerThread
+import android.os.Process
 import android.os.UserHandle
 import android.os.UserManager
+import android.permission.PermissionManager.PermissionResult
 import android.telephony.TelephonyManager
 import android.testing.TestableContext
 import android.util.ArraySet
@@ -71,8 +73,11 @@
 import com.android.testutils.visibleOnHandlerThread
 import com.android.testutils.waitForIdle
 import java.util.concurrent.Executors
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
 import java.util.function.Consumer
 import java.util.function.BiConsumer
+import kotlin.test.assertNotNull
 import kotlin.test.assertNull
 import kotlin.test.fail
 import org.junit.After
@@ -82,7 +87,7 @@
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
 
-internal const val HANDLER_TIMEOUT_MS = 2_000
+internal const val HANDLER_TIMEOUT_MS = 2_000L
 internal const val BROADCAST_TIMEOUT_MS = 3_000L
 internal const val TEST_PACKAGE_NAME = "com.android.test.package"
 internal const val WIFI_WOL_IFNAME = "test_wlan_wol"
@@ -300,13 +305,65 @@
         val pacProxyManager = mock<PacProxyManager>()
         val networkPolicyManager = mock<NetworkPolicyManager>()
 
+        // Map of permission name -> PermissionManager.Permission_{GRANTED|DENIED} constant
+        // For permissions granted across the board, the key is only the permission name.
+        // For permissions only granted to a combination of uid/pid, the key
+        // is "<permission name>,<pid>,<uid>". PID+UID permissions have priority over generic ones.
+        private val mMockedPermissions: HashMap<String, Int> = HashMap()
+        private val mStartedActivities = LinkedBlockingQueue<Intent>()
         override fun getPackageManager() = this@CSTest.packageManager
         override fun getContentResolver() = this@CSTest.contentResolver
 
-        // TODO : buff up the capabilities of this permission scheme to allow checking for
-        // permission rejections
-        override fun checkPermission(permission: String, pid: Int, uid: Int) = PERMISSION_GRANTED
-        override fun checkCallingOrSelfPermission(permission: String) = PERMISSION_GRANTED
+        // If the permission result does not set in the mMockedPermissions, it will be
+        // considered as PERMISSION_GRANTED as existing design to prevent breaking other tests.
+        override fun checkPermission(permission: String, pid: Int, uid: Int) =
+            checkMockedPermission(permission, pid, uid, PERMISSION_GRANTED)
+
+        override fun enforceCallingOrSelfPermission(permission: String, message: String?) {
+            // If the permission result does not set in the mMockedPermissions, it will be
+            // considered as PERMISSION_GRANTED as existing design to prevent breaking other tests.
+            val granted = checkMockedPermission(permission, Process.myPid(), Process.myUid(),
+                PERMISSION_GRANTED)
+            if (!granted.equals(PERMISSION_GRANTED)) {
+                throw SecurityException("[Test] permission denied: " + permission)
+            }
+        }
+
+        // If the permission result does not set in the mMockedPermissions, it will be
+        // considered as PERMISSION_GRANTED as existing design to prevent breaking other tests.
+        override fun checkCallingOrSelfPermission(permission: String) =
+            checkMockedPermission(permission, Process.myPid(), Process.myUid(), PERMISSION_GRANTED)
+
+        private fun checkMockedPermission(permission: String, pid: Int, uid: Int, default: Int):
+                Int {
+            val processSpecificKey = "$permission,$pid,$uid"
+            return mMockedPermissions[processSpecificKey]
+                    ?: mMockedPermissions[permission] ?: default
+        }
+
+        /**
+         * Mock checks for the specified permission, and have them behave as per `granted` or
+         * `denied`.
+         *
+         * This will apply to all calls no matter what the checked UID and PID are.
+         *
+         * @param granted One of {@link PackageManager#PermissionResult}.
+         */
+        fun setPermission(permission: String, @PermissionResult granted: Int) {
+            mMockedPermissions.put(permission, granted)
+        }
+
+        /**
+         * Mock checks for the specified permission, and have them behave as per `granted` or
+         * `denied`.
+         *
+         * This will only apply to the passed UID and PID.
+         *
+         * @param granted One of {@link PackageManager#PermissionResult}.
+         */
+        fun setPermission(permission: String, pid: Int, uid: Int, @PermissionResult granted: Int) {
+            mMockedPermissions.put("$permission,$pid,$uid", granted)
+        }
 
         // Necessary for MultinetworkPolicyTracker, which tries to register a receiver for
         // all users. The test can't do that since it doesn't hold INTERACT_ACROSS_USERS.
@@ -364,6 +421,16 @@
         ) {
             orderedBroadcastAsUserHistory.add(intent)
         }
+
+        override fun startActivityAsUser(intent: Intent, handle: UserHandle) {
+            mStartedActivities.put(intent)
+        }
+
+        fun expectStartActivityIntent(timeoutMs: Long = HANDLER_TIMEOUT_MS): Intent {
+            val intent = mStartedActivities.poll(timeoutMs, TimeUnit.MILLISECONDS)
+            assertNotNull(intent, "Did not receive sign-in intent after " + timeoutMs + "ms")
+            return intent
+        }
     }
 
     // Utility methods for subclasses to use
diff --git a/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
new file mode 100644
index 0000000..27e6f96
--- /dev/null
+++ b/tests/unit/java/com/android/server/net/TrafficStatsRateLimitCacheTest.kt
@@ -0,0 +1,89 @@
+/*
+ * 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.net
+
+import android.net.NetworkStats
+import com.android.testutils.DevSdkIgnoreRunner
+import java.time.Clock
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when`
+
+@RunWith(DevSdkIgnoreRunner::class)
+class TrafficStatsRateLimitCacheTest {
+    companion object {
+        private const val expiryDurationMs = 1000L
+    }
+
+    private val clock = mock(Clock::class.java)
+    private val entry = mock(NetworkStats.Entry::class.java)
+    private val cache = TrafficStatsRateLimitCache(clock, expiryDurationMs)
+
+    @Test
+    fun testGet_returnsEntryIfNotExpired() {
+        cache.put("iface", 2, entry)
+        `when`(clock.millis()).thenReturn(500L) // Set clock to before expiry
+        val result = cache.get("iface", 2)
+        assertEquals(entry, result)
+    }
+
+    @Test
+    fun testGet_returnsNullIfExpired() {
+        cache.put("iface", 2, entry)
+        `when`(clock.millis()).thenReturn(2000L) // Set clock to after expiry
+        assertNull(cache.get("iface", 2))
+    }
+
+    @Test
+    fun testGet_returnsNullForNonExistentKey() {
+        val result = cache.get("otherIface", 99)
+        assertNull(result)
+    }
+
+    @Test
+    fun testPutAndGet_retrievesCorrectEntryForDifferentKeys() {
+        val entry1 = mock(NetworkStats.Entry::class.java)
+        val entry2 = mock(NetworkStats.Entry::class.java)
+
+        cache.put("iface1", 2, entry1)
+        cache.put("iface2", 4, entry2)
+
+        assertEquals(entry1, cache.get("iface1", 2))
+        assertEquals(entry2, cache.get("iface2", 4))
+    }
+
+    @Test
+    fun testPut_overridesExistingEntry() {
+        val entry1 = mock(NetworkStats.Entry::class.java)
+        val entry2 = mock(NetworkStats.Entry::class.java)
+
+        cache.put("iface", 2, entry1)
+        cache.put("iface", 2, entry2) // Put with the same key
+
+        assertEquals(entry2, cache.get("iface", 2))
+    }
+
+    @Test
+    fun testClear() {
+        cache.put("iface", 2, entry)
+        cache.clear()
+        assertNull(cache.get("iface", 2))
+    }
+}
diff --git a/thread/flags/Android.bp b/thread/flags/Android.bp
new file mode 100644
index 0000000..15f58a9
--- /dev/null
+++ b/thread/flags/Android.bp
@@ -0,0 +1,23 @@
+//
+// 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.
+//
+
+aconfig_declarations {
+    name: "com.android.net.thread.flags-aconfig",
+    package: "com.android.net.thread.flags",
+    container: "system",
+    srcs: ["thread_base.aconfig"],
+    visibility: ["//packages/modules/Connectivity:__subpackages__"],
+}
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkManager.java b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
index b584487..150b759 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkManager.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkManager.java
@@ -83,8 +83,8 @@
      * This user restriction specifies if Thread network is disallowed on the device. If Thread
      * network is disallowed it cannot be turned on via Settings.
      *
-     * <p>this is a mirror of {@link UserManager#DISALLOW_THREAD_NETWORK} which is not available
-     * on Android U devices.
+     * <p>this is a mirror of {@link UserManager#DISALLOW_THREAD_NETWORK} which is not available on
+     * Android U devices.
      *
      * @hide
      */
diff --git a/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java b/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java
new file mode 100644
index 0000000..e3b4e1a
--- /dev/null
+++ b/thread/service/java/com/android/server/thread/ActiveOperationalDatasetReceiverWrapper.java
@@ -0,0 +1,87 @@
+/*
+ * 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 android.net.thread.ThreadNetworkException.ERROR_UNAVAILABLE;
+
+import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IActiveOperationalDatasetReceiver;
+import android.os.RemoteException;
+
+import com.android.internal.annotations.GuardedBy;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A {@link IActiveOperationalDatasetReceiver} wrapper which makes it easier to invoke the
+ * callbacks.
+ */
+final class ActiveOperationalDatasetReceiverWrapper {
+    private final IActiveOperationalDatasetReceiver mReceiver;
+
+    private static final Object sPendingReceiversLock = new Object();
+
+    @GuardedBy("sPendingReceiversLock")
+    private static final Set<ActiveOperationalDatasetReceiverWrapper> sPendingReceivers =
+            new HashSet<>();
+
+    public ActiveOperationalDatasetReceiverWrapper(IActiveOperationalDatasetReceiver receiver) {
+        this.mReceiver = receiver;
+
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.add(this);
+        }
+    }
+
+    public static void onOtDaemonDied() {
+        synchronized (sPendingReceiversLock) {
+            for (ActiveOperationalDatasetReceiverWrapper receiver : sPendingReceivers) {
+                try {
+                    receiver.mReceiver.onError(ERROR_UNAVAILABLE, "Thread daemon died");
+                } catch (RemoteException e) {
+                    // The client is dead, do nothing
+                }
+            }
+            sPendingReceivers.clear();
+        }
+    }
+
+    public void onSuccess(ActiveOperationalDataset dataset) {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onSuccess(dataset);
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+
+    public void onError(int errorCode, String errorMessage) {
+        synchronized (sPendingReceiversLock) {
+            sPendingReceivers.remove(this);
+        }
+
+        try {
+            mReceiver.onError(errorCode, errorMessage);
+        } catch (RemoteException e) {
+            // The client is dead, do nothing
+        }
+    }
+}
diff --git a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
index 44745b3..56dd056 100644
--- a/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
+++ b/thread/service/java/com/android/server/thread/ThreadNetworkControllerService.java
@@ -46,7 +46,7 @@
 
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_ABORT;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_BUSY;
-import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_DETACHED;
+import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_FAILED_PRECONDITION;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_NO_BUFS;
 import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_PARSE;
@@ -109,6 +109,7 @@
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.server.ServiceManagerWrapper;
 import com.android.server.thread.openthread.BorderRouterConfigurationParcel;
+import com.android.server.thread.openthread.IChannelMasksReceiver;
 import com.android.server.thread.openthread.IOtDaemon;
 import com.android.server.thread.openthread.IOtDaemonCallback;
 import com.android.server.thread.openthread.IOtStatusReceiver;
@@ -157,9 +158,6 @@
     private final NsdPublisher mNsdPublisher;
     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
-
     @Nullable private IOtDaemon mOtDaemon;
     @Nullable private NetworkAgent mNetworkAgent;
     @Nullable private NetworkAgent mTestNetworkAgent;
@@ -593,26 +591,51 @@
     @Override
     public void createRandomizedDataset(
             String networkName, IActiveOperationalDatasetReceiver receiver) {
-        mHandler.post(
-                () -> {
-                    ActiveOperationalDataset dataset =
-                            createRandomizedDatasetInternal(
-                                    networkName,
-                                    mSupportedChannelMask,
-                                    Instant.now(),
-                                    new Random(),
-                                    new SecureRandom());
-                    try {
-                        receiver.onSuccess(dataset);
-                    } catch (RemoteException e) {
-                        // The client is dead, do nothing
-                    }
-                });
+        ActiveOperationalDatasetReceiverWrapper receiverWrapper =
+                new ActiveOperationalDatasetReceiverWrapper(receiver);
+        mHandler.post(() -> createRandomizedDatasetInternal(networkName, receiverWrapper));
     }
 
-    private static ActiveOperationalDataset createRandomizedDatasetInternal(
+    private void createRandomizedDatasetInternal(
+            String networkName, @NonNull ActiveOperationalDatasetReceiverWrapper receiver) {
+        checkOnHandlerThread();
+
+        try {
+            getOtDaemon().getChannelMasks(newChannelMasksReceiver(networkName, receiver));
+        } catch (RemoteException e) {
+            Log.e(TAG, "otDaemon.getChannelMasks failed", e);
+            receiver.onError(ERROR_INTERNAL_ERROR, "Thread stack error");
+        }
+    }
+
+    private IChannelMasksReceiver newChannelMasksReceiver(
+            String networkName, ActiveOperationalDatasetReceiverWrapper receiver) {
+        return new IChannelMasksReceiver.Stub() {
+            @Override
+            public void onSuccess(int supportedChannelMask, int preferredChannelMask) {
+                ActiveOperationalDataset dataset =
+                        createRandomizedDataset(
+                                networkName,
+                                supportedChannelMask,
+                                preferredChannelMask,
+                                Instant.now(),
+                                new Random(),
+                                new SecureRandom());
+
+                receiver.onSuccess(dataset);
+            }
+
+            @Override
+            public void onError(int errorCode, String errorMessage) {
+                receiver.onError(otErrorToAndroidError(errorCode), errorMessage);
+            }
+        };
+    }
+
+    private static ActiveOperationalDataset createRandomizedDataset(
             String networkName,
             int supportedChannelMask,
+            int preferredChannelMask,
             Instant now,
             Random random,
             SecureRandom secureRandom) {
@@ -622,6 +645,7 @@
 
         final SparseArray<byte[]> channelMask = new SparseArray<>(1);
         channelMask.put(CHANNEL_PAGE_24_GHZ, channelMaskToByteArray(supportedChannelMask));
+        final int channel = selectChannel(supportedChannelMask, preferredChannelMask, random);
 
         final byte[] securityFlags = new byte[] {(byte) 0xff, (byte) 0xf8};
 
@@ -632,7 +656,7 @@
                 .setExtendedPanId(newRandomBytes(random, LENGTH_EXTENDED_PAN_ID))
                 .setPanId(panId)
                 .setNetworkName(networkName)
-                .setChannel(CHANNEL_PAGE_24_GHZ, selectRandomChannel(supportedChannelMask, random))
+                .setChannel(CHANNEL_PAGE_24_GHZ, channel)
                 .setChannelMask(channelMask)
                 .setPskc(newRandomBytes(secureRandom, LENGTH_PSKC))
                 .setNetworkKey(newRandomBytes(secureRandom, LENGTH_NETWORK_KEY))
@@ -641,6 +665,18 @@
                 .build();
     }
 
+    private static int selectChannel(
+            int supportedChannelMask, int preferredChannelMask, Random random) {
+        // If the preferred channel mask is not empty, select a random channel from it, otherwise
+        // choose one from the supported channel mask.
+        preferredChannelMask = preferredChannelMask & supportedChannelMask;
+        if (preferredChannelMask == 0) {
+            preferredChannelMask = supportedChannelMask;
+        }
+
+        return selectRandomChannel(preferredChannelMask, random);
+    }
+
     private static byte[] newRandomBytes(Random random, int length) {
         byte[] result = new byte[length];
         random.nextBytes(result);
@@ -740,9 +776,6 @@
                 return ERROR_ABORTED;
             case OT_ERROR_BUSY:
                 return ERROR_BUSY;
-            case OT_ERROR_DETACHED:
-            case OT_ERROR_INVALID_STATE:
-                return ERROR_FAILED_PRECONDITION;
             case OT_ERROR_NO_BUFS:
                 return ERROR_RESOURCE_EXHAUSTED;
             case OT_ERROR_PARSE:
@@ -756,6 +789,9 @@
                 return ERROR_UNSUPPORTED_CHANNEL;
             case OT_ERROR_THREAD_DISABLED:
                 return ERROR_THREAD_DISABLED;
+            case OT_ERROR_FAILED_PRECONDITION:
+                return ERROR_FAILED_PRECONDITION;
+            case OT_ERROR_INVALID_STATE:
             default:
                 return ERROR_INTERNAL_ERROR;
         }
diff --git a/thread/tests/unit/AndroidTest.xml b/thread/tests/unit/AndroidTest.xml
index 26813c1..d16e423 100644
--- a/thread/tests/unit/AndroidTest.xml
+++ b/thread/tests/unit/AndroidTest.xml
@@ -19,6 +19,18 @@
     <option name="test-tag" value="ThreadNetworkUnitTests" />
     <option name="test-suite-tag" value="apct" />
 
+    <!--
+        Only run tests if the device under test is SDK version 34 (Android 14) or above.
+    -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.Sdk34ModuleController" />
+
+    <!-- Run tests in MTS only if the Tethering Mainline module is installed. -->
+    <object type="module_controller"
+            class="com.android.tradefed.testtype.suite.module.MainlineTestModuleController">
+        <option name="mainline-module-package-name" value="com.google.android.tethering" />
+    </object>
+
     <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
         <option name="test-file-name" value="ThreadNetworkUnitTests.apk" />
         <option name="check-min-sdk" value="true" />
diff --git a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
index 1640679..b557e65 100644
--- a/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
+++ b/thread/tests/unit/src/com/android/server/thread/ThreadNetworkControllerServiceTest.java
@@ -16,6 +16,7 @@
 
 package com.android.server.thread;
 
+import static android.net.thread.ActiveOperationalDataset.CHANNEL_PAGE_24_GHZ;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
@@ -23,6 +24,7 @@
 import static android.net.thread.ThreadNetworkManager.DISALLOW_THREAD_NETWORK;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 
+import static com.android.server.thread.openthread.IOtDaemon.ErrorCode.OT_ERROR_INVALID_STATE;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
 import static com.google.common.io.BaseEncoding.base16;
@@ -30,8 +32,10 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
@@ -47,9 +51,11 @@
 import android.net.NetworkAgent;
 import android.net.NetworkProvider;
 import android.net.thread.ActiveOperationalDataset;
+import android.net.thread.IActiveOperationalDatasetReceiver;
 import android.net.thread.IOperationReceiver;
 import android.net.thread.ThreadNetworkException;
 import android.os.Handler;
+import android.os.IBinder;
 import android.os.ParcelFileDescriptor;
 import android.os.RemoteException;
 import android.os.UserManager;
@@ -64,6 +70,8 @@
 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;
 
@@ -95,6 +103,12 @@
                                     + "B9D351B40C0402A0FFF8");
     private static final ActiveOperationalDataset DEFAULT_ACTIVE_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_ACTIVE_DATASET_TLVS);
+    private static final String DEFAULT_NETWORK_NAME = "thread-wpan0";
+    private static final int OT_ERROR_NONE = 0;
+    private static final int DEFAULT_SUPPORTED_CHANNEL_MASK = 0x07FFF800; // from channel 11 to 26
+    private static final int DEFAULT_PREFERRED_CHANNEL_MASK = 0x00000800; // channel 11
+    private static final int DEFAULT_SELECTED_CHANNEL = 11;
+    private static final byte[] DEFAULT_SUPPORTED_CHANNEL_MASK_ARRAY = base16().decode("001FFFE0");
 
     @Mock private ConnectivityManager mMockConnectivityManager;
     @Mock private NetworkAgent mMockNetworkAgent;
@@ -104,10 +118,12 @@
     @Mock private ThreadPersistentSettings mMockPersistentSettings;
     @Mock private NsdPublisher mMockNsdPublisher;
     @Mock private UserManager mMockUserManager;
+    @Mock private IBinder mIBinder;
     private Context mContext;
     private TestLooper mTestLooper;
     private FakeOtDaemon mFakeOtDaemon;
     private ThreadNetworkControllerService mService;
+    @Captor private ArgumentCaptor<ActiveOperationalDataset> mActiveDatasetCaptor;
 
     @Before
     public void setUp() {
@@ -281,4 +297,40 @@
             }
         };
     }
+
+    @Test
+    public void createRandomizedDataset_succeed_activeDatasetCreated() throws Exception {
+        final IActiveOperationalDatasetReceiver mockReceiver =
+                mock(IActiveOperationalDatasetReceiver.class);
+        mFakeOtDaemon.setChannelMasks(
+                DEFAULT_SUPPORTED_CHANNEL_MASK, DEFAULT_PREFERRED_CHANNEL_MASK);
+        mFakeOtDaemon.setChannelMasksReceiverOtError(OT_ERROR_NONE);
+
+        mService.createRandomizedDataset(DEFAULT_NETWORK_NAME, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, never()).onError(anyInt(), anyString());
+        verify(mockReceiver, times(1)).onSuccess(mActiveDatasetCaptor.capture());
+        ActiveOperationalDataset activeDataset = mActiveDatasetCaptor.getValue();
+        assertThat(activeDataset.getNetworkName()).isEqualTo(DEFAULT_NETWORK_NAME);
+        assertThat(activeDataset.getChannelMask().size()).isEqualTo(1);
+        assertThat(activeDataset.getChannelMask().get(CHANNEL_PAGE_24_GHZ))
+                .isEqualTo(DEFAULT_SUPPORTED_CHANNEL_MASK_ARRAY);
+        assertThat(activeDataset.getChannel()).isEqualTo(DEFAULT_SELECTED_CHANNEL);
+    }
+
+    @Test
+    public void createRandomizedDataset_otDaemonRemoteFailure_returnsPreconditionError()
+            throws Exception {
+        final IActiveOperationalDatasetReceiver mockReceiver =
+                mock(IActiveOperationalDatasetReceiver.class);
+        mFakeOtDaemon.setChannelMasksReceiverOtError(OT_ERROR_INVALID_STATE);
+        when(mockReceiver.asBinder()).thenReturn(mIBinder);
+
+        mService.createRandomizedDataset(DEFAULT_NETWORK_NAME, mockReceiver);
+        mTestLooper.dispatchAll();
+
+        verify(mockReceiver, never()).onSuccess(any(ActiveOperationalDataset.class));
+        verify(mockReceiver, times(1)).onError(eq(ERROR_INTERNAL_ERROR), anyString());
+    }
 }