[Thread] use TestRule to filter Thread tests

It's error-prone to check dependent feature in both setUp() and
tearDown() to filter specific Thread tests. This commit adds
`ThreadFeatureCheckRule` and refactor the Thread tests to use the new
test rule to filter test cases.

Test: manually verified the annonated tests are skipped with
ASSUMPTION_FAILED when the dependency feature is not supported

Change-Id: I7a5efe6bda53fc1a6e204f6f40dd3ab2c3940988
diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
index b74a15a..4a7d3a7 100644
--- a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
+++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java
@@ -74,42 +74,61 @@
 public final class ActiveOperationalDataset implements Parcelable {
     /** The maximum length of the Active Operational Dataset TLV array in bytes. */
     public static final int LENGTH_MAX_DATASET_TLVS = 254;
+
     /** The length of Extended PAN ID in bytes. */
     public static final int LENGTH_EXTENDED_PAN_ID = 8;
+
     /** The minimum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MIN_NETWORK_NAME_BYTES = 1;
+
     /** The maximum length of Network Name as UTF-8 bytes. */
     public static final int LENGTH_MAX_NETWORK_NAME_BYTES = 16;
+
     /** The length of Network Key in bytes. */
     public static final int LENGTH_NETWORK_KEY = 16;
+
     /** The length of Mesh-Local Prefix in bits. */
     public static final int LENGTH_MESH_LOCAL_PREFIX_BITS = 64;
+
     /** The length of PSKc in bytes. */
     public static final int LENGTH_PSKC = 16;
+
     /** The 2.4 GHz channel page. */
     public static final int CHANNEL_PAGE_24_GHZ = 0;
+
     /** The minimum 2.4GHz channel. */
     public static final int CHANNEL_MIN_24_GHZ = 11;
+
     /** The maximum 2.4GHz channel. */
     public static final int CHANNEL_MAX_24_GHZ = 26;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL = 0;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PAN_ID = 1;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_EXTENDED_PAN_ID = 2;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_NAME = 3;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_PSKC = 4;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_NETWORK_KEY = 5;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_MESH_LOCAL_PREFIX = 7;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_SECURITY_POLICY = 12;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_ACTIVE_TIMESTAMP = 14;
+
     /** @hide */
     @VisibleForTesting public static final int TYPE_CHANNEL_MASK = 53;
 
@@ -975,8 +994,10 @@
     public static final class SecurityPolicy {
         /** The default Rotation Time in hours. */
         public static final int DEFAULT_ROTATION_TIME_HOURS = 672;
+
         /** The minimum length of Security Policy flags in bytes. */
         public static final int LENGTH_MIN_SECURITY_POLICY_FLAGS = 1;
+
         /** The length of Rotation Time TLV value in bytes. */
         private static final int LENGTH_SECURITY_POLICY_ROTATION_TIME = 2;
 
diff --git a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
index aba4193..0277528 100644
--- a/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
+++ b/thread/service/java/com/android/server/thread/ThreadPersistentSettings.java
@@ -46,10 +46,13 @@
  */
 public class ThreadPersistentSettings {
     private static final String TAG = "ThreadPersistentSettings";
+
     /** File name used for storing settings. */
     private static final String FILE_NAME = "ThreadPersistentSettings.xml";
+
     /** Current config store data version. This will be incremented for any additions. */
     private static final int CURRENT_SETTINGS_STORE_DATA_VERSION = 1;
+
     /**
      * Stores the version of the data. This can be used to handle migration of data if some
      * non-backward compatible change introduced.
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index 36ce4d5..48e0c26 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -40,7 +40,6 @@
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
-import static org.junit.Assume.assumeNotNull;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
@@ -60,7 +59,8 @@
 import android.net.thread.ThreadNetworkException;
 import android.net.thread.ThreadNetworkManager;
 import android.net.thread.utils.TapTestNetworkTracker;
-import android.os.Build;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 
@@ -69,16 +69,12 @@
 import androidx.test.filters.LargeTest;
 
 import com.android.net.module.util.ArrayTrackRecord;
-import com.android.testutils.DevSdkIgnoreRule;
-import com.android.testutils.DevSdkIgnoreRule.IgnoreUpTo;
-import com.android.testutils.DevSdkIgnoreRunner;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
-import org.junit.runner.RunWith;
 
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -99,8 +95,7 @@
 
 /** CTS tests for {@link ThreadNetworkController}. */
 @LargeTest
-@RunWith(DevSdkIgnoreRunner.class)
-@IgnoreUpTo(Build.VERSION_CODES.TIRAMISU) // Thread is available on only U+
+@RequiresThreadFeature
 public class ThreadNetworkControllerTest {
     private static final int JOIN_TIMEOUT_MILLIS = 30 * 1000;
     private static final int LEAVE_TIMEOUT_MILLIS = 2_000;
@@ -113,7 +108,7 @@
     private static final String THREAD_NETWORK_PRIVILEGED =
             "android.permission.THREAD_NETWORK_PRIVILEGED";
 
-    @Rule public DevSdkIgnoreRule mIgnoreRule = new DevSdkIgnoreRule();
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
@@ -126,14 +121,10 @@
 
     @Before
     public void setUp() throws Exception {
-        ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // TODO: we will also need it in tearDown(), it's better to have a Rule to skip
-        // tests if a feature is not available.
-        assumeNotNull(mController);
+        mController =
+                mContext.getSystemService(ThreadNetworkManager.class)
+                        .getAllThreadNetworkControllers()
+                        .get(0);
 
         mGrantedPermissions = new HashSet<String>();
         mExecutor = Executors.newSingleThreadExecutor();
@@ -146,9 +137,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
         dropAllPermissions();
         leaveAndWait(mController);
         tearDownTestNetwork();
diff --git a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
index 88ee47e..b40685c 100644
--- a/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
+++ b/thread/tests/integration/src/android/net/thread/BorderRoutingTest.java
@@ -24,8 +24,6 @@
 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.isMulticastRoutingSupported;
-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.pollForPacket;
@@ -44,8 +42,6 @@
 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;
 
@@ -56,6 +52,10 @@
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.InfraNetworkDevice;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresIpv6MulticastRouting;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.os.Handler;
 import android.os.HandlerThread;
 
@@ -68,6 +68,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -82,19 +83,11 @@
 
 /** Integration test cases for Thread Border Routing feature. */
 @RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
 @LargeTest
 public class BorderRoutingTest {
     private static final String TAG = BorderRoutingTest.class.getSimpleName();
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private OtDaemonController mOtCtl;
-    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 Inet6Address GROUP_ADDR_SCOPE_5 =
             (Inet6Address) InetAddresses.parseNumericAddress("ff05::1234");
@@ -114,17 +107,25 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private ThreadNetworkController mController;
+    private OtDaemonController mOtCtl;
+    private HandlerThread mHandlerThread;
+    private Handler mHandler;
+    private TestNetworkTracker mInfraNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+    private TapPacketReader mInfraNetworkReader;
+    private InfraNetworkDevice mInfraDevice;
+
     @Before
     public void setUp() throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
         final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
         if (manager != null) {
             mController = manager.getAllThreadNetworkControllers().get(0);
         }
 
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
-
         // TODO: b/323301831 - This is a workaround to avoid unnecessary delay to re-form a network
         mOtCtl = new OtDaemonController();
         mOtCtl.factoryReset();
@@ -151,10 +152,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
         runAsShell(
                 PERMISSION_THREAD_NETWORK_PRIVILEGED,
                 NETWORK_SETTINGS,
@@ -225,8 +222,6 @@
     @Test
     public void unicastRouting_borderRouterSendsUdpToThreadDevice_datagramReceived()
             throws Exception {
-        assumeTrue(isSimulatedThreadRadioSupported());
-
         /*
          * <pre>
          * Topology:
@@ -260,9 +255,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -281,10 +276,10 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void
             multicastRouting_ftdSubscribedScope3MulticastAddress_infraLinkNotJoinMulticastGroup()
                     throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -303,9 +298,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_canPingfromInfraLink()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -325,9 +320,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_inboundForwarding_afterBrRejoinFtdRepliesSubscribedAddress()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
 
         // TODO (b/327311034): Testing bbr state switch from primary mode to secondary mode and back
         // to primary mode requires an additional BR in the Thread network. This is not currently
@@ -335,9 +330,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedScope3MulticastAddress_cannotPingfromInfraLink()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -357,9 +352,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdNotSubscribedMulticastAddress_cannotPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -378,9 +373,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedDifferentAddresses_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -414,9 +409,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedSameAddress_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -449,8 +444,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeLargerThan3IsForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -474,9 +469,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeSmallerThan4IsNotForwarded()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -497,8 +492,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_llaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -520,8 +515,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_mlaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -546,9 +541,9 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_ftdRepliesToSubscribedAddress()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
@@ -575,8 +570,8 @@
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_outboundPacketIsForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * <pre>
          * Topology:
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 3493d9f..4e6ee5f 100644
--- a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
+++ b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
@@ -24,7 +24,6 @@
 import static android.net.thread.utils.IntegrationTestUtils.SERVICE_DISCOVERY_TIMEOUT;
 import static android.net.thread.utils.IntegrationTestUtils.discoverForServiceLost;
 import static android.net.thread.utils.IntegrationTestUtils.discoverService;
-import static android.net.thread.utils.IntegrationTestUtils.isSimulatedThreadRadioSupported;
 import static android.net.thread.utils.IntegrationTestUtils.resolveService;
 import static android.net.thread.utils.IntegrationTestUtils.resolveServiceUntil;
 import static android.net.thread.utils.IntegrationTestUtils.waitFor;
@@ -36,8 +35,6 @@
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import static org.junit.Assert.assertThrows;
-import static org.junit.Assume.assumeNotNull;
-import static org.junit.Assume.assumeTrue;
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -47,6 +44,9 @@
 import android.net.nsd.NsdServiceInfo;
 import android.net.thread.utils.FullThreadDevice;
 import android.net.thread.utils.TapTestNetworkTracker;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresSimulationThreadDevice;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.os.HandlerThread;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -58,6 +58,7 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -73,18 +74,13 @@
 
 /** Integration test cases for Service Discovery feature. */
 @RunWith(AndroidJUnit4.class)
+@RequiresThreadFeature
+@RequiresSimulationThreadDevice
 @LargeTest
 @Ignore("TODO: b/328527773 - enable the test when it's stable")
 public class ServiceDiscoveryTest {
     private static final String TAG = ServiceDiscoveryTest.class.getSimpleName();
     private static final int NUM_FTD = 3;
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-
-    private HandlerThread mHandlerThread;
-    private ThreadNetworkController mController;
-    private NsdManager mNsdManager;
-    private TapTestNetworkTracker mTestNetworkTracker;
-    private List<FullThreadDevice> mFtds;
 
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
@@ -100,6 +96,16 @@
     private static final Correspondence<byte[], byte[]> BYTE_ARRAY_EQUALITY =
             Correspondence.from(Arrays::equals, "is equivalent to");
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+
+    private HandlerThread mHandlerThread;
+    private ThreadNetworkController mController;
+    private NsdManager mNsdManager;
+    private TapTestNetworkTracker mTestNetworkTracker;
+    private List<FullThreadDevice> mFtds;
+
     @Before
     public void setUp() throws Exception {
         final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
@@ -107,12 +113,6 @@
             mController = manager.getAllThreadNetworkControllers().get(0);
         }
 
-        // Run the tests on only devices where the Thread feature is available.
-        assumeNotNull(mController);
-
-        // Run the tests only when the device uses simulated Thread radio.
-        assumeTrue(isSimulatedThreadRadioSupported());
-
         // BR forms a network.
         CompletableFuture<Void> joinFuture = new CompletableFuture<>();
         runAsShell(
@@ -150,12 +150,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-        if (!isSimulatedThreadRadioSupported()) {
-            return;
-        }
         for (FullThreadDevice ftd : mFtds) {
             // Clear registered SRP hosts and services
             if (ftd.isSrpHostRegistered()) {
diff --git a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
index 580a83a..9585d7d 100644
--- a/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
+++ b/thread/tests/integration/src/android/net/thread/ThreadIntegrationTest.java
@@ -33,14 +33,14 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
-import static org.junit.Assume.assumeNotNull;
-
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import android.annotation.Nullable;
 import android.content.Context;
 import android.net.thread.ThreadNetworkController.StateCallback;
 import android.net.thread.utils.OtDaemonController;
+import android.net.thread.utils.ThreadFeatureCheckerRule;
+import android.net.thread.utils.ThreadFeatureCheckerRule.RequiresThreadFeature;
 import android.os.SystemClock;
 
 import androidx.test.core.app.ApplicationProvider;
@@ -49,6 +49,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
@@ -58,12 +59,9 @@
 
 /** Tests for E2E Android Thread integration with ot-daemon, ConnectivityService, etc.. */
 @LargeTest
+@RequiresThreadFeature
 @RunWith(AndroidJUnit4.class)
 public class ThreadIntegrationTest {
-    private final Context mContext = ApplicationProvider.getApplicationContext();
-    private ThreadNetworkController mController;
-    private OtDaemonController mOtCtl;
-
     // A valid Thread Active Operational Dataset generated from OpenThread CLI "dataset init new".
     private static final byte[] DEFAULT_DATASET_TLVS =
             base16().decode(
@@ -75,16 +73,18 @@
     private static final ActiveOperationalDataset DEFAULT_DATASET =
             ActiveOperationalDataset.fromThreadTlvs(DEFAULT_DATASET_TLVS);
 
+    @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
+
+    private final Context mContext = ApplicationProvider.getApplicationContext();
+    private ThreadNetworkController mController;
+    private OtDaemonController mOtCtl;
+
     @Before
     public void setUp() throws Exception {
-        final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
-        if (manager != null) {
-            mController = manager.getAllThreadNetworkControllers().get(0);
-        }
-
-        // Run the tests on only devices where the Thread feature is available
-        assumeNotNull(mController);
-
+        mController =
+                mContext.getSystemService(ThreadNetworkManager.class)
+                        .getAllThreadNetworkControllers()
+                        .get(0);
         mOtCtl = new OtDaemonController();
         leaveAndWait(mController);
 
@@ -94,10 +94,6 @@
 
     @After
     public void tearDown() throws Exception {
-        if (mController == null) {
-            return;
-        }
-
         setTestUpStreamNetworkAndWait(mController, null);
         leaveAndWait(mController);
     }
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 bb2d973..2237e65 100644
--- a/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
+++ b/thread/tests/integration/src/android/net/thread/utils/IntegrationTestUtils.java
@@ -20,7 +20,6 @@
 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;
-import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
@@ -32,8 +31,6 @@
 import android.net.thread.ThreadNetworkController;
 import android.os.Handler;
 import android.os.SystemClock;
-import android.os.SystemProperties;
-import android.os.VintfRuntimeInfo;
 
 import androidx.annotation.NonNull;
 
@@ -65,8 +62,6 @@
 import java.util.concurrent.TimeoutException;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 /** Static utility methods relating to Thread integration tests. */
 public final class IntegrationTestUtils {
@@ -79,35 +74,8 @@
     public static final Duration CALLBACK_TIMEOUT = Duration.ofSeconds(1);
     public static final Duration SERVICE_DISCOVERY_TIMEOUT = Duration.ofSeconds(20);
 
-    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
-    private static final int KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED = 14;
-
     private IntegrationTestUtils() {}
 
-    /** Returns whether the device supports simulated Thread radio. */
-    public static boolean isSimulatedThreadRadioSupported() {
-        // The integration test uses SIMULATION Thread radio so that it only supports CuttleFish.
-        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
-    }
-
-    public static boolean isMulticastRoutingSupported() {
-        return isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED)
-                && isKernelAndroidVersionAtLeast(
-                        KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED);
-    }
-
-    private static boolean isKernelAndroidVersionAtLeast(int n) {
-        final String osRelease = VintfRuntimeInfo.getOsRelease();
-        final Pattern pattern = Pattern.compile("android(\\d+)");
-        Matcher matcher = pattern.matcher(osRelease);
-
-        if (matcher.find()) {
-            int version = Integer.parseInt(matcher.group(1));
-            return (version >= n);
-        }
-        return false;
-    }
-
     /**
      * Waits for the given {@link Supplier} to be true until given timeout.
      *
diff --git a/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
new file mode 100644
index 0000000..bee9ceb
--- /dev/null
+++ b/thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java
@@ -0,0 +1,160 @@
+/*
+ * 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.thread.utils;
+
+import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
+
+import static org.junit.Assume.assumeTrue;
+
+import android.content.Context;
+import android.net.thread.ThreadNetworkManager;
+import android.os.SystemProperties;
+import android.os.VintfRuntimeInfo;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A rule used to skip Thread tests when the device doesn't support a specific feature indicated by
+ * {@code ThreadFeatureCheckerRule.Requires*}.
+ */
+public final class ThreadFeatureCheckerRule implements TestRule {
+    private static final String KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED = "5.15.0";
+    private static final int KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED = 14;
+
+    /**
+     * Annotates a test class or method requires the Thread feature to run.
+     *
+     * <p>In Absence of the Thread feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresThreadFeature {}
+
+    /**
+     * Annotates a test class or method requires the kernel IPv6 multicast routing feature to run.
+     *
+     * <p>In Absence of the multicast routing feature, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresIpv6MulticastRouting {}
+
+    /**
+     * Annotates a test class or method requires the simulation Thread device (i.e. ot-cli-ftd) to
+     * run.
+     *
+     * <p>In Absence of the simulation device, the test class or method will be ignored.
+     */
+    @Retention(RetentionPolicy.RUNTIME)
+    @Target({ElementType.METHOD, ElementType.TYPE})
+    public @interface RequiresSimulationThreadDevice {}
+
+    @Override
+    public Statement apply(final Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                if (hasAnnotation(RequiresThreadFeature.class, description)) {
+                    assumeTrue(
+                            "Skipping test because the Thread feature is unavailable",
+                            hasThreadFeature());
+                }
+
+                if (hasAnnotation(RequiresIpv6MulticastRouting.class, description)) {
+                    assumeTrue(
+                            "Skipping test because kernel IPv6 multicast routing is unavailable",
+                            hasIpv6MulticastRouting());
+                }
+
+                if (hasAnnotation(RequiresSimulationThreadDevice.class, description)) {
+                    assumeTrue(
+                            "Skipping test because simulation Thread device is unavailable",
+                            hasSimulationThreadDevice());
+                }
+
+                base.evaluate();
+            }
+        };
+    }
+
+    /** Returns {@code true} if a test method or the test class is annotated with annotation. */
+    private <T extends Annotation> boolean hasAnnotation(
+            Class<T> annotationClass, Description description) {
+        // Method annotation
+        boolean hasAnnotation = description.getAnnotation(annotationClass) != null;
+
+        // Class annotation
+        Class<?> clazz = description.getTestClass();
+        while (!hasAnnotation && clazz != Object.class) {
+            hasAnnotation |= clazz.getAnnotation(annotationClass) != null;
+            clazz = clazz.getSuperclass();
+        }
+
+        return hasAnnotation;
+    }
+
+    /** Returns {@code true} if this device has the Thread feature supported. */
+    private static boolean hasThreadFeature() {
+        final Context context = ApplicationProvider.getApplicationContext();
+        return context.getSystemService(ThreadNetworkManager.class) != null;
+    }
+
+    /**
+     * Returns {@code true} if this device has the kernel IPv6 multicast routing feature enabled.
+     */
+    private static boolean hasIpv6MulticastRouting() {
+        // The kernel IPv6 multicast routing (i.e. IPV6_MROUTE) is enabled on kernel version
+        // android14-5.15.0 and later
+        return isKernelVersionAtLeast(KERNEL_VERSION_MULTICAST_ROUTING_SUPPORTED)
+                && isKernelAndroidVersionAtLeast(
+                        KERNEL_ANDROID_VERSION_MULTICAST_ROUTING_SUPPORTED);
+    }
+
+    /**
+     * Returns {@code true} if the android version in the kernel version of this device is equal to
+     * or larger than the given {@code minVersion}.
+     */
+    private static boolean isKernelAndroidVersionAtLeast(int minVersion) {
+        final String osRelease = VintfRuntimeInfo.getOsRelease();
+        final Pattern pattern = Pattern.compile("android(\\d+)");
+        Matcher matcher = pattern.matcher(osRelease);
+
+        if (matcher.find()) {
+            int version = Integer.parseInt(matcher.group(1));
+            return (version >= minVersion);
+        }
+        return false;
+    }
+
+    /** Returns {@code true} if the simulation Thread device is supported. */
+    private static boolean hasSimulationThreadDevice() {
+        // Simulation radio is supported on only Cuttlefish
+        return SystemProperties.get("ro.product.model").startsWith("Cuttlefish");
+    }
+}