Merge "[Thread] add CtsThreadNetworkTestCases to mcts-tethering" into main
diff --git a/common/FlaggedApi.bp b/common/FlaggedApi.bp
index 449d7ae..56625c5 100644
--- a/common/FlaggedApi.bp
+++ b/common/FlaggedApi.bp
@@ -23,6 +23,14 @@
 }
 
 aconfig_declarations {
+    name: "com.android.net.thread.flags-aconfig",
+    package: "com.android.net.thread.flags",
+    container: "system",
+    srcs: ["thread_flags.aconfig"],
+    visibility: ["//packages/modules/Connectivity:__subpackages__"],
+}
+
+aconfig_declarations {
     name: "nearby_flags",
     package: "com.android.nearby.flags",
     container: "system",
diff --git a/common/OWNERS b/common/OWNERS
new file mode 100644
index 0000000..e7f5d11
--- /dev/null
+++ b/common/OWNERS
@@ -0,0 +1 @@
+per-file thread_flags.aconfig = file:platform/packages/modules/Connectivity:main:/thread/OWNERS
diff --git a/common/flags.aconfig b/common/flags.aconfig
index 8c448e6..19b522c 100644
--- a/common/flags.aconfig
+++ b/common/flags.aconfig
@@ -26,13 +26,6 @@
 }
 
 flag {
-  name: "register_nsd_offload_engine"
-  namespace: "android_core_networking"
-  description: "The flag controls the access for registerOffloadEngine API in NsdManager"
-  bug: "294777050"
-}
-
-flag {
   name: "ipsec_transform_state"
   namespace: "android_core_networking_ipsec"
   description: "The flag controls the access for getIpSecTransformState and IpSecTransformState"
diff --git a/thread/flags/thread_base.aconfig b/common/thread_flags.aconfig
similarity index 100%
rename from thread/flags/thread_base.aconfig
rename to common/thread_flags.aconfig
diff --git a/framework-t/Android.bp b/framework-t/Android.bp
index e40b55c..468cee4 100644
--- a/framework-t/Android.bp
+++ b/framework-t/Android.bp
@@ -197,6 +197,7 @@
     ],
     aconfig_declarations: [
         "com.android.net.flags-aconfig",
+        "com.android.net.thread.flags-aconfig",
         "nearby_flags",
     ],
 }
diff --git a/framework/src/android/net/NetworkCapabilities.java b/framework/src/android/net/NetworkCapabilities.java
index f6ef75e..84a0d29 100644
--- a/framework/src/android/net/NetworkCapabilities.java
+++ b/framework/src/android/net/NetworkCapabilities.java
@@ -1770,9 +1770,13 @@
     public @NonNull NetworkCapabilities setNetworkSpecifier(
             @NonNull NetworkSpecifier networkSpecifier) {
         if (networkSpecifier != null
-                // Transport can be test, or test + a single other transport
+                // Transport can be test, or test + a single other transport or cellular + satellite
+                // transport. Note: cellular + satellite combination is allowed since both transport
+                // use the same specifier, TelephonyNetworkSpecifier.
                 && mTransportTypes != (1L << TRANSPORT_TEST)
-                && Long.bitCount(mTransportTypes & ~(1L << TRANSPORT_TEST)) != 1) {
+                && Long.bitCount(mTransportTypes & ~(1L << TRANSPORT_TEST)) != 1
+                && (mTransportTypes & ~(1L << TRANSPORT_TEST))
+                != (1 << TRANSPORT_CELLULAR | 1 << TRANSPORT_SATELLITE)) {
             throw new IllegalStateException("Must have a single non-test transport specified to "
                     + "use setNetworkSpecifier");
         }
diff --git a/service/src/com/android/server/connectivity/SatelliteAccessController.java b/service/src/com/android/server/connectivity/SatelliteAccessController.java
index 0968aff..b53abce 100644
--- a/service/src/com/android/server/connectivity/SatelliteAccessController.java
+++ b/service/src/com/android/server/connectivity/SatelliteAccessController.java
@@ -26,8 +26,10 @@
 import android.os.Handler;
 import android.os.Process;
 import android.os.UserHandle;
+import android.os.UserManager;
 import android.util.ArraySet;
 import android.util.Log;
+import android.util.SparseArray;
 
 import com.android.internal.annotations.VisibleForTesting;
 
@@ -44,13 +46,18 @@
  */
 public class SatelliteAccessController {
     private static final String TAG = SatelliteAccessController.class.getSimpleName();
-    private final PackageManager mPackageManager;
+    private final Context mContext;
     private final Dependencies mDeps;
     private final DefaultMessageRoleListener mDefaultMessageRoleListener;
+    private final UserManager mUserManager;
     private final Consumer<Set<Integer>> mCallback;
-    private final Set<Integer> mSatelliteNetworkPreferredUidCache = new ArraySet<>();
     private final Handler mConnectivityServiceHandler;
 
+    // At this sparseArray, Key is userId and values are uids of SMS apps that are allowed
+    // to use satellite network as fallback.
+    private final SparseArray<Set<Integer>> mAllUsersSatelliteNetworkFallbackUidCache =
+            new SparseArray<>();
+
     /**
      *  Monitor {@link android.app.role.OnRoleHoldersChangedListener#onRoleHoldersChanged(String,
      *  UserHandle)},
@@ -59,10 +66,10 @@
     private final class DefaultMessageRoleListener
             implements OnRoleHoldersChangedListener {
         @Override
-        public void onRoleHoldersChanged(String role, UserHandle user) {
+        public void onRoleHoldersChanged(String role, UserHandle userHandle) {
             if (RoleManager.ROLE_SMS.equals(role)) {
                 Log.i(TAG, "ROLE_SMS Change detected ");
-                onRoleSmsChanged();
+                onRoleSmsChanged(userHandle);
             }
         }
 
@@ -71,7 +78,7 @@
                 mDeps.addOnRoleHoldersChangedListenerAsUser(
                         mConnectivityServiceHandler::post, this, UserHandle.ALL);
             } catch (RuntimeException e) {
-                Log.e(TAG, "Could not register satellite controller listener due to " + e);
+                Log.wtf(TAG, "Could not register satellite controller listener due to " + e);
             }
         }
     }
@@ -89,9 +96,9 @@
             mRoleManager = context.getSystemService(RoleManager.class);
         }
 
-        /** See {@link RoleManager#getRoleHolders(String)} */
-        public List<String> getRoleHolders(String roleName) {
-            return mRoleManager.getRoleHolders(roleName);
+        /** See {@link RoleManager#getRoleHoldersAsUser(String, UserHandle)} */
+        public List<String> getRoleHoldersAsUser(String roleName, UserHandle userHandle) {
+            return mRoleManager.getRoleHoldersAsUser(roleName, userHandle);
         }
 
         /** See {@link RoleManager#addOnRoleHoldersChangedListenerAsUser} */
@@ -105,81 +112,107 @@
     SatelliteAccessController(@NonNull final Context c, @NonNull final Dependencies deps,
             Consumer<Set<Integer>> callback,
             @NonNull final Handler connectivityServiceInternalHandler) {
+        mContext = c;
         mDeps = deps;
-        mPackageManager = c.getPackageManager();
+        mUserManager = mContext.getSystemService(UserManager.class);
         mDefaultMessageRoleListener = new DefaultMessageRoleListener();
         mCallback = callback;
         mConnectivityServiceHandler = connectivityServiceInternalHandler;
     }
 
-    private void updateSatelliteNetworkPreferredUidListCache(List<String> packageNames) {
-        for (String packageName : packageNames) {
-            // Check if SATELLITE_COMMUNICATION permission is enabled for default sms application
-            // package before adding it part of satellite network preferred uid cache list.
-            if (isSatellitePermissionEnabled(packageName)) {
-                mSatelliteNetworkPreferredUidCache.add(getUidForPackage(packageName));
+    private Set<Integer> updateSatelliteNetworkFallbackUidListCache(List<String> packageNames,
+            @NonNull UserHandle userHandle) {
+        Set<Integer> fallbackUids = new ArraySet<>();
+        PackageManager pm =
+                mContext.createContextAsUser(userHandle, 0).getPackageManager();
+        if (pm != null) {
+            for (String packageName : packageNames) {
+                // Check if SATELLITE_COMMUNICATION permission is enabled for default sms
+                // application package before adding it part of satellite network fallback uid
+                // cache list.
+                if (isSatellitePermissionEnabled(pm, packageName)) {
+                    int uid = getUidForPackage(pm, packageName);
+                    if (uid != Process.INVALID_UID) {
+                        fallbackUids.add(uid);
+                    }
+                }
             }
+        } else {
+            Log.wtf(TAG, "package manager found null");
         }
+        return fallbackUids;
     }
 
     //Check if satellite communication is enabled for the package
-    private boolean isSatellitePermissionEnabled(String packageName) {
-        if (mPackageManager != null) {
-            return mPackageManager.checkPermission(
-                    Manifest.permission.SATELLITE_COMMUNICATION, packageName)
-                    == PackageManager.PERMISSION_GRANTED;
-        }
-        return false;
+    private boolean isSatellitePermissionEnabled(PackageManager packageManager,
+            String packageName) {
+        return packageManager.checkPermission(
+                Manifest.permission.SATELLITE_COMMUNICATION, packageName)
+                == PackageManager.PERMISSION_GRANTED;
     }
 
-    private int getUidForPackage(String pkgName) {
+    private int getUidForPackage(PackageManager packageManager, String pkgName) {
         if (pkgName == null) {
             return Process.INVALID_UID;
         }
         try {
-            if (mPackageManager != null) {
-                ApplicationInfo applicationInfo = mPackageManager.getApplicationInfo(pkgName, 0);
-                if (applicationInfo != null) {
-                    return applicationInfo.uid;
-                }
-            }
+            ApplicationInfo applicationInfo = packageManager.getApplicationInfo(pkgName, 0);
+            return applicationInfo.uid;
         } catch (PackageManager.NameNotFoundException exception) {
             Log.e(TAG, "Unable to find uid for package: " + pkgName);
         }
         return Process.INVALID_UID;
     }
 
-    //on Role sms change triggered by OnRoleHoldersChangedListener()
-    private void onRoleSmsChanged() {
-        final List<String> packageNames = getRoleSmsChangedPackageName();
-
-        // Create a new Set
-        Set<Integer> previousSatellitePreferredUid = new ArraySet<>(
-                mSatelliteNetworkPreferredUidCache);
-
-        mSatelliteNetworkPreferredUidCache.clear();
-
-        if (packageNames != null) {
-            Log.i(TAG, "role_sms_packages: " + packageNames);
-            // On Role change listener, update the satellite network preferred uid cache list
-            updateSatelliteNetworkPreferredUidListCache(packageNames);
-            Log.i(TAG, "satellite_preferred_uid: " + mSatelliteNetworkPreferredUidCache);
-        } else {
-            Log.wtf(TAG, "package name was found null");
+    // on Role sms change triggered by OnRoleHoldersChangedListener()
+    // TODO(b/326373613): using UserLifecycleListener, callback to be received when user removed for
+    // user delete scenario. This to be used to update uid list and ML Layer request can also be
+    // updated.
+    private void onRoleSmsChanged(@NonNull UserHandle userHandle) {
+        int userId = userHandle.getIdentifier();
+        if (userId == Process.INVALID_UID) {
+            Log.wtf(TAG, "Invalid User Id");
+            return;
         }
 
+        //Returns empty list if no package exists
+        final List<String> packageNames =
+                mDeps.getRoleHoldersAsUser(RoleManager.ROLE_SMS, userHandle);
+
+        // Store previous satellite fallback uid available
+        final Set<Integer> prevUidsForUser =
+                mAllUsersSatelliteNetworkFallbackUidCache.get(userId, new ArraySet<>());
+
+        Log.i(TAG, "currentUser : role_sms_packages: " + userId + " : " + packageNames);
+        final Set<Integer> newUidsForUser = !packageNames.isEmpty()
+                ? updateSatelliteNetworkFallbackUidListCache(packageNames, userHandle)
+                : new ArraySet<>();
+        Log.i(TAG, "satellite_fallback_uid: " + newUidsForUser);
+
         // on Role change, update the multilayer request at ConnectivityService with updated
-        // satellite network preferred uid cache list if changed or to revoke for previous default
-        // sms app
-        if (!mSatelliteNetworkPreferredUidCache.equals(previousSatellitePreferredUid)) {
-            Log.i(TAG, "update multi layer request");
-            mCallback.accept(mSatelliteNetworkPreferredUidCache);
+        // satellite network fallback uid cache list of multiple users as applicable
+        if (newUidsForUser.equals(prevUidsForUser)) {
+            return;
         }
+
+        mAllUsersSatelliteNetworkFallbackUidCache.put(userId, newUidsForUser);
+
+        // Merge all uids of multiple users available
+        Set<Integer> mergedSatelliteNetworkFallbackUidCache = new ArraySet<>();
+        for (int i = 0; i < mAllUsersSatelliteNetworkFallbackUidCache.size(); i++) {
+            mergedSatelliteNetworkFallbackUidCache.addAll(
+                    mAllUsersSatelliteNetworkFallbackUidCache.valueAt(i));
+        }
+        Log.i(TAG, "merged uid list for multi layer request : "
+                + mergedSatelliteNetworkFallbackUidCache);
+
+        // trigger multiple layer request for satellite network fallback of multi user uids
+        mCallback.accept(mergedSatelliteNetworkFallbackUidCache);
     }
 
-    private List<String> getRoleSmsChangedPackageName() {
+    private List<String> getRoleSmsChangedPackageName(UserHandle userHandle) {
         try {
-            return mDeps.getRoleHolders(RoleManager.ROLE_SMS);
+            return mDeps.getRoleHoldersAsUser(RoleManager.ROLE_SMS, userHandle);
         } catch (RuntimeException e) {
             Log.wtf(TAG, "Could not get package name at role sms change update due to: " + e);
             return null;
@@ -188,7 +221,16 @@
 
     /** Register OnRoleHoldersChangedListener */
     public void start() {
-        mConnectivityServiceHandler.post(this::onRoleSmsChanged);
+        mConnectivityServiceHandler.post(this::updateAllUserRoleSmsUids);
         mDefaultMessageRoleListener.register();
     }
+
+    private void updateAllUserRoleSmsUids() {
+        List<UserHandle> existingUsers = mUserManager.getUserHandles(true /* excludeDying */);
+        // Iterate through the user handles and obtain their uids with role sms and satellite
+        // communication permission
+        for (UserHandle userHandle : existingUsers) {
+            onRoleSmsChanged(userHandle);
+        }
+    }
 }
diff --git a/tests/common/java/android/net/NetworkCapabilitiesTest.java b/tests/common/java/android/net/NetworkCapabilitiesTest.java
index 3a3459b..3124b1b 100644
--- a/tests/common/java/android/net/NetworkCapabilitiesTest.java
+++ b/tests/common/java/android/net/NetworkCapabilitiesTest.java
@@ -54,6 +54,7 @@
 import static android.net.NetworkCapabilities.SIGNAL_STRENGTH_UNSPECIFIED;
 import static android.net.NetworkCapabilities.TRANSPORT_CELLULAR;
 import static android.net.NetworkCapabilities.TRANSPORT_ETHERNET;
+import static android.net.NetworkCapabilities.TRANSPORT_SATELLITE;
 import static android.net.NetworkCapabilities.TRANSPORT_TEST;
 import static android.net.NetworkCapabilities.TRANSPORT_USB;
 import static android.net.NetworkCapabilities.TRANSPORT_VPN;
@@ -761,6 +762,47 @@
     }
 
     @Test
+    public void testSetNetworkSpecifierWithCellularAndSatelliteMultiTransportNc() {
+        final TelephonyNetworkSpecifier specifier = new TelephonyNetworkSpecifier(1);
+        NetworkCapabilities nc = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addTransportType(TRANSPORT_SATELLITE)
+                .setNetworkSpecifier(specifier)
+                .build();
+        // Adding a specifier did not crash with 2 transports if it is cellular + satellite
+        assertEquals(specifier, nc.getNetworkSpecifier());
+    }
+
+    @Test
+    public void testSetNetworkSpecifierWithWifiAndSatelliteMultiTransportNc() {
+        final TelephonyNetworkSpecifier specifier = new TelephonyNetworkSpecifier(1);
+        NetworkCapabilities.Builder nc1 = new NetworkCapabilities.Builder();
+        nc1.addTransportType(TRANSPORT_SATELLITE).addTransportType(TRANSPORT_WIFI);
+        // Adding multiple transports specifier to crash, apart from cellular + satellite
+        // combination
+        assertThrows("Cannot set NetworkSpecifier on a NetworkCapability with multiple transports!",
+                IllegalStateException.class,
+                () -> nc1.build().setNetworkSpecifier(specifier));
+        assertThrows("Cannot set NetworkSpecifier on a NetworkCapability with multiple transports!",
+                IllegalStateException.class,
+                () -> nc1.setNetworkSpecifier(specifier));
+    }
+
+    @Test
+    public void testSetNetworkSpecifierOnTestWithCellularAndSatelliteMultiTransportNc() {
+        final TelephonyNetworkSpecifier specifier = new TelephonyNetworkSpecifier(1);
+        NetworkCapabilities nc = new NetworkCapabilities.Builder()
+                .addTransportType(TRANSPORT_TEST)
+                .addTransportType(TRANSPORT_CELLULAR)
+                .addTransportType(TRANSPORT_SATELLITE)
+                .setNetworkSpecifier(specifier)
+                .build();
+        // Adding a specifier did not crash with 3 transports , TEST + CELLULAR + SATELLITE and if
+        // one is test
+        assertEquals(specifier, nc.getNetworkSpecifier());
+    }
+
+    @Test
     public void testSetNetworkSpecifierOnTestMultiTransportNc() {
         final NetworkSpecifier specifier = CompatUtil.makeEthernetNetworkSpecifier("eth0");
         NetworkCapabilities nc = new NetworkCapabilities.Builder()
diff --git a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
index 1241e18..2ca8832 100644
--- a/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
+++ b/tests/cts/hostside/app/src/com/android/cts/net/hostside/AbstractRestrictBackgroundNetworkTestCase.java
@@ -160,10 +160,6 @@
 
     private static final long BROADCAST_TIMEOUT_MS = 5_000;
 
-    // Should be kept in sync with the constant in NetworkPolicyManagerService.
-    // TODO: b/322115994 - remove once the feature is in staging.
-    private static final boolean ALWAYS_RESTRICT_BACKGROUND_NETWORK = false;
-
     protected Context mContext;
     protected Instrumentation mInstrumentation;
     protected ConnectivityManager mCm;
@@ -233,8 +229,9 @@
         }
         final String output = executeShellCommand("device_config get backstage_power"
                 + " com.android.server.net.network_blocked_for_top_sleeping_and_above");
-        return Boolean.parseBoolean(output) && ALWAYS_RESTRICT_BACKGROUND_NETWORK;
+        return Boolean.parseBoolean(output);
     }
+
     protected int getUid(String packageName) throws Exception {
         return mContext.getPackageManager().getPackageUid(packageName, 0);
     }
diff --git a/tests/cts/net/src/android/net/cts/IpSecTransformStateTest.java b/tests/cts/net/src/android/net/cts/IpSecTransformStateTest.java
new file mode 100644
index 0000000..7b42306
--- /dev/null
+++ b/tests/cts/net/src/android/net/cts/IpSecTransformStateTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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 android.net.cts;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.fail;
+
+import android.net.IpSecTransformState;
+import android.os.Build;
+import android.os.SystemClock;
+
+import com.android.testutils.DevSdkIgnoreRule;
+import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
+import com.android.testutils.DevSdkIgnoreRunner;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@IgnoreUpTo(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+@RunWith(DevSdkIgnoreRunner.class)
+public class IpSecTransformStateTest {
+    @Rule public final DevSdkIgnoreRule ignoreRule = new DevSdkIgnoreRule();
+
+    private static final long TIMESTAMP_MILLIS = 1000L;
+    private static final long HIGHEST_SEQ_NUMBER_TX = 10000L;
+    private static final long HIGHEST_SEQ_NUMBER_RX = 20000L;
+    private static final long PACKET_COUNT = 9000L;
+    private static final long BYTE_COUNT = 900000L;
+
+    private static final int REPLAY_BITMAP_LEN_BYTE = 512;
+    private static final byte[] REPLAY_BITMAP_NO_PACKETS = new byte[REPLAY_BITMAP_LEN_BYTE];
+    private static final byte[] REPLAY_BITMAP_ALL_RECEIVED = new byte[REPLAY_BITMAP_LEN_BYTE];
+
+    static {
+        for (int i = 0; i < REPLAY_BITMAP_ALL_RECEIVED.length; i++) {
+            REPLAY_BITMAP_ALL_RECEIVED[i] = (byte) 0xff;
+        }
+    }
+
+    @Test
+    public void testBuildAndGet() {
+        final IpSecTransformState state =
+                new IpSecTransformState.Builder()
+                        .setTimestampMillis(TIMESTAMP_MILLIS)
+                        .setTxHighestSequenceNumber(HIGHEST_SEQ_NUMBER_TX)
+                        .setRxHighestSequenceNumber(HIGHEST_SEQ_NUMBER_RX)
+                        .setPacketCount(PACKET_COUNT)
+                        .setByteCount(BYTE_COUNT)
+                        .setReplayBitmap(REPLAY_BITMAP_ALL_RECEIVED)
+                        .build();
+
+        assertEquals(TIMESTAMP_MILLIS, state.getTimestampMillis());
+        assertEquals(HIGHEST_SEQ_NUMBER_TX, state.getTxHighestSequenceNumber());
+        assertEquals(HIGHEST_SEQ_NUMBER_RX, state.getRxHighestSequenceNumber());
+        assertEquals(PACKET_COUNT, state.getPacketCount());
+        assertEquals(BYTE_COUNT, state.getByteCount());
+        assertArrayEquals(REPLAY_BITMAP_ALL_RECEIVED, state.getReplayBitmap());
+    }
+
+    @Test
+    public void testSelfGeneratedTimestampMillis() {
+        final long elapsedRealtimeBefore = SystemClock.elapsedRealtime();
+
+        final IpSecTransformState state =
+                new IpSecTransformState.Builder().setReplayBitmap(REPLAY_BITMAP_NO_PACKETS).build();
+
+        final long elapsedRealtimeAfter = SystemClock.elapsedRealtime();
+
+        // Verify  elapsedRealtimeBefore <= state.getTimestampMillis() <= elapsedRealtimeAfter
+        assertFalse(elapsedRealtimeBefore > state.getTimestampMillis());
+        assertFalse(elapsedRealtimeAfter < state.getTimestampMillis());
+    }
+
+    @Test
+    public void testBuildWithoutReplayBitmap() throws Exception {
+        try {
+            new IpSecTransformState.Builder().build();
+            fail("Expected expcetion if replay bitmap is not set");
+        } catch (NullPointerException expected) {
+        }
+    }
+}
diff --git a/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt b/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
index 64a515a..193078b 100644
--- a/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
+++ b/tests/unit/java/com/android/server/connectivity/SatelliteAccessControllerTest.kt
@@ -21,9 +21,12 @@
 import android.content.Context
 import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
+import android.content.pm.UserInfo
 import android.os.Build
 import android.os.Handler
 import android.os.UserHandle
+import android.util.ArraySet
+import com.android.server.makeMockUserManager
 import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo
 import com.android.testutils.DevSdkIgnoreRunner
 import org.junit.Before
@@ -36,18 +39,31 @@
 import org.mockito.Mockito.doReturn
 import org.mockito.Mockito.mock
 import org.mockito.Mockito.never
-import org.mockito.Mockito.times
 import org.mockito.Mockito.verify
 import java.util.concurrent.Executor
 import java.util.function.Consumer
-import kotlin.test.assertEquals
-import kotlin.test.assertFalse
-import kotlin.test.assertTrue
 
-private const val DEFAULT_MESSAGING_APP1 = "default_messaging_app_1"
-private const val DEFAULT_MESSAGING_APP2 = "default_messaging_app_2"
-private const val DEFAULT_MESSAGING_APP1_UID = 1234
-private const val DEFAULT_MESSAGING_APP2_UID = 5678
+private const val USER = 0
+val USER_INFO = UserInfo(USER, "" /* name */, UserInfo.FLAG_PRIMARY)
+val USER_HANDLE = UserHandle(USER)
+private const val PRIMARY_USER = 0
+private const val SECONDARY_USER = 10
+private val PRIMARY_USER_HANDLE = UserHandle.of(PRIMARY_USER)
+private val SECONDARY_USER_HANDLE = UserHandle.of(SECONDARY_USER)
+// sms app names
+private const val SMS_APP1 = "sms_app_1"
+private const val SMS_APP2 = "sms_app_2"
+// sms app ids
+private const val SMS_APP_ID1 = 100
+private const val SMS_APP_ID2 = 101
+// UID for app1 and app2 on primary user
+// These app could become default sms app for user1
+private val PRIMARY_USER_SMS_APP_UID1 = UserHandle.getUid(PRIMARY_USER, SMS_APP_ID1)
+private val PRIMARY_USER_SMS_APP_UID2 = UserHandle.getUid(PRIMARY_USER, SMS_APP_ID2)
+// UID for app1 and app2 on secondary user
+// These app could become default sms app for user2
+private val SECONDARY_USER_SMS_APP_UID1 = UserHandle.getUid(SECONDARY_USER, SMS_APP_ID1)
+private val SECONDARY_USER_SMS_APP_UID2 = UserHandle.getUid(SECONDARY_USER, SMS_APP_ID2)
 
 @RunWith(DevSdkIgnoreRunner::class)
 @IgnoreUpTo(Build.VERSION_CODES.TIRAMISU)
@@ -58,33 +74,36 @@
     private val mRoleManager =
         mock(SatelliteAccessController.Dependencies::class.java)
     private val mCallback = mock(Consumer::class.java) as Consumer<Set<Int>>
-    private val mSatelliteAccessController by lazy {
-        SatelliteAccessController(context, mRoleManager, mCallback, mHandler)}
-    private var mRoleHolderChangedListener: OnRoleHoldersChangedListener? = null
+    private val mSatelliteAccessController =
+        SatelliteAccessController(context, mRoleManager, mCallback, mHandler)
+    private lateinit var mRoleHolderChangedListener: OnRoleHoldersChangedListener
     @Before
     @Throws(PackageManager.NameNotFoundException::class)
     fun setup() {
+        makeMockUserManager(USER_INFO, USER_HANDLE)
+        doReturn(context).`when`(context).createContextAsUser(any(), anyInt())
         doReturn(mPackageManager).`when`(context).packageManager
-        doReturn(PackageManager.PERMISSION_GRANTED)
-            .`when`(mPackageManager)
-            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, DEFAULT_MESSAGING_APP1)
-        doReturn(PackageManager.PERMISSION_GRANTED)
-            .`when`(mPackageManager)
-            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, DEFAULT_MESSAGING_APP2)
 
-        // Initialise default message application package1
+        doReturn(PackageManager.PERMISSION_GRANTED)
+            .`when`(mPackageManager)
+            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP1)
+        doReturn(PackageManager.PERMISSION_GRANTED)
+            .`when`(mPackageManager)
+            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP2)
+
+        // Initialise default message application primary user package1
         val applicationInfo1 = ApplicationInfo()
-        applicationInfo1.uid = DEFAULT_MESSAGING_APP1_UID
+        applicationInfo1.uid = PRIMARY_USER_SMS_APP_UID1
         doReturn(applicationInfo1)
             .`when`(mPackageManager)
-            .getApplicationInfo(eq(DEFAULT_MESSAGING_APP1), anyInt())
+            .getApplicationInfo(eq(SMS_APP1), anyInt())
 
-        // Initialise default message application package2
+        // Initialise default message application primary user package2
         val applicationInfo2 = ApplicationInfo()
-        applicationInfo2.uid = DEFAULT_MESSAGING_APP2_UID
+        applicationInfo2.uid = PRIMARY_USER_SMS_APP_UID2
         doReturn(applicationInfo2)
             .`when`(mPackageManager)
-            .getApplicationInfo(eq(DEFAULT_MESSAGING_APP2), anyInt())
+            .getApplicationInfo(eq(SMS_APP2), anyInt())
 
         // Get registered listener using captor
         val listenerCaptor = ArgumentCaptor.forClass(
@@ -97,80 +116,107 @@
     }
 
     @Test
-    fun test_onRoleHoldersChanged_SatellitePreferredUid_Changed() {
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        val satelliteNetworkPreferredSet =
-            ArgumentCaptor.forClass(Set::class.java) as ArgumentCaptor<Set<Int>>
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, never()).accept(satelliteNetworkPreferredSet.capture())
+    fun test_onRoleHoldersChanged_SatelliteFallbackUid_Changed_SingleUser() {
+        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback, never()).accept(any())
 
-        // check DEFAULT_MESSAGING_APP1 is available as satellite network preferred uid
-        doReturn(listOf(DEFAULT_MESSAGING_APP1))
-            .`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback).accept(satelliteNetworkPreferredSet.capture())
-        var satelliteNetworkPreferredUids = satelliteNetworkPreferredSet.value
-        assertEquals(1, satelliteNetworkPreferredUids.size)
-        assertTrue(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP1_UID))
-        assertFalse(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP2_UID))
+        // check DEFAULT_MESSAGING_APP1 is available as satellite network fallback uid
+        doReturn(listOf(SMS_APP1))
+            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID1))
 
-        // check DEFAULT_MESSAGING_APP1 and DEFAULT_MESSAGING_APP2 is available
-        // as satellite network preferred uid
-        val dmas: MutableList<String> = ArrayList()
-        dmas.add(DEFAULT_MESSAGING_APP1)
-        dmas.add(DEFAULT_MESSAGING_APP2)
-        doReturn(dmas).`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, times(2))
-            .accept(satelliteNetworkPreferredSet.capture())
-        satelliteNetworkPreferredUids = satelliteNetworkPreferredSet.value
-        assertEquals(2, satelliteNetworkPreferredUids.size)
-        assertTrue(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP1_UID))
-        assertTrue(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP2_UID))
+        // check SMS_APP2 is available as satellite network Fallback uid
+        doReturn(listOf(SMS_APP2)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
 
-        // check no uid is available as satellite network preferred uid
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, times(3))
-            .accept(satelliteNetworkPreferredSet.capture())
-        satelliteNetworkPreferredUids = satelliteNetworkPreferredSet.value
-        assertEquals(0, satelliteNetworkPreferredUids.size)
-        assertFalse(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP1_UID))
-        assertFalse(satelliteNetworkPreferredUids.contains(DEFAULT_MESSAGING_APP2_UID))
-
-        // No Change received at OnRoleSmsChanged, check callback not triggered
-        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, times(3))
-            .accept(satelliteNetworkPreferredSet.capture())
+        // check no uid is available as satellite network fallback uid
+        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(ArraySet())
     }
 
     @Test
     fun test_onRoleHoldersChanged_NoSatelliteCommunicationPermission() {
-        doReturn(listOf<Any>()).`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        val satelliteNetworkPreferredSet =
-            ArgumentCaptor.forClass(Set::class.java) as ArgumentCaptor<Set<Int>>
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, never()).accept(satelliteNetworkPreferredSet.capture())
+        doReturn(listOf<Any>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback, never()).accept(any())
 
-        // check DEFAULT_MESSAGING_APP1 is not available as satellite network preferred uid
+        // check DEFAULT_MESSAGING_APP1 is not available as satellite network fallback uid
         // since satellite communication permission not available.
         doReturn(PackageManager.PERMISSION_DENIED)
             .`when`(mPackageManager)
-            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, DEFAULT_MESSAGING_APP1)
-        doReturn(listOf(DEFAULT_MESSAGING_APP1))
-            .`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_SMS, UserHandle.ALL)
-        verify(mCallback, never()).accept(satelliteNetworkPreferredSet.capture())
+            .checkPermission(Manifest.permission.SATELLITE_COMMUNICATION, SMS_APP1)
+        doReturn(listOf(SMS_APP1))
+            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback, never()).accept(any())
     }
 
     @Test
     fun test_onRoleHoldersChanged_RoleSms_NotAvailable() {
-        doReturn(listOf(DEFAULT_MESSAGING_APP1))
-            .`when`(mRoleManager).getRoleHolders(RoleManager.ROLE_SMS)
-        val satelliteNetworkPreferredSet =
-            ArgumentCaptor.forClass(Set::class.java) as ArgumentCaptor<Set<Int>>
-        mRoleHolderChangedListener?.onRoleHoldersChanged(RoleManager.ROLE_BROWSER, UserHandle.ALL)
-        verify(mCallback, never()).accept(satelliteNetworkPreferredSet.capture())
+        doReturn(listOf(SMS_APP1))
+            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_BROWSER,
+            PRIMARY_USER_HANDLE)
+        verify(mCallback, never()).accept(any())
+    }
+
+    @Test
+    fun test_onRoleHoldersChanged_SatelliteNetworkFallbackUid_Changed_multiUser() {
+        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback, never()).accept(any())
+
+        // check SMS_APP1 is available as satellite network fallback uid at primary user
+        doReturn(listOf(SMS_APP1))
+            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID1))
+
+        // check SMS_APP2 is available as satellite network fallback uid at primary user
+        doReturn(listOf(SMS_APP2)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2))
+
+        // check SMS_APP1 is available as satellite network fallback uid at secondary user
+        val applicationInfo1 = ApplicationInfo()
+        applicationInfo1.uid = SECONDARY_USER_SMS_APP_UID1
+        doReturn(applicationInfo1).`when`(mPackageManager)
+            .getApplicationInfo(eq(SMS_APP1), anyInt())
+        doReturn(listOf(SMS_APP1)).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            SECONDARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(PRIMARY_USER_SMS_APP_UID2, SECONDARY_USER_SMS_APP_UID1))
+
+        // check no uid is available as satellite network fallback uid at primary user
+        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS,
+            PRIMARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(SECONDARY_USER_SMS_APP_UID1))
+
+        // check SMS_APP2 is available as satellite network fallback uid at secondary user
+        applicationInfo1.uid = SECONDARY_USER_SMS_APP_UID2
+        doReturn(applicationInfo1).`when`(mPackageManager)
+            .getApplicationInfo(eq(SMS_APP2), anyInt())
+        doReturn(listOf(SMS_APP2))
+            .`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+        verify(mCallback).accept(setOf(SECONDARY_USER_SMS_APP_UID2))
+
+        // check no uid is available as satellite network fallback uid at secondary user
+        doReturn(listOf<String>()).`when`(mRoleManager).getRoleHoldersAsUser(RoleManager.ROLE_SMS,
+            SECONDARY_USER_HANDLE)
+        mRoleHolderChangedListener.onRoleHoldersChanged(RoleManager.ROLE_SMS, SECONDARY_USER_HANDLE)
+        verify(mCallback).accept(ArraySet())
     }
 }
diff --git a/thread/flags/Android.bp b/thread/flags/Android.bp
deleted file mode 100644
index 15f58a9..0000000
--- a/thread/flags/Android.bp
+++ /dev/null
@@ -1,23 +0,0 @@
-//
-// 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/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 2fccf6b..7554610 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;
 
@@ -38,13 +41,16 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 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 +72,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 +89,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 +115,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 +127,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 +160,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 +182,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 +215,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 +229,422 @@
         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_outboundForwarding_llaToScope4IsNotForwarded() 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 ftdLla = ftd.getLinkLocalAddress();
+        assertNotNull(ftdLla);
+
+        ftd.ping(GROUP_ADDR_SCOPE_4, ftdLla, 100 /* size */, 1 /* count */);
+
+        assertNull(
+                pollForPacketOnInfraNetwork(ICMPV6_ECHO_REQUEST_TYPE, ftdLla, GROUP_ADDR_SCOPE_4));
+    }
+
+    @Test
+    public void multicastRouting_outboundForwarding_mlaToScope4IsNotForwarded() 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);
+        List<Inet6Address> ftdMlas = ftd.getMeshLocalAddresses();
+        assertFalse(ftdMlas.isEmpty());
+
+        for (Inet6Address ftdMla : ftdMlas) {
+            ftd.ping(GROUP_ADDR_SCOPE_4, ftdMla, 100 /* size */, 1 /* count */);
+
+            assertNull(
+                    pollForPacketOnInfraNetwork(
+                            ICMPV6_ECHO_REQUEST_TYPE, ftdMla, GROUP_ADDR_SCOPE_4));
+        }
+    }
+
+    @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..6cb1675 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.
      *
@@ -103,6 +107,39 @@
     }
 
     /**
+     * Returns the link-local address of the device.
+     *
+     * <p>This methods goes through all unicast addresses on the device and returns the address that
+     * begins with fe80.
+     */
+    public Inet6Address getLinkLocalAddress() {
+        List<String> output = executeCommand("ipaddr linklocal");
+        if (!output.isEmpty() && output.get(0).startsWith("fe80:")) {
+            return (Inet6Address) InetAddresses.parseNumericAddress(output.get(0));
+        }
+        return null;
+    }
+
+    /**
+     * Returns the mesh-local addresses of the device.
+     *
+     * <p>This methods goes through all unicast addresses on the device and returns the address that
+     * begins with mesh-local prefix.
+     */
+    public List<Inet6Address> getMeshLocalAddresses() {
+        List<String> addresses = executeCommand("ipaddr");
+        List<Inet6Address> meshLocalAddresses = new ArrayList<>();
+        IpPrefix meshLocalPrefix = mActiveOperationalDataset.getMeshLocalPrefix();
+        for (String address : addresses) {
+            Inet6Address addr = (Inet6Address) InetAddresses.parseNumericAddress(address);
+            if (meshLocalPrefix.contains(addr)) {
+                meshLocalAddresses.add(addr);
+            }
+        }
+        return meshLocalAddresses;
+    }
+
+    /**
      * Joins the Thread network using the given {@link ActiveOperationalDataset}.
      *
      * @param dataset the Active Operational Dataset
@@ -182,6 +219,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;
+    }
 }
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 b557e65..4948c22 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.Manifest.permission.ACCESS_NETWORK_STATE;
 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;
@@ -25,7 +26,6 @@
 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;
 import static com.google.common.truth.Truth.assertThat;
@@ -37,6 +37,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
@@ -130,6 +131,11 @@
         MockitoAnnotations.initMocks(this);
 
         mContext = spy(ApplicationProvider.getApplicationContext());
+        doNothing()
+                .when(mContext)
+                .enforceCallingOrSelfPermission(
+                        eq(PERMISSION_THREAD_NETWORK_PRIVILEGED), anyString());
+
         mTestLooper = new TestLooper();
         final Handler handler = new Handler(mTestLooper.getLooper());
         NetworkProvider networkProvider =
@@ -174,9 +180,7 @@
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
         mFakeOtDaemon.setJoinException(new RemoteException("ot-daemon join() throws"));
 
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver));
+        mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver);
         mTestLooper.dispatchAll();
 
         verify(mockReceiver, never()).onSuccess();
@@ -188,9 +192,7 @@
         mService.initialize();
         final IOperationReceiver mockReceiver = mock(IOperationReceiver.class);
 
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver));
+        mService.join(DEFAULT_ACTIVE_DATASET, mockReceiver);
         // Here needs to call Testlooper#dispatchAll twices because TestLooper#moveTimeForward
         // operates on only currently enqueued messages but the delayed message is posted from
         // another Handler task.
@@ -274,9 +276,7 @@
         mService.initialize();
 
         CompletableFuture<Void> setEnabledFuture = new CompletableFuture<>();
-        runAsShell(
-                PERMISSION_THREAD_NETWORK_PRIVILEGED,
-                () -> mService.setEnabled(true, newOperationReceiver(setEnabledFuture)));
+        mService.setEnabled(true, newOperationReceiver(setEnabledFuture));
         mTestLooper.dispatchAll();
 
         var thrown = assertThrows(ExecutionException.class, () -> setEnabledFuture.get());