Merge "Enable runtime test-level rule injection in microbenchmarks." into sc-dev
diff --git a/libraries/compatibility-common-util/Android.bp b/libraries/compatibility-common-util/Android.bp
index bc40510..714a681 100644
--- a/libraries/compatibility-common-util/Android.bp
+++ b/libraries/compatibility-common-util/Android.bp
@@ -26,6 +26,7 @@
     static_libs: [
         "guava",
         "junit",
+        "platform-test-annotations",
     ],
 }
 
diff --git a/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicExecutor.java b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicExecutor.java
index b137801..8905fce 100644
--- a/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicExecutor.java
+++ b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicExecutor.java
@@ -140,10 +140,14 @@
         }
         String className = method.substring(0, index);
         Class cls = Class.forName(className);
-        Object obj = cls.getDeclaredConstructor().newInstance();
+        Object obj = null;
         if (getTestObject() != null && cls.isAssignableFrom(getTestObject().getClass())) {
             // The given method is a member of the test class, use the known test class instance
             obj = getTestObject();
+        } else {
+            // Only instantiate a new object if we don't already have one.
+            // Otherwise the class could have been an interface which isn't instantiatable.
+            obj = cls.getDeclaredConstructor().newInstance();
         }
         ResolvedMethod rm = getResolvedMethod(cls, method.substring(index + 1), args);
         return rm.invoke(obj);
diff --git a/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicMapStore.java b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicMapStore.java
new file mode 100644
index 0000000..d208d39
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/BusinessLogicMapStore.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2021 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 com.android.compatibility.common.util;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Business-Logic GCL-accessible utility for key-value stores. */
+public class BusinessLogicMapStore {
+
+    private static Map<String, Map<String, String>> maps = new HashMap<>();
+
+    public boolean hasMap(String mapName) {
+        return maps.containsKey(mapName);
+    }
+
+    public void putMap(String mapName, String separator, String... keyValuePairs) {
+        Map<String, String> map = maps.get(mapName);
+        if (map == null) {
+            map = new HashMap<>();
+            maps.put(mapName, map);
+        }
+
+        for (String keyValuePair : keyValuePairs) {
+            String[] tmp = keyValuePair.split(separator, 2);
+            if (tmp.length != 2) {
+                throw new IllegalArgumentException(
+                        "Can't split key-value pair for \"" + keyValuePair + "\"");
+            }
+            String key = tmp[0];
+            String value = tmp[1];
+            map.put(key, value);
+        }
+    }
+
+    public static Map<String, String> getMap(String mapName) {
+        Map<String, String> map = maps.get(mapName);
+        if (map == null) {
+            return null;
+        }
+        return Collections.unmodifiableMap(map);
+    }
+}
diff --git a/libraries/compatibility-common-util/src/com/android/compatibility/common/util/DescriptionProvider.java b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/DescriptionProvider.java
new file mode 100644
index 0000000..2c58fd7
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/DescriptionProvider.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2021 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 com.android.compatibility.common.util;
+
+import org.junit.rules.TestWatcher;
+import org.junit.runner.Description;
+
+/** Provide a way for tests to get their description while running. */
+public class DescriptionProvider extends TestWatcher {
+    private volatile Description description;
+
+    @Override
+    protected void starting(Description description) {
+        this.description = description;
+    }
+
+    public Description getDescription() {
+        return description;
+    }
+}
diff --git a/libraries/compatibility-common-util/src/com/android/compatibility/common/util/MultiLog.java b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/MultiLog.java
new file mode 100644
index 0000000..3a63bd4
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/compatibility/common/util/MultiLog.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2021 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 com.android.compatibility.common.util;
+
+/** Provide an interface for logging on host+device-common code. */
+public interface MultiLog {
+
+    /**
+     * Log information with whichever logging mechanism is available to the instance. This varies
+     * from host-side to device-side, so implementations are left to subclasses.
+     * Host: MultiLogHost
+     * Device: MultiLogDevice
+     * See {@link String.format(String, Object...)} for parameter information.
+     */
+    public void logInfo(String logTag, String format, Object... args);
+
+    /**
+     * Log debugging information to the host or device logs (depending on implementation).
+     * See {@link String.format(String, Object...)} for parameter information.
+     * Host: MultiLogHost
+     * Device: MultiLogDevice
+     */
+    public void logDebug(String logTag, String format, Object... args);
+
+    /**
+     * Log warnings to the host or device logs (depending on implementation).
+     * See {@link String.format(String, Object...)} for parameter information.
+     * Host: MultiLogHost
+     * Device: MultiLogDevice
+     */
+    public void logWarn(String logTag, String format, Object... args);
+
+    /**
+     * Log errors to the host or device logs (depending on implementation).
+     * See {@link String.format(String, Object...)} for parameter information.
+     * Host: MultiLogHost
+     * Device: MultiLogDevice
+     */
+    public void logError(String logTag, String format, Object... args);
+}
diff --git a/libraries/compatibility-common-util/src/com/android/sts/OWNERS b/libraries/compatibility-common-util/src/com/android/sts/OWNERS
new file mode 100644
index 0000000..d029d20
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/sts/OWNERS
@@ -0,0 +1,2 @@
+# STS Owners
+cdombroski@google.com
diff --git a/libraries/compatibility-common-util/src/com/android/sts/common/util/SplUtils.java b/libraries/compatibility-common-util/src/com/android/sts/common/util/SplUtils.java
new file mode 100644
index 0000000..4144fff
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/sts/common/util/SplUtils.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2021 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 com.android.sts.common.util;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+/** Tools for Security Patch Levels and LocalDates representing them. */
+public final class SplUtils {
+    private static final ZoneId UTC_ZONE_ID = ZoneId.of("UTC");
+    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    public static LocalDate localDateFromMillis(long millis) {
+        return Instant.ofEpochMilli(millis).atZone(UTC_ZONE_ID).toLocalDate();
+    }
+
+    public static LocalDate localDateFromSplString(String spl) {
+        return LocalDate.parse(spl, formatter);
+    }
+
+    public static String format(LocalDate date) {
+        return date.format(formatter);
+    }
+}
diff --git a/libraries/compatibility-common-util/src/com/android/sts/common/util/StsLogic.java b/libraries/compatibility-common-util/src/com/android/sts/common/util/StsLogic.java
new file mode 100644
index 0000000..1dec855
--- /dev/null
+++ b/libraries/compatibility-common-util/src/com/android/sts/common/util/StsLogic.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2021 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 com.android.sts.common.util;
+
+import static org.junit.Assume.*;
+
+import android.platform.test.annotations.AsbSecurityTest;
+
+import com.android.compatibility.common.util.BusinessLogicMapStore;
+import com.android.compatibility.common.util.MultiLog;
+
+import org.junit.runner.Description;
+
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/** Common STS extra business logic for host-side and device-side to implement. */
+public interface StsLogic extends MultiLog {
+
+    static final String LOG_TAG = StsLogic.class.getSimpleName();
+
+    // keep in sync with google3:
+    // //wireless/android/partner/apbs/*/config/xtsbgusinesslogic/sts_business_logic.gcl
+    List<String> STS_EXTRA_BUSINESS_LOGIC_FULL = Arrays.asList(new String[]{
+        "uploadSpl",
+        "uploadModificationTime",
+    });
+    List<String> STS_EXTRA_BUSINESS_LOGIC_INCREMENTAL = Arrays.asList(new String[]{
+        "uploadSpl",
+        "uploadModificationTime",
+        "incremental",
+    });
+
+    Description getTestDescription();
+
+    LocalDate getDeviceSpl();
+
+    default long[] getCveBugIds() {
+        AsbSecurityTest annotation = getTestDescription().getAnnotation(AsbSecurityTest.class);
+        if (annotation == null) {
+            return null;
+        }
+        return annotation.cveBugId();
+    }
+
+    default LocalDate getMinTestSpl() {
+        Map<String, String> map = BusinessLogicMapStore.getMap("security_bulletins");
+        if (map == null) {
+            throw new IllegalArgumentException("Could not find the security bulletin map");
+        }
+        LocalDate minSpl = null;
+        for (long cveBugId : getCveBugIds()) {
+            String splString = map.get(Long.toString(cveBugId));
+            if (splString == null) {
+                // This bug id wasn't found in the map.
+                // This is a new test or the bug was removed from the bulletin and this is an old
+                // binary. Neither is a critical issue and the test will run in these cases.
+                // New test: developer should be able to write the test without getting blocked.
+                // Removed bug + old binary: test will run.
+                logInfo(LOG_TAG, "could not find the CVE bug %d in the spl map", cveBugId);
+                continue;
+            }
+            LocalDate spl = SplUtils.localDateFromSplString(splString);
+            if (minSpl == null) {
+                minSpl = spl;
+            } else if (spl.isBefore(minSpl)) {
+                minSpl = spl;
+            }
+        }
+        return minSpl;
+    }
+
+    default LocalDate getMinModificationDate() {
+        Map<String, String> map = BusinessLogicMapStore.getMap("sts_modification_times");
+        if (map == null) {
+            throw new IllegalArgumentException("Could not find the modification date map");
+        }
+        LocalDate minModificationDate = null;
+        for (long cveBugId : getCveBugIds()) {
+            String modificationMillisString = map.get(Long.toString(cveBugId));
+            if (modificationMillisString == null) {
+                logInfo(LOG_TAG,
+                        "Could not find the CVE bug %d in the modification date map", cveBugId);
+                continue;
+            }
+            LocalDate modificationDate =
+                    SplUtils.localDateFromMillis(Long.parseLong(modificationMillisString));
+            if (minModificationDate == null) {
+                minModificationDate = modificationDate;
+            } else if (modificationDate.isBefore(minModificationDate)) {
+                minModificationDate = modificationDate;
+            }
+        }
+        return minModificationDate;
+    }
+
+    default boolean shouldSkipIncremental() {
+        logDebug(LOG_TAG, "filtering by incremental");
+
+        long[] bugIds = getCveBugIds();
+        if (bugIds == null) {
+            // There were no @AsbSecurityTest annotations
+            logInfo(LOG_TAG, "not an ASB test");
+            return false;
+        }
+
+        // check if test spl is older than the past 6 months from the device spl
+        LocalDate deviceSpl = getDeviceSpl();
+        LocalDate incrementalCutoffSpl = deviceSpl.plusMonths(-6);
+
+        LocalDate minTestModifiedDate = getMinModificationDate();
+        if (minTestModifiedDate == null) {
+            // could not get the modification date - run the test
+            if (Arrays.stream(bugIds).min().getAsLong() < 157905780) {
+                // skip if the bug id is older than ~ June 2020
+                // otherwise the test will run due to missing data
+                logDebug(LOG_TAG, "no data for this old test");
+                return true;
+            }
+          return false;
+        }
+        if (minTestModifiedDate.isAfter(incrementalCutoffSpl)) {
+            logDebug(LOG_TAG, "the test was recently modified");
+            return false;
+        }
+
+        LocalDate minTestSpl = getMinTestSpl();
+        if (minTestSpl == null) {
+            // could not get the test spl - run the test
+            logWarn(LOG_TAG, "could not get the test SPL");
+            return false;
+        }
+        if (minTestSpl.isAfter(incrementalCutoffSpl)) {
+            logDebug(LOG_TAG, "the test has a recent SPL");
+            return false;
+        }
+
+        logDebug(LOG_TAG, "test should skip");
+        return true;
+    }
+
+    default boolean shouldSkipSpl() {
+        return true;
+    }
+
+    default void skip(String message) {
+        assumeTrue(message, false);
+    }
+}
diff --git a/libraries/health/rules/src/android/platform/test/rule/DynamicRuleChain.java b/libraries/health/rules/src/android/platform/test/rule/DynamicRuleChain.java
new file mode 100644
index 0000000..44733e4
--- /dev/null
+++ b/libraries/health/rules/src/android/platform/test/rule/DynamicRuleChain.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2021 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.platform.test.rule;
+
+import android.os.Bundle;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.test.InstrumentationRegistry;
+
+import org.junit.rules.TestRule;
+import org.junit.runners.model.Statement;
+import org.junit.runner.Description;
+
+import java.lang.reflect.Constructor;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A rule that loads other ({@link TestRule}s) at runtime.
+ *
+ * <p>Rules are supplied as a comma-separated list. If the rule is in the {@code
+ * android.platform.test.rule} package (and not in its sub-packages), it can be passed via their
+ * simple class name; otherwise, its fully-qualified class name must be used.
+ *
+ * <p>Each passed-in rule wraps around the subsequent rule; this fits the mental model of JUnit's
+ * {@code RuleChain}. For example, if {@code "rule1,rule2,rule3"} are passed in, {@code rule1} will
+ * wrap around {@code rule2} and {@code rule2} will wrap around {@code rule3}; {@code rule3} will be
+ * applied closest to the test method.
+ */
+public class DynamicRuleChain implements TestRule {
+    private static final String LOG_TAG = DynamicRuleChain.class.getSimpleName();
+
+    @VisibleForTesting static final String DEFAULT_RULES_OPTION = "dynamic-rules";
+
+    @VisibleForTesting static final String RULES_PACKAGE = "android.platform.test.rule";
+
+    private String mRulesOptionName = DEFAULT_RULES_OPTION;
+    private Bundle mArgs;
+
+    public DynamicRuleChain() {
+        this(InstrumentationRegistry.getArguments());
+    }
+
+    public DynamicRuleChain(Bundle args) {
+        mArgs = args;
+    }
+
+    public DynamicRuleChain(String rulesOptionName, Bundle args) {
+        this(args);
+        if (rulesOptionName == null || rulesOptionName.isEmpty()) {
+            throw new IllegalArgumentException(
+                    "Rules option name override must not be null or empty.");
+        }
+        mRulesOptionName = rulesOptionName;
+    }
+
+    @Override
+    public Statement apply(final Statement base, final Description description) {
+        List<String> ruleNames = Arrays.asList(mArgs.getString(mRulesOptionName, "").split(","));
+        // The inner rules need to be applied first, so reverse the class names first.
+        Collections.reverse(ruleNames);
+        // Instantiate rules and apply them one-by-one.
+        // JUnit's RunRules is not used here because its ordering of rules is not clearly defined.
+        Statement statement = base;
+        for (String ruleName : ruleNames) {
+            if (ruleName.isEmpty()) {
+                continue;
+            }
+            TestRule rule = null;
+            // We could use a regex here, but this is simpler and should work just as well.
+            if (ruleName.contains(".")) {
+                Log.i(
+                        LOG_TAG,
+                        String.format(
+                                "Attempting to dynamically load rule with fully qualified name %s.",
+                                ruleName));
+                try {
+                    rule = loadRuleByFullyQualifiedName(ruleName);
+                } catch (Exception e) {
+                    throw new IllegalArgumentException(
+                            String.format(
+                                    "Failed to dynamically load rule with fully qualified name %s.",
+                                    ruleName),
+                            e);
+                }
+            } else {
+                String fullName = String.format("%s.%s", RULES_PACKAGE, ruleName);
+                Log.i(
+                        LOG_TAG,
+                        String.format(
+                                "Attempting to dynamically load rule with simple class name %s"
+                                        + " (fully qualified name: %s).",
+                                ruleName, fullName));
+                try {
+                    rule = loadRuleByFullyQualifiedName(fullName);
+                } catch (Exception e) {
+                    throw new IllegalArgumentException(
+                            String.format(
+                                    "Failed to dynamically load rule with simple class name %s.",
+                                    ruleName),
+                            e);
+                }
+            }
+            statement = rule.apply(statement, description);
+        }
+        return statement;
+    }
+
+    private TestRule loadRuleByFullyQualifiedName(String name) throws Exception {
+        // Load the rule class using reflection.
+        Class<?> loadedClass = null;
+        try {
+            loadedClass = DynamicRuleChain.class.getClassLoader().loadClass(name);
+        } catch (ClassNotFoundException e) {
+            throw new IllegalArgumentException(
+                    String.format("Could not find class with fully qualified name %s.", name));
+        }
+        // Ensure that the class found is a TestRule.
+        if (loadedClass == null || (!TestRule.class.isAssignableFrom(loadedClass))) {
+            throw new IllegalArgumentException(
+                    String.format("Class %s is not a TestRule.", loadedClass));
+        }
+
+        // Use the default constructor to create a rule instance.
+        try {
+            Constructor<?> constructor = loadedClass.getConstructor();
+            // Cast is safe as we have vetted that loadedClass is a TestRule.
+            return (TestRule) constructor.newInstance();
+        } catch (NoSuchMethodException e) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Rule %s cannot be instantiated with an empty constructor",
+                            loadedClass),
+                    e);
+        }
+    }
+}
diff --git a/libraries/health/rules/tests/src/android/platform/test/rule/DynamicRuleChainTest.java b/libraries/health/rules/tests/src/android/platform/test/rule/DynamicRuleChainTest.java
new file mode 100644
index 0000000..a866451
--- /dev/null
+++ b/libraries/health/rules/tests/src/android/platform/test/rule/DynamicRuleChainTest.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2021 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.platform.test.rule;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.junit.runners.model.Statement;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/** Unit tests for {@link DynamicRuleChain}. */
+@RunWith(JUnit4.class)
+public class DynamicRuleChainTest {
+    private static final Description DESCRIPTION =
+            Description.createTestDescription("class", "method");
+
+    // A static variable is used so that the rules in this test can modify its state while being
+    // static (and thus able to be created via an empty constructor). This route is taken as the
+    // rules themselves are not directly observable due to being created via reflection from
+    // DynamicRuleChain.
+    private static final List<String> sLogs = new ArrayList<String>();
+
+    private static final Statement mStatement =
+            new Statement() {
+                @Override
+                public void evaluate() {
+                    sLogs.add("Test execution");
+                }
+            };
+
+    @Rule public ExpectedException expectedException = ExpectedException.none();
+
+    @Before
+    public void setUp() {
+        // Clear the logs between tests.
+        sLogs.clear();
+    }
+
+    @Test
+    public void testAppliesRuleBySimpleClassNameForRulesInKnownPackage() throws Throwable {
+        DynamicRuleChain chain = createWithRuleNames("DynamicRuleChainTest$Rule1");
+        Statement applied = chain.apply(mStatement, DESCRIPTION);
+        applied.evaluate();
+        assertThat(sLogs).containsExactly("Rule1 starting", "Test execution", "Rule1 finished");
+    }
+
+    @Test
+    public void testAppliesRuleByFullyQualifiedClassName() throws Throwable {
+        DynamicRuleChain chain =
+                createWithRuleNames("android.platform.test.rule.DynamicRuleChainTest$Rule1");
+        Statement applied = chain.apply(mStatement, DESCRIPTION);
+        applied.evaluate();
+        assertThat(sLogs).containsExactly("Rule1 starting", "Test execution", "Rule1 finished");
+    }
+
+    @Test
+    public void testThrowsOnNonexistentSimpleClassName() throws Throwable {
+        String badName = "NotARuleRule";
+        expectedException.expectMessage(
+                String.format(
+                        "Failed to dynamically load rule with simple class name %s.", badName));
+        DynamicRuleChain chain = createWithRuleNames(badName);
+        chain.apply(mStatement, DESCRIPTION);
+    }
+
+    @Test
+    public void testThrowsOnNonexistentFullyQualifiedClassName() throws Throwable {
+        String badName = "not.a.rule.Rule";
+        expectedException.expectMessage(
+                String.format(
+                        "Failed to dynamically load rule with fully qualified name %s.", badName));
+        DynamicRuleChain chain = createWithRuleNames(badName);
+        chain.apply(mStatement, DESCRIPTION);
+    }
+
+    @Test
+    public void testMultipleRulesAreAppliedInCorrectOrder() throws Throwable {
+        DynamicRuleChain chain =
+                createWithRuleNames(
+                        "DynamicRuleChainTest$Rule2",
+                        "android.platform.test.rule.DynamicRuleChainTest$Rule1");
+        Statement applied = chain.apply(mStatement, DESCRIPTION);
+        applied.evaluate();
+        // Rule2's logs should be outside, and Rule1's logs should be inside.
+        assertThat(sLogs)
+                .containsExactly(
+                        "Rule2 starting",
+                        "Rule1 starting",
+                        "Test execution",
+                        "Rule1 finished",
+                        "Rule2 finished");
+    }
+
+    @Test
+    public void testInvalidRuleNameInMultipleRulesAlsoThrows() throws Throwable {
+        String badName = "not.a.rule.Rule";
+        expectedException.expectMessage(
+                String.format(
+                        "Failed to dynamically load rule with fully qualified name %s.", badName));
+        DynamicRuleChain chain =
+                createWithRuleNames(
+                        badName, "android.platform.test.rule.DynamicRuleChainTest$Rule1");
+        chain.apply(mStatement, DESCRIPTION);
+    }
+
+    @Test
+    public void testSupportsDuplicateSimpleRuleNames() throws Throwable {
+        DynamicRuleChain chain =
+                createWithRuleNames(
+                        "DynamicRuleChainTest$Rule2",
+                        "android.platform.test.rule.DynamicRuleChainTest$Rule1",
+                        "DynamicRuleChainTest$Rule2");
+        Statement applied = chain.apply(mStatement, DESCRIPTION);
+        applied.evaluate();
+        // Rule2's logs should be outside, and Rule1's logs should be inside.
+        assertThat(sLogs)
+                .containsExactly(
+                        "Rule2 starting",
+                        "Rule1 starting",
+                        "Rule2 starting",
+                        "Test execution",
+                        "Rule2 finished",
+                        "Rule1 finished",
+                        "Rule2 finished");
+    }
+
+    @Test
+    public void testSupportsDuplicateFullyQualifiedRuleNames() throws Throwable {
+        DynamicRuleChain chain =
+                createWithRuleNames(
+                        "android.platform.test.rule.DynamicRuleChainTest$Rule1",
+                        "DynamicRuleChainTest$Rule2",
+                        "android.platform.test.rule.DynamicRuleChainTest$Rule1");
+        Statement applied = chain.apply(mStatement, DESCRIPTION);
+        applied.evaluate();
+        // Rule2's logs should be outside, and Rule1's logs should be inside.
+        assertThat(sLogs)
+                .containsExactly(
+                        "Rule1 starting",
+                        "Rule2 starting",
+                        "Rule1 starting",
+                        "Test execution",
+                        "Rule1 finished",
+                        "Rule2 finished",
+                        "Rule1 finished");
+    }
+
+    private DynamicRuleChain createWithRuleNames(String... ruleNames) {
+        Bundle args = new Bundle();
+        args.putString(DynamicRuleChain.DEFAULT_RULES_OPTION, String.join(",", ruleNames));
+        return new DynamicRuleChain(args);
+    }
+
+    private DynamicRuleChain createWithRulesOptionNameAndRuleNames(
+            String rulesOptionName, String... ruleNames) {
+        Bundle args = new Bundle();
+        args.putString(rulesOptionName, String.join(",", ruleNames));
+        return new DynamicRuleChain(rulesOptionName, args);
+    }
+
+    public static class Rule1 extends TestWatcher {
+        @Override
+        public void starting(Description description) {
+            sLogs.add("Rule1 starting");
+        }
+
+        @Override
+        public void finished(Description description) {
+            sLogs.add("Rule1 finished");
+        }
+    }
+
+    public static class Rule2 extends TestWatcher {
+        @Override
+        public void starting(Description description) {
+            sLogs.add("Rule2 starting");
+        }
+
+        @Override
+        public void finished(Description description) {
+            sLogs.add("Rule2 finished");
+        }
+    }
+}