diff --git a/Tethering/common/TetheringLib/Android.bp b/Tethering/common/TetheringLib/Android.bp
index 6e8d0c9ca2a325ea0475749e27768e6d2e061a78..bcea425c93cd156b82addb0b5cbca0fe8e46cc2a 100644
--- a/Tethering/common/TetheringLib/Android.bp
+++ b/Tethering/common/TetheringLib/Android.bp
@@ -43,6 +43,7 @@ java_sdk_library {
         "//packages/modules/Connectivity/staticlibs/tests:__subpackages__",
         "//packages/modules/Connectivity/Tethering/tests:__subpackages__",
         "//packages/modules/Connectivity/tests:__subpackages__",
+        "//packages/modules/Connectivity/thread/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index 0e1921a16e8f361a612c24750f72818e14495d21..c31dcf5d9cc8535e43f8b2721d73e182815c1081 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -186,6 +186,7 @@ java_sdk_library {
         "//packages/modules/Connectivity/staticlibs/tests:__subpackages__",
         "//packages/modules/Connectivity/Tethering/tests:__subpackages__",
         "//packages/modules/Connectivity/tests:__subpackages__",
+        "//packages/modules/Connectivity/thread/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
diff --git a/framework/Android.bp b/framework/Android.bp
index 7ec397122c4bc6da0eeada28fd31bd5ab5f8d619..f3d8689bed20d202d40863eec9ce0d996ee34a01 100644
--- a/framework/Android.bp
+++ b/framework/Android.bp
@@ -190,6 +190,7 @@ java_sdk_library {
         "//packages/modules/Connectivity/Cronet/tests:__subpackages__",
         "//packages/modules/Connectivity/Tethering/tests:__subpackages__",
         "//packages/modules/Connectivity/tests:__subpackages__",
+        "//packages/modules/Connectivity/thread/tests:__subpackages__",
         "//packages/modules/IPsec/tests/iketests",
         "//packages/modules/NetworkStack/tests:__subpackages__",
         "//packages/modules/Wifi/service/tests/wifitests",
diff --git a/staticlibs/device/com/android/net/module/util/Ipv6Utils.java b/staticlibs/device/com/android/net/module/util/Ipv6Utils.java
index d5382215c3387580e19af1a9af69900601a7abf1..497b8cbda876f591859abc1463caa50a3418306b 100644
--- a/staticlibs/device/com/android/net/module/util/Ipv6Utils.java
+++ b/staticlibs/device/com/android/net/module/util/Ipv6Utils.java
@@ -165,6 +165,24 @@ public class Ipv6Utils {
                 (byte) ICMPV6_ROUTER_SOLICITATION /* type */, (byte) 0 /* code */, payload);
     }
 
+    /**
+     * Build an ICMPv6 Router Solicitation packet from the required specified parameters without
+     * ethernet header.
+     */
+    public static ByteBuffer buildRsPacket(
+            final Inet6Address srcIp, final Inet6Address dstIp, final ByteBuffer... options) {
+        final RsHeader rsHeader = new RsHeader((int) 0 /* reserved */);
+        final ByteBuffer[] payload =
+                buildIcmpv6Payload(
+                        ByteBuffer.wrap(rsHeader.writeToBytes(ByteOrder.BIG_ENDIAN)), options);
+        return buildIcmpv6Packet(
+                srcIp,
+                dstIp,
+                (byte) ICMPV6_ROUTER_SOLICITATION /* type */,
+                (byte) 0 /* code */,
+                payload);
+    }
+
     /**
      * Build an ICMPv6 Echo Request packet from the required specified parameters.
      */
@@ -176,11 +194,21 @@ public class Ipv6Utils {
     }
 
     /**
-     * Build an ICMPv6 Echo Reply packet without ethernet header.
+     * Build an ICMPv6 Echo Request packet from the required specified parameters without ethernet
+     * header.
      */
-    public static ByteBuffer buildEchoReplyPacket(final Inet6Address srcIp,
+    public static ByteBuffer buildEchoRequestPacket(final Inet6Address srcIp,
             final Inet6Address dstIp) {
         final ByteBuffer payload = ByteBuffer.allocate(4); // ID and Sequence number may be zero.
+        return buildIcmpv6Packet(srcIp, dstIp, (byte) ICMPV6_ECHO_REQUEST_TYPE /* type */,
+                (byte) 0 /* code */,
+                payload);
+    }
+
+    /** Build an ICMPv6 Echo Reply packet without ethernet header. */
+    public static ByteBuffer buildEchoReplyPacket(
+            final Inet6Address srcIp, final Inet6Address dstIp) {
+        final ByteBuffer payload = ByteBuffer.allocate(4); // ID and Sequence number may be zero.
         return buildIcmpv6Packet(srcIp, dstIp, (byte) ICMPV6_ECHO_REPLY_TYPE /* type */,
                 (byte) 0 /* code */, payload);
     }
diff --git a/thread/TEST_MAPPING b/thread/TEST_MAPPING
index ec1cc08acaf47bb73a9388e5f201db5e20faca1e..d3765f160a7b23a6260ba8b0e70ec5c909ffa338 100644
--- a/thread/TEST_MAPPING
+++ b/thread/TEST_MAPPING
@@ -8,5 +8,10 @@
     {
       "name": "ThreadNetworkUnitTests"
     }
+  ],
+  "postsubmit": [
+    {
+      "name": "ThreadNetworkIntegrationTests"
+    }
   ]
 }
diff --git a/thread/tests/integration/Android.bp b/thread/tests/integration/Android.bp
new file mode 100644
index 0000000000000000000000000000000000000000..405fb76f721f0708be4756e43c09d4c195474747
--- /dev/null
+++ b/thread/tests/integration/Android.bp
@@ -0,0 +1,55 @@
+//
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_defaults {
+    name: "ThreadNetworkIntegrationTestsDefaults",
+    min_sdk_version: "30",
+    static_libs: [
+        "androidx.test.rules",
+        "guava",
+        "mockito-target-minus-junit4",
+        "net-tests-utils",
+        "net-utils-device-common",
+        "net-utils-device-common-bpf",
+        "testables",
+    ],
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+        "android.test.mock",
+    ],
+}
+
+android_test {
+    name: "ThreadNetworkIntegrationTests",
+    platform_apis: true,
+    manifest: "AndroidManifest.xml",
+    defaults: [
+        "framework-connectivity-test-defaults",
+        "ThreadNetworkIntegrationTestsDefaults"
+    ],
+    test_suites: [
+        "general-tests",
+    ],
+    srcs: [
+        "src/**/*.java",
+    ],
+    compile_multilib: "both",
+}
diff --git a/thread/tests/integration/AndroidManifest.xml b/thread/tests/integration/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a34765485715ffb047b5f33fe81f8431b513540b
--- /dev/null
+++ b/thread/tests/integration/AndroidManifest.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.android.thread.tests.integration">
+
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+    <!-- The test need CHANGE_NETWORK_STATE permission to use requestNetwork API to setup test
+         network. Since R shell application don't have such permission, grant permission to the test
+         here. TODO: Remove CHANGE_NETWORK_STATE permission here and use adopt shell permission to
+         obtain CHANGE_NETWORK_STATE for testing once R device is no longer supported. -->
+    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
+    <uses-permission android:name="android.permission.THREAD_NETWORK_PRIVILEGED"/>
+    <uses-permission android:name="android.permission.INTERNET"/>
+
+    <application android:debuggable="true">
+        <uses-library android:name="android.test.runner" />
+    </application>
+    <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+        android:targetPackage="com.android.thread.tests.integration"
+        android:label="Thread integration tests">
+    </instrumentation>
+</manifest>
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..5d3818a4b50547a703a5fd1852d21f3a80177eac
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.net.thread;
+
+import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
+import static android.net.thread.IntegrationTestUtils.isExpectedIcmpv6Packet;
+import static android.net.thread.IntegrationTestUtils.newPacketReader;
+import static android.net.thread.IntegrationTestUtils.readPacketFrom;
+import static android.net.thread.IntegrationTestUtils.waitFor;
+import static android.net.thread.IntegrationTestUtils.waitForStateAnyOf;
+import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
+import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
+
+import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
+import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
+import static com.android.testutils.TestPermissionUtil.runAsShell;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+
+import android.content.Context;
+import android.net.LinkProperties;
+import android.net.MacAddress;
+import android.os.Handler;
+import android.os.HandlerThread;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.filters.LargeTest;
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.testutils.TapPacketReader;
+import com.android.testutils.TestNetworkTracker;
+
+import com.google.common.util.concurrent.MoreExecutors;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.net.Inet6Address;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/** Integration test cases for Thread Border Routing feature. */
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class BorderRoutingTest {
+    private static final String TAG = BorderRoutingTest.class.getSimpleName();
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private final ThreadNetworkManager mThreadNetworkManager =
+            mContext.getSystemService(ThreadNetworkManager.class);
+    private ThreadNetworkController mThreadNetworkController;
+    private HandlerThread mHandlerThread;
+    private Handler mHandler;
+    private TestNetworkTracker mInfraNetworkTracker;
+
+    // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
+    private static final byte[] DEFAULT_DATASET_TLVS =
+            base16().decode(
+                            "0E080000000000010000000300001335060004001FFFE002"
+                                    + "08ACC214689BC40BDF0708FD64DB1225F47E0B0510F26B31"
+                                    + "53760F519A63BAFDDFFC80D2AF030F4F70656E5468726561"
+                                    + "642D643961300102D9A00410A245479C836D551B9CA557F7"
+                                    + "B9D351B40C0402A0FFF8");
+    private static final ActiveOperationalDataset DEFAULT_DATASET =
+            ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
+
+    @Before
+    public void setUp() throws Exception {
+        mHandlerThread = new HandlerThread(getClass().getSimpleName());
+        mHandlerThread.start();
+        mHandler = new Handler(mHandlerThread.getLooper());
+        var threadControllers = mThreadNetworkManager.getAllThreadNetworkControllers();
+        assertEquals(threadControllers.size(), 1);
+        mThreadNetworkController = threadControllers.get(0);
+        mInfraNetworkTracker =
+                runAsShell(
+                        MANAGE_TEST_NETWORKS,
+                        () ->
+                                initTestNetwork(
+                                        mContext, new LinkProperties(), 5000 /* timeoutMs */));
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    CountDownLatch latch = new CountDownLatch(1);
+                    mThreadNetworkController.setTestNetworkAsUpstream(
+                            mInfraNetworkTracker.getTestIface().getInterfaceName(),
+                            MoreExecutors.directExecutor(),
+                            v -> {
+                                latch.countDown();
+                            });
+                    latch.await();
+                });
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    CountDownLatch latch = new CountDownLatch(2);
+                    mThreadNetworkController.setTestNetworkAsUpstream(
+                            null, MoreExecutors.directExecutor(), v -> latch.countDown());
+                    mThreadNetworkController.leave(
+                            MoreExecutors.directExecutor(), v -> latch.countDown());
+                    latch.await(10, TimeUnit.SECONDS);
+                });
+        runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
+
+        mHandlerThread.quitSafely();
+        mHandlerThread.join();
+    }
+
+    @Test
+    public void infraDevicePingTheadDeviceOmr_Succeeds() throws Exception {
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        // BR forms a network.
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    mThreadNetworkController.join(
+                            DEFAULT_DATASET, MoreExecutors.directExecutor(), result -> {});
+                });
+        waitForStateAnyOf(
+                mThreadNetworkController, List.of(DEVICE_ROLE_LEADER), 30 /* timeoutSeconds */);
+
+        // Creates a Full Thread Device (FTD) and lets it join the network.
+        FullThreadDevice ftd = new FullThreadDevice(5 /* node ID */);
+        ftd.factoryReset();
+        ftd.joinNetwork(DEFAULT_DATASET);
+        ftd.waitForStateAnyOf(List.of("router", "child"), 10 /* timeoutSeconds */);
+        waitFor(() -> ftd.getOmrAddress() != null, 60 /* timeoutSeconds */);
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+        assertNotNull(ftdOmr);
+
+        // Creates a infra network device.
+        TapPacketReader infraNetworkReader =
+                newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        InfraNetworkDevice infraDevice =
+                new InfraNetworkDevice(MacAddress.fromString("1:2:3:4:5:6"), infraNetworkReader);
+        infraDevice.runSlaac(60 /* timeoutSeconds */);
+        assertNotNull(infraDevice.ipv6Addr);
+
+        // Infra device sends an echo request to FTD's OMR.
+        infraDevice.sendEchoRequest(ftdOmr);
+
+        // Infra device receives an echo reply sent by FTD.
+        assertNotNull(
+                readPacketFrom(
+                        infraNetworkReader,
+                        p -> isExpectedIcmpv6Packet(p, ICMPV6_ECHO_REPLY_TYPE)));
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/FullThreadDevice.java
new file mode 100644
index 0000000000000000000000000000000000000000..01638f357400c818d47cca4fb62b0b791ded5098
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/FullThreadDevice.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.thread;
+
+import static android.net.thread.IntegrationTestUtils.waitFor;
+
+import static com.google.common.io.BaseEncoding.base16;
+
+import static org.junit.Assert.fail;
+
+import android.net.InetAddresses;
+import android.net.IpPrefix;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.net.Inet6Address;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A class that launches and controls a simulation Full Thread Device (FTD).
+ *
+ * <p>This class launches an `ot-cli-ftd` process and communicates with it via command line input
+ * and output. See <a
+ * href="https://github.com/openthread/openthread/blob/main/src/cli/README.md">this page</a> for
+ * available commands.
+ */
+public final class FullThreadDevice {
+    private final Process mProcess;
+    private final BufferedReader mReader;
+    private final BufferedWriter mWriter;
+
+    private ActiveOperationalDataset mActiveOperationalDataset;
+
+    /**
+     * Constructs a {@link FullThreadDevice} for the given node ID.
+     *
+     * <p>It launches an `ot-cli-ftd` process using the given node ID. The node ID is an integer in
+     * range [1, OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE]. `OPENTHREAD_SIMULATION_MAX_NETWORK_SIZE`
+     * is defined in `external/openthread/examples/platforms/simulation/platform-config.h`.
+     *
+     * @param nodeId the node ID for the simulation Full Thread Device.
+     * @throws IllegalStateException the node ID is already occupied by another simulation Thread
+     *     device.
+     */
+    public FullThreadDevice(int nodeId) {
+        try {
+            mProcess = Runtime.getRuntime().exec("/system/bin/ot-cli-ftd " + nodeId);
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to start ot-cli-ftd (id=" + nodeId + ")", e);
+        }
+        mReader = new BufferedReader(new InputStreamReader(mProcess.getInputStream()));
+        mWriter = new BufferedWriter(new OutputStreamWriter(mProcess.getOutputStream()));
+        mActiveOperationalDataset = null;
+    }
+
+    /**
+     * Returns an OMR (Off-Mesh-Routable) address on this device if any.
+     *
+     * <p>This methods goes through all unicast addresses on the device and returns the first
+     * address which is neither link-local nor mesh-local.
+     */
+    public Inet6Address getOmrAddress() {
+        List<String> addresses = executeCommand("ipaddr");
+        IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix();
+        for (String address : addresses) {
+            if (address.startsWith("fe80:")) {
+                continue;
+            }
+            Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address);
+            if (!meshLocalPrefix.contains(addr)) {
+                return addr;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Joins the Thread network using the given {@link ActiveOperationalDataset}.
+     *
+     * @param dataset the Active Operational Dataset
+     */
+    public void joinNetwork(ActiveOperationalDataset dataset) {
+        mActiveOperationalDataset = dataset;
+        executeCommand("dataset set active " + base16().lowerCase().encode(dataset.toThreadTlvs()));
+        executeCommand("ifconfig up");
+        executeCommand("thread start");
+    }
+
+    /** Stops the Thread network radio. */
+    public void stopThreadRadio() {
+        executeCommand("thread stop");
+        executeCommand("ifconfig down");
+    }
+
+    /**
+     * Waits for the Thread device to enter the any state of the given {@link List<String>}.
+     *
+     * @param states the list of states to wait for. Valid states are "disabled", "detached",
+     *     "child", "router" and "leader".
+     * @param timeoutSeconds the number of seconds to wait for.
+     */
+    public void waitForStateAnyOf(List<String> states, int timeoutSeconds) throws TimeoutException {
+        waitFor(() -> states.contains(getState()), timeoutSeconds);
+    }
+
+    /**
+     * Gets the state of the Thread device.
+     *
+     * @return a string representing the state.
+     */
+    public String getState() {
+        return executeCommand("state").get(0);
+    }
+
+    /** Runs the "factoryreset" command on the device. */
+    public void factoryReset() {
+        try {
+            mWriter.write("factoryreset\n");
+            mWriter.flush();
+            // fill the input buffer to avoid truncating next command
+            for (int i = 0; i < 1000; ++i) {
+                mWriter.write("\n");
+            }
+            mWriter.flush();
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to run factoryreset on ot-cli-ftd", e);
+        }
+    }
+
+    private List<String> executeCommand(String command) {
+        try {
+            mWriter.write(command + "\n");
+            mWriter.flush();
+        } catch (IOException e) {
+            throw new IllegalStateException(
+                    "Failed to write the command " + command + " to ot-cli-ftd", e);
+        }
+        try {
+            return readUntilDone();
+        } catch (IOException e) {
+            throw new IllegalStateException(
+                    "Failed to read the ot-cli-ftd output of command: " + command, e);
+        }
+    }
+
+    private List<String> readUntilDone() throws IOException {
+        ArrayList<String> result = new ArrayList<>();
+        String line;
+        while ((line = mReader.readLine()) != null) {
+            if (line.equals("Done")) {
+                break;
+            }
+            if (line.startsWith("Error:")) {
+                fail("ot-cli-ftd reported an error: " + line);
+            }
+            if (!line.startsWith("> ")) {
+                result.add(line);
+            }
+        }
+        return result;
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java b/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java
new file mode 100644
index 0000000000000000000000000000000000000000..43a800dafa712a914e04918df147c716db9d8d63
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/InfraNetworkDevice.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread;
+
+import static android.net.thread.IntegrationTestUtils.getRaPios;
+import static android.net.thread.IntegrationTestUtils.readPacketFrom;
+import static android.net.thread.IntegrationTestUtils.waitFor;
+
+import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_SLLA;
+import static com.android.net.module.util.NetworkStackConstants.IPV6_ADDR_ALL_ROUTERS_MULTICAST;
+
+import android.net.InetAddresses;
+import android.net.MacAddress;
+
+import com.android.net.module.util.Ipv6Utils;
+import com.android.net.module.util.structs.LlaOption;
+import com.android.net.module.util.structs.PrefixInformationOption;
+import com.android.testutils.TapPacketReader;
+
+import java.io.IOException;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * A class that simulates a device on the infrastructure network.
+ *
+ * <p>This class directly interacts with the TUN interface of the test network to pretend there's a
+ * device on the infrastructure network.
+ */
+public final class InfraNetworkDevice {
+    // The MAC address of this device.
+    public final MacAddress macAddr;
+    // The packet reader of the TUN interface of the test network.
+    public final TapPacketReader packetReader;
+    // The IPv6 address generated by SLAAC for the device.
+    public Inet6Address ipv6Addr;
+
+    /**
+     * Constructs an InfraNetworkDevice with the given {@link MAC address} and {@link
+     * TapPacketReader}.
+     *
+     * @param macAddr the MAC address of the device
+     * @param packetReader the packet reader of the TUN interface of the test network.
+     */
+    public InfraNetworkDevice(MacAddress macAddr, TapPacketReader packetReader) {
+        this.macAddr = macAddr;
+        this.packetReader = packetReader;
+    }
+
+    /**
+     * Sends an ICMPv6 echo request message to the given {@link Inet6Address}.
+     *
+     * @param dstAddr the destination address of the packet.
+     * @throws IOException when it fails to send the packet.
+     */
+    public void sendEchoRequest(Inet6Address dstAddr) throws IOException {
+        ByteBuffer icmp6Packet = Ipv6Utils.buildEchoRequestPacket(ipv6Addr, dstAddr);
+        packetReader.sendResponse(icmp6Packet);
+    }
+
+    /**
+     * Sends an ICMPv6 Router Solicitation (RS) message to all routers on the network.
+     *
+     * @throws IOException when it fails to send the packet.
+     */
+    public void sendRsPacket() throws IOException {
+        ByteBuffer slla = LlaOption.build((byte) ICMPV6_ND_OPTION_SLLA, macAddr);
+        ByteBuffer rs =
+                Ipv6Utils.buildRsPacket(
+                        (Inet6Address) InetAddresses.parseNumericAddress("fe80::1"),
+                        IPV6_ADDR_ALL_ROUTERS_MULTICAST,
+                        slla);
+        packetReader.sendResponse(rs);
+    }
+
+    /**
+     * Runs SLAAC to generate an IPv6 address for the device.
+     *
+     * <p>The devices sends an RS message, processes the received RA messages and generates an IPv6
+     * address if there's any available Prefix Information Option (PIO). For now it only generates
+     * one address in total and doesn't track the expiration.
+     *
+     * @param timeoutSeconds the number of seconds to wait for.
+     * @throws TimeoutException when the device fails to generate a SLAAC address in given timeout.
+     */
+    public void runSlaac(int timeoutSeconds) throws TimeoutException {
+        waitFor(() -> (ipv6Addr = runSlaac()) != null, timeoutSeconds, 5 /* intervalSeconds */);
+    }
+
+    private Inet6Address runSlaac() {
+        try {
+            sendRsPacket();
+
+            final byte[] raPacket = readPacketFrom(packetReader, p -> !getRaPios(p).isEmpty());
+
+            final List<PrefixInformationOption> options = getRaPios(raPacket);
+
+            for (PrefixInformationOption pio : options) {
+                if (pio.validLifetime > 0 && pio.preferredLifetime > 0) {
+                    final byte[] addressBytes = pio.prefix;
+                    addressBytes[addressBytes.length - 1] = (byte) (new Random()).nextInt();
+                    addressBytes[addressBytes.length - 2] = (byte) (new Random()).nextInt();
+                    return (Inet6Address) InetAddress.getByAddress(addressBytes);
+                }
+            }
+        } catch (IOException e) {
+            throw new IllegalStateException("Failed to generate an address by SLAAC", e);
+        }
+        return null;
+    }
+}
diff --git a/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..9d9a4ff7494b64204bff412d995baeeaa343dceb
--- /dev/null
+++ b/thread/tests/integration/src/android/net/thread/IntegrationTestUtils.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package android.net.thread;
+
+import static android.system.OsConstants.IPPROTO_ICMPV6;
+
+import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_PIO;
+import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+
+import android.net.TestNetworkInterface;
+import android.os.Handler;
+import android.os.SystemClock;
+
+import com.android.net.module.util.Struct;
+import com.android.net.module.util.structs.Icmpv6Header;
+import com.android.net.module.util.structs.Ipv6Header;
+import com.android.net.module.util.structs.PrefixInformationOption;
+import com.android.net.module.util.structs.RaHeader;
+import com.android.testutils.HandlerUtils;
+import com.android.testutils.TapPacketReader;
+
+import com.google.common.util.concurrent.SettableFuture;
+
+import java.io.FileDescriptor;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+/** Static utility methods relating to Thread integration tests. */
+public final class IntegrationTestUtils {
+    private IntegrationTestUtils() {}
+
+    /**
+     * Waits for the given {@link Supplier} to be true until given timeout.
+     *
+     * <p>It checks the condition once every second.
+     *
+     * @param condition the condition to check.
+     * @param timeoutSeconds the number of seconds to wait for.
+     * @throws TimeoutException if the condition is not met after the timeout.
+     */
+    public static void waitFor(Supplier<Boolean> condition, int timeoutSeconds)
+            throws TimeoutException {
+        waitFor(condition, timeoutSeconds, 1);
+    }
+
+    /**
+     * Waits for the given {@link Supplier} to be true until given timeout.
+     *
+     * <p>It checks the condition once every {@code intervalSeconds}.
+     *
+     * @param condition the condition to check.
+     * @param timeoutSeconds the number of seconds to wait for.
+     * @param intervalSeconds the period to check the {@code condition}.
+     * @throws TimeoutException if the condition is still not met when the timeout expires.
+     */
+    public static void waitFor(Supplier<Boolean> condition, int timeoutSeconds, int intervalSeconds)
+            throws TimeoutException {
+        for (int i = 0; i < timeoutSeconds; i += intervalSeconds) {
+            if (condition.get()) {
+                return;
+            }
+            SystemClock.sleep(intervalSeconds * 1000L);
+        }
+        if (condition.get()) {
+            return;
+        }
+        throw new TimeoutException(
+                String.format(
+                        "The condition failed to become true in %d seconds.", timeoutSeconds));
+    }
+
+    /**
+     * Creates a {@link TapPacketReader} given the {@link TestNetworkInterface} and {@link Handler}.
+     *
+     * @param testNetworkInterface the TUN interface of the test network.
+     * @param handler the handler to process the packets.
+     * @return the {@link TapPacketReader}.
+     */
+    public static TapPacketReader newPacketReader(
+            TestNetworkInterface testNetworkInterface, Handler handler) {
+        FileDescriptor fd = testNetworkInterface.getFileDescriptor().getFileDescriptor();
+        final TapPacketReader reader =
+                new TapPacketReader(handler, fd, testNetworkInterface.getMtu());
+        handler.post(() -> reader.start());
+        HandlerUtils.waitForIdle(handler, 5000 /* timeout in milliseconds */);
+        return reader;
+    }
+
+    /**
+     * Waits for the Thread module to enter any state of the given {@code deviceRoles}.
+     *
+     * @param controller the {@link ThreadNetworkController}.
+     * @param deviceRoles the desired device roles. See also {@link
+     *     ThreadNetworkController.DeviceRole}.
+     * @param timeoutSeconds the number of seconds ot wait for.
+     * @return the {@link ThreadNetworkController.DeviceRole} after waiting.
+     * @throws TimeoutException if the device hasn't become any of expected roles until the timeout
+     *     expires.
+     */
+    public static int waitForStateAnyOf(
+            ThreadNetworkController controller, List<Integer> deviceRoles, int timeoutSeconds)
+            throws TimeoutException {
+        SettableFuture<Integer> future = SettableFuture.create();
+        ThreadNetworkController.StateCallback callback =
+                newRole -> {
+                    if (deviceRoles.contains(newRole)) {
+                        future.set(newRole);
+                    }
+                };
+        controller.registerStateCallback(directExecutor(), callback);
+        try {
+            int role = future.get(timeoutSeconds, TimeUnit.SECONDS);
+            controller.unregisterStateCallback(callback);
+            return role;
+        } catch (InterruptedException | ExecutionException e) {
+            throw new TimeoutException(
+                    String.format(
+                            "The device didn't become an expected role in %d seconds.",
+                            timeoutSeconds));
+        }
+    }
+
+    /**
+     * Reads a packet from a given {@link TapPacketReader} that satisfies the {@code filter}.
+     *
+     * @param packetReader a TUN packet reader.
+     * @param filter the filter to be applied on the packet.
+     * @return the first IPv6 packet that satisfies the {@code filter}. If it has waited for more
+     *     than 3000ms to read the next packet, the method will return null.
+     */
+    public static byte[] readPacketFrom(TapPacketReader packetReader, Predicate<byte[]> filter) {
+        byte[] packet;
+        while ((packet = packetReader.poll(3000 /* timeoutMs */)) != null) {
+            if (filter.test(packet)) return packet;
+        }
+        return null;
+    }
+
+    /** Returns {@code true} if {@code packet} is an ICMPv6 packet of given {@code type}. */
+    public static boolean isExpectedIcmpv6Packet(byte[] packet, int type) {
+        if (packet == null) {
+            return false;
+        }
+        ByteBuffer buf = ByteBuffer.wrap(packet);
+        try {
+            if (Struct.parse(Ipv6Header.class, buf).nextHeader != (byte) IPPROTO_ICMPV6) {
+                return false;
+            }
+            return Struct.parse(Icmpv6Header.class, buf).type == (short) type;
+        } catch (IllegalArgumentException ignored) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return false;
+    }
+
+    /** Returns the Prefix Information Options (PIO) extracted from an ICMPv6 RA message. */
+    public static List<PrefixInformationOption> getRaPios(byte[] raMsg) {
+        final ArrayList<PrefixInformationOption> pioList = new ArrayList<>();
+
+        if (raMsg == null) {
+            return pioList;
+        }
+
+        final ByteBuffer buf = ByteBuffer.wrap(raMsg);
+        final Ipv6Header ipv6Header = Struct.parse(Ipv6Header.class, buf);
+        if (ipv6Header.nextHeader != (byte) IPPROTO_ICMPV6) {
+            return pioList;
+        }
+
+        final Icmpv6Header icmpv6Header = Struct.parse(Icmpv6Header.class, buf);
+        if (icmpv6Header.type != (short) ICMPV6_ROUTER_ADVERTISEMENT) {
+            return pioList;
+        }
+
+        Struct.parse(RaHeader.class, buf);
+        while (buf.position() < raMsg.length) {
+            final int currentPos = buf.position();
+            final int type = Byte.toUnsignedInt(buf.get());
+            final int length = Byte.toUnsignedInt(buf.get());
+            if (type == ICMPV6_ND_OPTION_PIO) {
+                final ByteBuffer pioBuf =
+                        ByteBuffer.wrap(
+                                buf.array(),
+                                currentPos,
+                                Struct.getSize(PrefixInformationOption.class));
+                final PrefixInformationOption pio =
+                        Struct.parse(PrefixInformationOption.class, pioBuf);
+                pioList.add(pio);
+
+                // Move ByteBuffer position to the next option.
+                buf.position(currentPos + Struct.getSize(PrefixInformationOption.class));
+            } else {
+                // The length is in units of 8 octets.
+                buf.position(currentPos + (length * 8));
+            }
+        }
+        return pioList;
+    }
+}