Creating a custom runner for multivalent tests with Launcher specific features

Bug: 338128923
Test: Verified by modifying a test
Flag: None
Change-Id: I1f04e28847ff9c0f7af3c0d952038ad24d03c17e
diff --git a/tests/Android.bp b/tests/Android.bp
index 3822ff8..5ec2263 100644
--- a/tests/Android.bp
+++ b/tests/Android.bp
@@ -23,7 +23,8 @@
     srcs: [
         "src/**/*.java",
         "src/**/*.kt",
-        ":launcher3-robo-src",
+        "multivalentTests/src/**/*.java",
+        "multivalentTests/src/**/*.kt",
     ],
     exclude_srcs: [
         ":launcher-non-quickstep-tests-src",
@@ -37,6 +38,8 @@
     srcs: [
         "multivalentTests/src/**/*.java",
         "multivalentTests/src/**/*.kt",
+        "src_deviceless/**/*.java",
+        "src_deviceless/**/*.kt",
     ],
 }
 
diff --git a/tests/multivalentTests/src/com/android/launcher3/icons/FastBitmapDrawableTest.java b/tests/multivalentTests/src/com/android/launcher3/icons/FastBitmapDrawableTest.java
index 7e9b68d..58dce0b 100644
--- a/tests/multivalentTests/src/com/android/launcher3/icons/FastBitmapDrawableTest.java
+++ b/tests/multivalentTests/src/com/android/launcher3/icons/FastBitmapDrawableTest.java
@@ -37,15 +37,12 @@
 import android.view.animation.PathInterpolator;
 
 import androidx.test.annotation.UiThreadTest;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
 
-import com.android.launcher3.util.rule.RobolectricUiThreadRule;
+import com.android.launcher3.util.LauncherMultivalentJUnit;
 
 import org.junit.Before;
-import org.junit.Rule;
 import org.junit.Test;
-import org.junit.rules.TestRule;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
@@ -55,14 +52,11 @@
  * Tests for FastBitmapDrawable.
  */
 @SmallTest
-@RunWith(AndroidJUnit4.class)
+@RunWith(LauncherMultivalentJUnit.class)
 @UiThreadTest
 public class FastBitmapDrawableTest {
     private static final float EPSILON = 0.00001f;
 
-    @Rule
-    public final TestRule roboUiThreadRule = new RobolectricUiThreadRule();
-
     @Spy
     FastBitmapDrawable mFastBitmapDrawable =
             spy(new FastBitmapDrawable(Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)));
diff --git a/tests/multivalentTests/src/com/android/launcher3/logging/StartupLatencyLoggerTest.kt b/tests/multivalentTests/src/com/android/launcher3/logging/StartupLatencyLoggerTest.kt
index 12f6c8c..713d4d5 100644
--- a/tests/multivalentTests/src/com/android/launcher3/logging/StartupLatencyLoggerTest.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/logging/StartupLatencyLoggerTest.kt
@@ -2,22 +2,18 @@
 
 import androidx.core.util.isEmpty
 import androidx.test.annotation.UiThreadTest
-import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
-import com.android.launcher3.util.rule.RobolectricUiThreadRule
+import com.android.launcher3.util.LauncherMultivalentJUnit
 import com.google.common.truth.Truth.assertThat
 import org.junit.Before
-import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
 /** Unit test for [ColdRebootStartupLatencyLogger]. */
 @SmallTest
-@RunWith(AndroidJUnit4::class)
+@RunWith(LauncherMultivalentJUnit::class)
 class StartupLatencyLoggerTest {
 
-    @get:Rule val roboUiThreadRule = RobolectricUiThreadRule()
-
     private val underTest = ColdRebootStartupLatencyLogger()
 
     @Before
diff --git a/tests/multivalentTests/src/com/android/launcher3/util/EmulatedDeviceAndroidJUnit.kt b/tests/multivalentTests/src/com/android/launcher3/util/LauncherMultivalentJUnit.kt
similarity index 68%
rename from tests/multivalentTests/src/com/android/launcher3/util/EmulatedDeviceAndroidJUnit.kt
rename to tests/multivalentTests/src/com/android/launcher3/util/LauncherMultivalentJUnit.kt
index 694f257..e8560af 100644
--- a/tests/multivalentTests/src/com/android/launcher3/util/EmulatedDeviceAndroidJUnit.kt
+++ b/tests/multivalentTests/src/com/android/launcher3/util/LauncherMultivalentJUnit.kt
@@ -24,23 +24,24 @@
 import org.junit.runners.Suite
 
 /**
- * A custom runner which emulates multiple devices when running in robolectric framework. Runs
- * normally when running on device
+ * A custom runner for multivalent tests with launcher specific features
+ * 1) Adds support for @UiThread annotations in deviceless tests
+ * 2) Allows emulating multiple devices when running in deviceless mode
  */
-class EmulatedDeviceAndroidJUnit(klass: Class<*>?) : Suite(klass, ImmutableList.of()) {
+class LauncherMultivalentJUnit(klass: Class<*>?) : Suite(klass, ImmutableList.of()) {
 
     val runners: List<Runner> =
-        testClass.getAnnotation(Devices::class.java)?.value?.let { devices ->
-            if (devices.isEmpty() || !isRunningInRobolectric) {
+        (testClass.getAnnotation(EmulatedDevices::class.java)?.value ?: emptyArray()).let { devices
+            ->
+            if (!isRunningInRobolectric) {
                 return@let null
             }
             try {
                 (testClass.javaClass.classLoader.loadClass(ROBOLECTRIC_RUNNER) as Class<Runner>)
                     .getConstructor(Class::class.java, String::class.java)
                     .let { ctor ->
-                        devices.map { deviceName ->
-                            ctor.newInstance(testClass.javaClass, deviceName)
-                        }
+                        if (devices.isEmpty()) listOf(ctor.newInstance(testClass.javaClass, null))
+                        else devices.map { ctor.newInstance(testClass.javaClass, it) }
                     }
             } catch (e: Exception) {
                 null
@@ -50,11 +51,13 @@
 
     override fun getChildren() = runners
 
-    @Retention(RUNTIME) @Target(CLASS) annotation class Devices(val value: Array<String>)
+    /**
+     * Annotation to be added to a test so run it on a list of emulated devices for deviceless test
+     */
+    @Retention(RUNTIME) @Target(CLASS) annotation class EmulatedDevices(val value: Array<String>)
 
     companion object {
-        private const val ROBOLECTRIC_RUNNER =
-            "com.android.launcher3.util.RobolectricEmulatedDeviceRunner"
+        private const val ROBOLECTRIC_RUNNER = "com.android.launcher3.util.RobolectricDeviceRunner"
 
         val isRunningInRobolectric: Boolean
             get() =
diff --git a/tests/multivalentTests/src/com/android/launcher3/util/rule/RobolectricUiThreadRule.kt b/tests/multivalentTests/src/com/android/launcher3/util/rule/RobolectricUiThreadRule.kt
deleted file mode 100644
index b65c443..0000000
--- a/tests/multivalentTests/src/com/android/launcher3/util/rule/RobolectricUiThreadRule.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Copyright (C) 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.launcher3.util.rule
-
-import androidx.test.annotation.UiThreadTest
-import androidx.test.platform.app.InstrumentationRegistry
-import com.android.launcher3.util.EmulatedDeviceAndroidJUnit.Companion.isRunningInRobolectric
-import java.util.concurrent.atomic.AtomicReference
-import org.junit.rules.TestRule
-import org.junit.runner.Description
-import org.junit.runners.model.Statement
-
-/**
- * A test rule to add support for @UiThreadTest annotations when running in robolectric until is it
- * natively supported by the robolectric runner:
- * https://github.com/robolectric/robolectric/issues/9026
- */
-class RobolectricUiThreadRule : TestRule {
-
-    override fun apply(base: Statement, description: Description): Statement =
-        if (!shouldRunOnUiThread(description)) base else UiThreadStatement(base)
-
-    private fun shouldRunOnUiThread(description: Description): Boolean {
-        if (!isRunningInRobolectric) {
-            // If not running in robolectric, let the default runner handle this
-            return false
-        }
-        var clazz = description.testClass
-        try {
-            if (
-                clazz
-                    .getDeclaredMethod(description.methodName)
-                    .getAnnotation(UiThreadTest::class.java) != null
-            ) {
-                return true
-            }
-        } catch (_: Exception) {
-            // Ignore
-        }
-
-        while (!clazz.isAnnotationPresent(UiThreadTest::class.java)) {
-            clazz = clazz.superclass ?: return false
-        }
-        return true
-    }
-
-    private class UiThreadStatement(val base: Statement) : Statement() {
-
-        override fun evaluate() {
-            val exceptionRef = AtomicReference<Throwable>()
-            InstrumentationRegistry.getInstrumentation().runOnMainSync {
-                try {
-                    base.evaluate()
-                } catch (throwable: Throwable) {
-                    exceptionRef.set(throwable)
-                }
-            }
-            exceptionRef.get()?.let { throw it }
-        }
-    }
-}
diff --git a/tests/src_deviceless/com/android/launcher3/util/RobolectricDeviceRunner.kt b/tests/src_deviceless/com/android/launcher3/util/RobolectricDeviceRunner.kt
new file mode 100644
index 0000000..dc6d716
--- /dev/null
+++ b/tests/src_deviceless/com/android/launcher3/util/RobolectricDeviceRunner.kt
@@ -0,0 +1,107 @@
+/*
+ * 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 com.android.launcher3.util
+
+import androidx.test.annotation.UiThreadTest
+import androidx.test.platform.app.InstrumentationRegistry
+import java.lang.reflect.Method
+import java.util.concurrent.atomic.AtomicReference
+import org.junit.runners.model.FrameworkMethod
+import org.junit.runners.model.Statement
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.internal.bytecode.Sandbox
+import org.robolectric.util.ReflectionHelpers
+import org.robolectric.util.ReflectionHelpers.ClassParameter
+
+/** Runner which emulates the provided display before running the actual test */
+class RobolectricDeviceRunner(testClass: Class<*>?, private val deviceName: String?) :
+    RobolectricTestRunner(testClass) {
+
+    private val nameSuffix = deviceName?.let { "-$it" } ?: ""
+
+    override fun getName() = super.getName() + nameSuffix
+
+    override fun testName(method: FrameworkMethod) = super.testName(method) + nameSuffix
+
+    @Throws(Throwable::class)
+    override fun beforeTest(sandbox: Sandbox, method: FrameworkMethod, bootstrappedMethod: Method) {
+        super.beforeTest(sandbox, method, bootstrappedMethod)
+
+        deviceName ?: return
+
+        val emulator =
+            try {
+                ReflectionHelpers.loadClass(
+                    bootstrappedMethod.declaringClass.classLoader,
+                    DEVICE_EMULATOR
+                )
+            } catch (e: Exception) {
+                // Ignore, if the device emulator is not present
+                return
+            }
+        ReflectionHelpers.callStaticMethod<Any>(
+            emulator,
+            "updateDevice",
+            ClassParameter.from(String::class.java, deviceName)
+        )
+    }
+
+    override fun getHelperTestRunner(clazz: Class<*>) = MyHelperTestRunner(clazz)
+
+    class MyHelperTestRunner(clazz: Class<*>) : HelperTestRunner(clazz) {
+
+        override fun methodBlock(method: FrameworkMethod): Statement =
+            // this needs to be run in the test classLoader
+            ReflectionHelpers.callStaticMethod(
+                method.declaringClass.classLoader,
+                RobolectricDeviceRunner::class.qualifiedName,
+                "wrapUiThreadMethod",
+                ClassParameter.from(FrameworkMethod::class.java, method),
+                ClassParameter.from(Statement::class.java, super.methodBlock(method))
+            )
+    }
+
+    private class UiThreadStatement(val base: Statement) : Statement() {
+
+        override fun evaluate() {
+            val exceptionRef = AtomicReference<Throwable>()
+            InstrumentationRegistry.getInstrumentation().runOnMainSync {
+                try {
+                    base.evaluate()
+                } catch (throwable: Throwable) {
+                    exceptionRef.set(throwable)
+                }
+            }
+            exceptionRef.get()?.let { throw it }
+        }
+    }
+
+    companion object {
+
+        private const val DEVICE_EMULATOR = "com.android.launcher3.util.RoboDeviceEmulator"
+
+        @JvmStatic
+        fun wrapUiThreadMethod(method: FrameworkMethod, base: Statement): Statement =
+            if (
+                method.method.isAnnotationPresent(UiThreadTest::class.java) ||
+                    method.declaringClass.isAnnotationPresent(UiThreadTest::class.java)
+            ) {
+                UiThreadStatement(base)
+            } else {
+                base
+            }
+    }
+}