From f1182f105732eae5533c7e7e1b34c497b2939bdc Mon Sep 17 00:00:00 2001 From: Kangping Dong Date: Sat, 9 Mar 2024 18:43:39 +0800 Subject: [PATCH] [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 --- .../net/thread/ActiveOperationalDataset.java | 21 +++ .../thread/ThreadPersistentSettings.java | 3 + .../cts/ThreadNetworkControllerTest.java | 28 +-- .../android/net/thread/BorderRoutingTest.java | 71 ++++---- .../net/thread/ServiceDiscoveryTest.java | 38 ++--- .../net/thread/ThreadIntegrationTest.java | 32 ++-- .../thread/utils/IntegrationTestUtils.java | 32 ---- .../utils/ThreadFeatureCheckerRule.java | 160 ++++++++++++++++++ 8 files changed, 255 insertions(+), 130 deletions(-) create mode 100644 thread/tests/utils/src/android/net/thread/utils/ThreadFeatureCheckerRule.java diff --git a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java index b74a15a8ce..4a7d3a79f9 100644 --- a/thread/framework/java/android/net/thread/ActiveOperationalDataset.java +++ b/thread/framework/java/android/net/thread/ActiveOperationalDataset.java @@ -74,42 +74,61 @@ import java.util.Arrays; 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 final class ActiveOperationalDataset implements Parcelable { 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 aba4193da0..0277528d59 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 @@ import java.io.InputStream; */ 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 36ce4d5dae..48e0c26b49 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 com.google.common.util.concurrent.MoreExecutors.directExecutor; 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.ThreadNetworkController.StateCallback; 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.core.app.ApplicationProvider; 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 @@ import java.util.function.Predicate; /** 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 @@ public class ThreadNetworkControllerTest { 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 @@ public class ThreadNetworkControllerTest { @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(); mExecutor = Executors.newSingleThreadExecutor(); @@ -146,9 +137,6 @@ public class ThreadNetworkControllerTest { @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 88ee47e5bb..b40685cda0 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.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.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.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; @@ -56,6 +52,10 @@ import android.net.MacAddress; 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 com.android.testutils.TestNetworkTracker; 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 @@ import java.util.function.Predicate; /** 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 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 @@ public class BorderRoutingTest { 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 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 @@ public class BorderRoutingTest { @After public void tearDown() throws Exception { - if (mController == null) { - return; - } - runAsShell( PERMISSION_THREAD_NETWORK_PRIVILEGED, NETWORK_SETTINGS, @@ -225,8 +222,6 @@ public class BorderRoutingTest { @Test public void unicastRouting_borderRouterSendsUdpToThreadDevice_datagramReceived() throws Exception { - assumeTrue(isSimulatedThreadRadioSupported()); - /* *
          * Topology:
@@ -260,9 +255,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_infraLinkJoinsMulticastGroup()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -281,10 +276,10 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void
             multicastRouting_ftdSubscribedScope3MulticastAddress_infraLinkNotJoinMulticastGroup()
                     throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -303,9 +298,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedMulticastAddress_canPingfromInfraLink()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -325,9 +320,9 @@ public class BorderRoutingTest {
     }
 
     @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 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdSubscribedScope3MulticastAddress_cannotPingfromInfraLink()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -357,9 +352,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_ftdNotSubscribedMulticastAddress_cannotPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -378,9 +373,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedDifferentAddresses_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -414,9 +409,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_multipleFtdsSubscribedSameAddress_canPingFromInfraDevice()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -449,8 +444,8 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeLargerThan3IsForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -474,9 +469,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_scopeSmallerThan4IsNotForwarded()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -497,8 +492,8 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_llaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -520,8 +515,8 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_outboundForwarding_mlaToScope4IsNotForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -546,9 +541,9 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_ftdRepliesToSubscribedAddress()
             throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
@@ -575,8 +570,8 @@ public class BorderRoutingTest {
     }
 
     @Test
+    @RequiresIpv6MulticastRouting
     public void multicastRouting_infraNetworkSwitch_outboundPacketIsForwarded() throws Exception {
-        assumeTrue(isMulticastRoutingSupported());
         /*
          * 
          * Topology:
diff --git a/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java b/thread/tests/integration/src/android/net/thread/ServiceDiscoveryTest.java
index 3493d9f199..4e6ee5f777 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.RESTART_JOIN_TIMEOUT
 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.truth.Truth.assertThat;
 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.NsdManager;
 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 com.google.common.truth.Correspondence;
 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 @@ import java.util.concurrent.TimeoutException;
 
 /** 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 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 @@ public class ServiceDiscoveryTest {
     private static final Correspondence 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 mFtds;
+
     @Before
     public void setUp() throws Exception {
         final ThreadNetworkManager manager = mContext.getSystemService(ThreadNetworkManager.class);
@@ -107,12 +113,6 @@ public class ServiceDiscoveryTest {
             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 joinFuture = new CompletableFuture<>();
         runAsShell(
@@ -150,12 +150,6 @@ public class ServiceDiscoveryTest {
 
     @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 580a83ac64..9585d7d46d 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.io.BaseEncoding.base16;
 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 androidx.test.runner.AndroidJUnit4;
 
 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 @@ import java.util.concurrent.CompletableFuture;
 
 /** 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 @@ public class ThreadIntegrationTest {
     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 @@ public class ThreadIntegrationTest {
 
     @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 bb2d973550..2237e651b2 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 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;
-import static com.android.testutils.DeviceInfoUtils.isKernelVersionAtLeast;
 
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
@@ -32,8 +31,6 @@ import android.net.nsd.NsdServiceInfo;
 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.TimeUnit;
 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 final class IntegrationTestUtils {
     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 0000000000..bee9ceba01
--- /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.
+     *
+     * 

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. + * + *

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. + * + *

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 boolean hasAnnotation( + Class 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"); + } +}