[Test] Thread multicast routing E2E test

Added E2E test for Thread multicast routing.

Bug: 320616891

Test: atest ThreadNetworkIntegrationTests:android.net.thread.BorderRoutingTest

Change-Id: Icbaa9f6645080bd1a0a95ddc7902b9a468dd0088
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 2fccf6b..1f27f06 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -18,19 +18,22 @@
 
 import static android.Manifest.permission.MANAGE_TEST_NETWORKS;
 import static android.Manifest.permission.NETWORK_SETTINGS;
-import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkManager.PERMISSION_THREAD_NETWORK_PRIVILEGED;
 import static android.net.thread.utils.IntegrationTestUtils.JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.RESTART_JOIN_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.isExpectedIcmpv6Packet;
+import static android.net.thread.utils.IntegrationTestUtils.isFromIpv6Source;
+import static android.net.thread.utils.IntegrationTestUtils.isInMulticastGroup;
 import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported;
+import static android.net.thread.utils.IntegrationTestUtils.isToIpv6Destination;
 import static android.net.thread.utils.IntegrationTestUtils.newPacketReader;
-import static android.net.thread.utils.IntegrationTestUtils.readPacketFrom;
+import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
 import static android.net.thread.utils.IntegrationTestUtils.sendUdpMessage;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
-import static android.net.thread.utils.IntegrationTestUtils.waitForStateAnyOf;
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REPLY_TYPE;
+import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ECHO_REQUEST_TYPE;
+import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
 import static com.android.testutils.TestNetworkTrackerKt.initTestNetwork;
 import static com.android.testutils.TestPermissionUtil.runAsShell;
 
@@ -39,12 +42,14 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assume.assumeNotNull;
 import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import android.content.Context;
+import android.net.InetAddresses;
 import android.net.LinkProperties;
 import android.net.MacAddress;
 import android.net.thread.utils.FullThreadDevice;
@@ -66,10 +71,12 @@
 
 import java.net.Inet6Address;
 import java.time.Duration;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
 
 /** Integration test cases for Thread Border Routing feature. */
 @RunWith(AndroidJUnit4.class)
@@ -81,6 +88,18 @@
     private HandlerThread mHandlerThread;
     private Handler mHandler;
     private TestNetworkTracker mInfraNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+    private TapPacketReader mInfraNetworkReader;
+    private InfraNetworkDevice mInfraDevice;
+
+    private static final int NUM_FTD = 2;
+    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
+    private static final Inet6Address GROUP_ADDR_SCOPE_5 =
+            (Inet6Address) InetAddresses.parseNumericAddress("ff05::1234");
+    private static final Inet6Address GROUP_ADDR_SCOPE_4 =
+            (Inet6Address) InetAddresses.parseNumericAddress("ff04::1234");
+    private static final Inet6Address GROUP_ADDR_SCOPE_3 =
+            (Inet6Address) InetAddresses.parseNumericAddress("ff03::1234");
 
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
@@ -95,6 +114,7 @@
 
     @Before
     public void setUp() throws Exception {
+        assumeTrue(isSimulatedThreadRadioSupported());
         final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
         if (manager != null) {
             mController = manager.getAllThreadNetworkControllers().get(0);
@@ -106,24 +126,21 @@
         mHandlerThread = new HandlerThread(getClass().getSimpleName());
         mHandlerThread.start();
         mHandler = new Handler(mHandlerThread.getLooper());
+        mFtds = new ArrayList<>();
 
-        mInfraNetworkTracker =
-                runAsShell(
-                        MANAGE_TEST_NETWORKS,
-                        () ->
-                                initTestNetwork(
-                                        mContext, new LinkProperties(), 5000 /* timeoutMs */));
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                NETWORK_SETTINGS,
-                () -> {
-                    CountDownLatch latch = new CountDownLatch(1);
-                    mController.setTestNetworkAsUpstream(
-                            mInfraNetworkTracker.getTestIface().getInterfaceName(),
-                            directExecutor(),
-                            v -> latch.countDown());
-                    latch.await();
-                });
+        setUpInfraNetwork();
+
+        // BR forms a network.
+        startBrLeader();
+
+        // Creates a infra network device.
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        startInfraDevice();
+
+        // Create Ftds
+        for (int i = 0; i < NUM_FTD; ++i) {
+            mFtds.add(new FullThreadDevice(15 + i /* node ID */));
+        }
     }
 
     @After
@@ -142,16 +159,19 @@
                     mController.leave(directExecutor(), v -> latch.countDown());
                     latch.await(10, TimeUnit.SECONDS);
                 });
-        runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
+        tearDownInfraNetwork();
 
         mHandlerThread.quitSafely();
         mHandlerThread.join();
+
+        for (var ftd : mFtds) {
+            ftd.destroy();
+        }
+        mFtds.clear();
     }
 
     @Test
     public void unicastRouting_infraDevicePingTheadDeviceOmr_replyReceived() throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-
         /*
          * <pre>
          * Topology:
@@ -161,37 +181,15 @@
          * </pre>
          */
 
-        // BR forms a network.
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mController.join(DEFAULT_DATASET, directExecutor(), result -> {}));
-        waitForStateAnyOf(mController, List.of(DEVICE_ROLE_LEADER), JOIN_TIMEOUT);
-
-        // 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"), JOIN_TIMEOUT);
-        waitFor(() -> ftd.getOmrAddress() != null, Duration.ofSeconds(60));
-        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(Duration.ofSeconds(60));
-        assertNotNull(infraDevice.ipv6Addr);
+        // Let ftd join the network.
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
 
         // Infra device sends an echo request to FTD's OMR.
-        infraDevice.sendEchoRequest(ftdOmr);
+        mInfraDevice.sendEchoRequest(ftd.getOmrAddress());
 
         // Infra device receives an echo reply sent by FTD.
-        assertNotNull(
-                readPacketFrom(
-                        infraNetworkReader,
-                        p -> isExpectedIcmpv6Packet(p, ICMPV6_ECHO_REPLY_TYPE)));
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, null /* srcAddress */));
     }
 
     @Test
@@ -216,12 +214,9 @@
         joinFuture.get(RESTART_JOIN_TIMEOUT.toMillis(), MILLISECONDS);
 
         // Creates a Full Thread Device (FTD) and lets it join the network.
-        FullThreadDevice ftd = new FullThreadDevice(6 /* node ID */);
-        ftd.joinNetwork(DEFAULT_DATASET);
-        ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
-        waitFor(() -> ftd.getOmrAddress() != null, Duration.ofSeconds(60));
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
         Inet6Address ftdOmr = ftd.getOmrAddress();
-        assertNotNull(ftdOmr);
         Inet6Address ftdMlEid = ftd.getMlEid();
         assertNotNull(ftdMlEid);
 
@@ -233,4 +228,373 @@
         sendUdpMessage(ftdMlEid, 12345, "bbbbbbbb");
         assertEquals("bbbbbbbb", ftd.udpReceive());
     }
+
+    @Test
+    public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        ftd.subscribeMulticastAddress(GROUP_ADDR_SCOPE_5);
+
+        assertInfraLinkMemberOfGroup(GROUP_ADDR_SCOPE_5);
+    }
+
+    @Test
+    public void
+            multicastRouting_ftdSubscribedScope3MulticastAddress_infraLinkNotJoinMulticastGroup()
+                    throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        ftd.subscribeMulticastAddress(GROUP_ADDR_SCOPE_3);
+
+        assertInfraLinkNotMemberOfGroup(GROUP_ADDR_SCOPE_3);
+    }
+
+    @Test
+    public void multicastRouting_ftdSubscribedMulticastAddress_canPingfromInfraLink()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        subscribeMulticastAddressAndWait(ftd, GROUP_ADDR_SCOPE_5);
+
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
+    public void multicastRouting_ftdSubscribedScope3MulticastAddress_cannotPingfromInfraLink()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        ftd.subscribeMulticastAddress(GROUP_ADDR_SCOPE_3);
+
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_3);
+
+        assertNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
+    public void multicastRouting_ftdNotSubscribedMulticastAddress_cannotPingFromInfraDevice()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_4);
+
+        assertNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd.getOmrAddress()));
+    }
+
+    @Test
+    public void multicastRouting_multipleFtdsSubscribedDifferentAddresses_canPingFromInfraDevice()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device 1
+         *                                   (Cuttlefish)
+         *                                         |
+         *                                         | Thread
+         *                                         |
+         *                                  Full Thread device 2
+         * </pre>
+         */
+
+        FullThreadDevice ftd1 = mFtds.get(0);
+        startFtdChild(ftd1);
+        subscribeMulticastAddressAndWait(ftd1, GROUP_ADDR_SCOPE_5);
+
+        FullThreadDevice ftd2 = mFtds.get(1);
+        startFtdChild(ftd2);
+        subscribeMulticastAddressAndWait(ftd2, GROUP_ADDR_SCOPE_4);
+
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_4);
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd1.getOmrAddress()));
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd2.getOmrAddress()));
+    }
+
+    @Test
+    public void multicastRouting_multipleFtdsSubscribedSameAddress_canPingFromInfraDevice()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device 1
+         *                                   (Cuttlefish)
+         *                                         |
+         *                                         | Thread
+         *                                         |
+         *                                  Full Thread device 2
+         * </pre>
+         */
+
+        FullThreadDevice ftd1 = mFtds.get(0);
+        startFtdChild(ftd1);
+        subscribeMulticastAddressAndWait(ftd1, GROUP_ADDR_SCOPE_5);
+
+        FullThreadDevice ftd2 = mFtds.get(1);
+        startFtdChild(ftd2);
+        subscribeMulticastAddressAndWait(ftd2, GROUP_ADDR_SCOPE_5);
+
+        // Send the request twice as the order of replies from ftd1 and ftd2 are not guaranteed
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd1.getOmrAddress()));
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftd2.getOmrAddress()));
+    }
+
+    @Test
+    public void multicastRouting_outboundForwarding_scopeLargerThan3IsForwarded() throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+
+        ftd.ping(GROUP_ADDR_SCOPE_5);
+        ftd.ping(GROUP_ADDR_SCOPE_4);
+
+        assertNotNull(
+                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_5));
+        assertNotNull(
+                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_4));
+    }
+
+    @Test
+    public void multicastRouting_outboundForwarding_scopeSmallerThan4IsNotForwarded()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+
+        ftd.ping(GROUP_ADDR_SCOPE_3);
+
+        assertNull(
+                pollForPacketOnInfraNetwork(
+                        ICMPV6_ECHO_REQUEST_TYPE, ftd.getOmrAddress(), GROUP_ADDR_SCOPE_3));
+    }
+
+    @Test
+    public void multicastRouting_infraNetworkSwitch_ftdRepliesToSubscribedAddress()
+            throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        subscribeMulticastAddressAndWait(ftd, GROUP_ADDR_SCOPE_5);
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+
+        // Destroy infra link and re-create
+        tearDownInfraNetwork();
+        setUpInfraNetwork();
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        startInfraDevice();
+
+        mInfraDevice.sendEchoRequest(GROUP_ADDR_SCOPE_5);
+
+        assertNotNull(pollForPacketOnInfraNetwork(ICMPV6_ECHO_REPLY_TYPE, ftdOmr));
+    }
+
+    @Test
+    public void multicastRouting_infraNetworkSwitch_outboundPacketIsForwarded() throws Exception {
+        assumeTrue(isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED));
+        /*
+         * <pre>
+         * Topology:
+         *                 infra network                       Thread
+         * infra device -------------------- Border Router -------------- Full Thread device
+         *                                   (Cuttlefish)
+         * </pre>
+         */
+
+        FullThreadDevice ftd = mFtds.get(0);
+        startFtdChild(ftd);
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+
+        // Destroy infra link and re-create
+        tearDownInfraNetwork();
+        setUpInfraNetwork();
+        mInfraNetworkReader = newPacketReader(mInfraNetworkTracker.getTestIface(), mHandler);
+        startInfraDevice();
+
+        ftd.ping(GROUP_ADDR_SCOPE_5);
+        ftd.ping(GROUP_ADDR_SCOPE_4);
+
+        assertNotNull(
+                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_5));
+        assertNotNull(
+                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdOmr, GROUP_ADDR_SCOPE_4));
+    }
+
+    private void setUpInfraNetwork() {
+        mInfraNetworkTracker =
+                runAsShell(
+                        MANAGE_TEST_NETWORKS,
+                        () ->
+                                initTestNetwork(
+                                        mContext, new LinkProperties(), 5000 /* timeoutMs */));
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                NETWORK_SETTINGS,
+                () -> {
+                    CompletableFuture<Void> future = new CompletableFuture<>();
+                    mController.setTestNetworkAsUpstream(
+                            mInfraNetworkTracker.getTestIface().getInterfaceName(),
+                            directExecutor(),
+                            future::complete);
+                    future.get(5, TimeUnit.SECONDS);
+                });
+    }
+
+    private void tearDownInfraNetwork() {
+        runAsShell(MANAGE_TEST_NETWORKS, () -> mInfraNetworkTracker.teardown());
+    }
+
+    private void startBrLeader() throws Exception {
+        CompletableFuture<Void> joinFuture = new CompletableFuture<>();
+        runAsShell(
+                PERMISSION_THREAD_NETWORK_PRIVILEGED,
+                () -> mController.join(DEFAULT_DATASET, directExecutor(), joinFuture::complete));
+        joinFuture.get(RESTART_JOIN_TIMEOUT.toSeconds(), TimeUnit.SECONDS);
+    }
+
+    private void startFtdChild(FullThreadDevice ftd) throws Exception {
+        ftd.factoryReset();
+        ftd.joinNetwork(DEFAULT_DATASET);
+        ftd.waitForStateAnyOf(List.of("router", "child"), JOIN_TIMEOUT);
+        waitFor(() -> ftd.getOmrAddress() != null, Duration.ofSeconds(60));
+        Inet6Address ftdOmr = ftd.getOmrAddress();
+        assertNotNull(ftdOmr);
+    }
+
+    private void startInfraDevice() throws Exception {
+        mInfraDevice =
+                new InfraNetworkDevice(MacAddress.fromString("1:2:3:4:5:6"), mInfraNetworkReader);
+        mInfraDevice.runSlaac(Duration.ofSeconds(60));
+        assertNotNull(mInfraDevice.ipv6Addr);
+    }
+
+    private void assertInfraLinkMemberOfGroup(Inet6Address address) throws Exception {
+        waitFor(
+                () ->
+                        isInMulticastGroup(
+                                mInfraNetworkTracker.getTestIface().getInterfaceName(), address),
+                Duration.ofSeconds(3));
+    }
+
+    private void assertInfraLinkNotMemberOfGroup(Inet6Address address) throws Exception {
+        waitFor(
+                () ->
+                        !isInMulticastGroup(
+                                mInfraNetworkTracker.getTestIface().getInterfaceName(), address),
+                Duration.ofSeconds(3));
+    }
+
+    private void subscribeMulticastAddressAndWait(FullThreadDevice ftd, Inet6Address address)
+            throws Exception {
+        ftd.subscribeMulticastAddress(address);
+
+        assertInfraLinkMemberOfGroup(address);
+    }
+
+    private byte[] pollForPacketOnInfraNetwork(int type, Inet6Address srcAddress) {
+        return pollForPacketOnInfraNetwork(type, srcAddress, null);
+    }
+
+    private byte[] pollForPacketOnInfraNetwork(
+            int type, Inet6Address srcAddress, Inet6Address destAddress) {
+        Predicate<byte[]> filter;
+        filter =
+                p ->
+                        (isExpectedIcmpv6Packet(p, type)
+                                && (srcAddress == null ? true : isFromIpv6Source(p, srcAddress))
+                                && (destAddress == null
+                                        ? true
+                                        : isToIpv6Destination(p, destAddress)));
+        return pollForPacket(mInfraNetworkReader, filter);
+    }
 }
diff --git a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
index 5ca40e3..bb0034a 100644
--- a/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/FullThreadDevice.java
@@ -75,6 +75,10 @@
         mActiveOperationalDataset = null;
     }
 
+    public void destroy() {
+        mProcess.destroy();
+    }
+
     /**
      * Returns an OMR (Off-Mesh-Routable) address on this device if any.
      *
@@ -182,6 +186,27 @@
         }
     }
 
+    public void subscribeMulticastAddress(Inet6Address address) {
+        executeCommand("ipmaddr add " + address.getHostAddress());
+    }
+
+    public void ping(Inet6Address address, Inet6Address source, int size, int count) {
+        String cmd =
+                "ping"
+                        + ((source == null) ? "" : (" -I " + source.getHostAddress()))
+                        + " "
+                        + address.getHostAddress()
+                        + " "
+                        + size
+                        + " "
+                        + count;
+        executeCommand(cmd);
+    }
+
+    public void ping(Inet6Address address) {
+        ping(address, null, 100 /* size */, 1 /* count */);
+    }
+
     private List<String> executeCommand(String command) {
         try {
             mWriter.write(command + "\n");
diff --git a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
index 3081f9f..72a278c 100644
--- a/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
+++ b/thread/tests/integration/src/android/net/thread/utils/InfraNetworkDevice.java
@@ -16,7 +16,7 @@
 package android.net.thread.utils;
 
 import static android.net.thread.utils.IntegrationTestUtils.getRaPios;
-import static android.net.thread.utils.IntegrationTestUtils.readPacketFrom;
+import static android.net.thread.utils.IntegrationTestUtils.pollForPacket;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
 
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_SLLA;
@@ -109,7 +109,7 @@
         try {
             sendRsPacket();
 
-            final byte[] raPacket = readPacketFrom(packetReader, p -> !getRaPios(p).isEmpty());
+            final byte[] raPacket = pollForPacket(packetReader, p -> !getRaPios(p).isEmpty());
 
             final List<PrefixInformationOption> options = getRaPios(raPacket);
 
diff --git a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
index 4eef0e5..74251a6 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
@@ -17,6 +17,7 @@
 
 import static android.system.OsConstants.IPPROTO_ICMPV6;
 
+import static com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ND_OPTION_PIO;
 import static com.android.net.module.util.NetworkStackConstants.ICMPV6_ROUTER_ADVERTISEMENT;
 
@@ -42,6 +43,7 @@
 import java.io.IOException;
 import java.net.DatagramPacket;
 import java.net.DatagramSocket;
+import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.SocketAddress;
@@ -149,17 +151,17 @@
     }
 
     /**
-     * Reads a packet from a given {@link TapPacketReader} that satisfies the {@code filter}.
+     * Polls for 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) {
+    public static byte[] pollForPacket(TapPacketReader packetReader, Predicate<byte[]> filter) {
         byte[] packet;
-        while ((packet = packetReader.poll(3000 /* timeoutMs */)) != null) {
-            if (filter.test(packet)) return packet;
+        while ((packet = packetReader.poll(3000 /* timeoutMs */, filter)) != null) {
+            return packet;
         }
         return null;
     }
@@ -182,6 +184,34 @@
         return false;
     }
 
+    public static boolean isFromIpv6Source(byte[] packet, Inet6Address src) {
+        if (packet == null) {
+            return false;
+        }
+        ByteBuffer buf = ByteBuffer.wrap(packet);
+        try {
+            return Struct.parse(Ipv6Header.class, buf).srcIp.equals(src);
+        } catch (IllegalArgumentException ignored) {
+            // It's fine that the passed in packet is malformed because it's could be sent
+            // by anybody.
+        }
+        return false;
+    }
+
+    public static boolean isToIpv6Destination(byte[] packet, Inet6Address dest) {
+        if (packet == null) {
+            return false;
+        }
+        ByteBuffer buf = ByteBuffer.wrap(packet);
+        try {
+            return Struct.parse(Ipv6Header.class, buf).dstIp.equals(dest);
+        } 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<>();
@@ -247,4 +277,16 @@
             socket.send(packet);
         }
     }
+
+    public static boolean isInMulticastGroup(String interfaceName, Inet6Address address) {
+        final String cmd = "ip -6 maddr show dev " + interfaceName;
+        final String output = runShellCommandOrThrow(cmd);
+        final String addressStr = address.getHostAddress();
+        for (final String line : output.split("\\n")) {
+            if (line.contains(addressStr)) {
+                return true;
+            }
+        }
+        return false;
+    }
 }