diff --git a/ext/junit/java/androidx/test/ext/junit/rules/AppComponentFactoryRule.kt b/ext/junit/java/androidx/test/ext/junit/rules/AppComponentFactoryRule.kt new file mode 100644 index 0000000000..68980e31d3 --- /dev/null +++ b/ext/junit/java/androidx/test/ext/junit/rules/AppComponentFactoryRule.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 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 androidx.test.ext.junit.rules + +import android.app.AppComponentFactory +import android.os.Build +import androidx.test.annotation.ExperimentalTestApi +import androidx.test.platform.app.AppComponentFactoryRegistry +import org.junit.rules.ExternalResource + +/** + * [AppComponentFactory] let you define a [AppComponentFactory] before the tests starts and clean-up + * the factory after the test. + * + * This rule is designed to be used with [ActivityScenarioRule]. + * + * Example: + * + * ```kotlin + * // Set-up `AppComponentFactoryRule` with your custom `AppComponentFactory`. + * private val factoryRule = AppComponentFactoryRule(MyAppComponentFactory()) + * + * // Set-up `ActivityScenarioRule` with your custom `Activity`. + * private val activityRule = ActivityScenarioRule() + * + * // Creates a `RuleChain` for ordering the test rules above. We need to ensure the + * // `AppComponentFactoryRule` will always run BEFORE the `ActivityScenarioRule` so that + * // your custom `AppComponentFactory` is available when the activity is launched. + * @[Rule JvmField] val ruleChain = RuleChain.outerRule(factoryRule).around(activityRule) + * ``` + * + * @see RuleChain + */ +@ExperimentalTestApi +class AppComponentFactoryRule(private val factory: AppComponentFactory) : ExternalResource() { + + override fun before() { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + "AppComponentFactoryRule is not supported on 'VERSION.SDK_INT < VERSION_CODES.P'" + } + AppComponentFactoryRegistry.appComponentFactory = factory + } + + override fun after() { + AppComponentFactoryRegistry.appComponentFactory = null + } +} diff --git a/ext/junit/javatests/androidx/test/ext/junit/rules/AppComponentFactoryRuleTest.kt b/ext/junit/javatests/androidx/test/ext/junit/rules/AppComponentFactoryRuleTest.kt new file mode 100644 index 0000000000..46a72882c4 --- /dev/null +++ b/ext/junit/javatests/androidx/test/ext/junit/rules/AppComponentFactoryRuleTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2023 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 androidx.test.ext.junit.rules + +import android.app.Activity +import android.app.AppComponentFactory +import android.app.Application +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AppComponentFactoryRuleTest { + + private val appComponentFactoryRule = AppComponentFactoryRule(DummyAppComponentFactory()) + + private val activityScenarioRule = ActivityScenarioRule(DummyActivity::class.java) + + @[JvmField Rule] + val ruleChain: RuleChain = + RuleChain.outerRule(appComponentFactoryRule).around(activityScenarioRule) + + @Test + @Config(minSdk = 28) + fun shouldCreateNewAppComponentsUsingAppComponentFactorySet() { + activityScenarioRule.scenario.onActivity { activity: DummyActivity -> + assertThat(activity.text).isEqualTo("instantiateActivity") + assertThat(activity.application).isInstanceOf(DummyApplication::class.java) + assertThat((activity.application as DummyApplication).text) + .isEqualTo("instantiateApplication") + } + } +} + +private class DummyAppComponentFactory : AppComponentFactory() { + override fun instantiateApplication(cl: ClassLoader, className: String): Application = + if (className == DummyApplication::class.java.name) { + DummyApplication(text = "instantiateApplication") + } else { + super.instantiateApplication(cl, className) + } + + override fun instantiateActivity(cl: ClassLoader, className: String, intent: Intent?): Activity = + if (className == DummyActivity::class.java.name) { + DummyActivity(text = "instantiateActivity") + } else { + super.instantiateActivity(cl, className, intent) + } +} + +private class DummyApplication(val text: String) : Application() + +private class DummyActivity(val text: String) : Activity() diff --git a/runner/monitor/java/androidx/test/api/current_internal.txt b/runner/monitor/java/androidx/test/api/current_internal.txt index 127577074c..79ed62876d 100644 --- a/runner/monitor/java/androidx/test/api/current_internal.txt +++ b/runner/monitor/java/androidx/test/api/current_internal.txt @@ -189,6 +189,18 @@ package androidx.test.internal.util { } +package androidx.test.platform.app { + + @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public final class AppComponentFactoryRegistry { + method public static android.app.AppComponentFactory? getAppComponentFactory(); + method public static android.app.Activity? instantiateActivity(ClassLoader cl, String className, android.content.Intent? intent = null); + method public static android.app.Application? instantiateApplication(ClassLoader cl, String className); + method public static void setAppComponentFactory(android.app.AppComponentFactory?); + property public static final android.app.AppComponentFactory? appComponentFactory; + } + +} + package androidx.test.platform.concurrent { @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public enum DirectExecutor implements java.util.concurrent.Executor { diff --git a/runner/monitor/java/androidx/test/platform/app/AppComponentFactoryRegistry.kt b/runner/monitor/java/androidx/test/platform/app/AppComponentFactoryRegistry.kt new file mode 100644 index 0000000000..c07cf01008 --- /dev/null +++ b/runner/monitor/java/androidx/test/platform/app/AppComponentFactoryRegistry.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2023 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 androidx.test.platform.app + +import android.app.Activity +import android.app.AppComponentFactory +import android.app.Application +import android.app.Instrumentation +import android.content.Intent +import android.os.Build +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RestrictTo +import java.util.concurrent.atomic.AtomicReference + +/** + * An exposed registry instance that holds a reference to an application [AppComponentFactory] which + * will be used by a test [Instrumentation]. + * + * [AppComponentFactoryRegistry] is a low level APIs, and is used internally by Android testing + * frameworks. It is **NOT** designed for direct use by third party clients. + * + * TODO(b/275323224): In order to avoid breaking open source, support for + * `RoboMonitoringInstrumentation` will be done later, once `AppComponentFactoryRegistry` is + * available in a published `androidx.test:monitor` release. + * + * @hide + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object AppComponentFactoryRegistry { + + private val factoryRef = AtomicReference(/* initialValue= */ null) + + /** [AppComponentFactory] to be used by the current test [Instrumentation]. */ + @JvmStatic + var appComponentFactory: AppComponentFactory? + /** + * Set the [AppComponentFactory] to be used by the current test [Instrumentation]. + * + * @throws [IllegalArgumentException] if called from an Android version smaller than 28. + */ + set(value) { + check(isVersionCodeAtLeastP()) { + "AppComponentFactoryRegistry is not supported on 'VERSION.SDK_INT < VERSION_CODES.P'" + } + factoryRef.set(value) + } + /** Returns the [AppComponentFactory] to be used by the current test [Instrumentation]. */ + get() = factoryRef.get() + + /** + * Returns an instance of an [Application] given a [className] using the registered + * [AppComponentFactory] as factory to instantiate it. + * + * It may return null if: + * - an instance of [AppComponentFactory] has not been registered with [appComponentFactory]. + * - the registered [AppComponentFactory] can not create an instance of the requested [className]. + * - the current Android [Build.VERSION.SDK_INT] is smaller than [Build.VERSION_CODES.P]. + * + * This function is a shorthand for getting an instance of the registered factory or null. + * + * @see [AppComponentFactory.instantiateApplication] + */ + @JvmStatic + fun instantiateApplication( + cl: ClassLoader, + className: String, + ): Application? = + if (isVersionCodeAtLeastP()) { + appComponentFactory?.instantiateApplication(cl, className) + } else { + null + } + + /** + * Returns an instance of an [Activity] given a [className] using the registered + * [AppComponentFactory] as factory to instantiate it. + * + * It may return null if: + * - an instance of [AppComponentFactory] has not been registered with [appComponentFactory]. + * - the registered [AppComponentFactory] can not create an instance of the requested [className]. + * - the current Android [Build.VERSION.SDK_INT] is smaller than [Build.VERSION_CODES.P]. + * + * This function is a shorthand for getting an instance of the registered factory or null. + * + * @see [AppComponentFactory.instantiateApplication] + */ + @JvmStatic + fun instantiateActivity( + cl: ClassLoader, + className: String, + intent: Intent? = null, + ): Activity? = + if (isVersionCodeAtLeastP()) { + appComponentFactory?.instantiateActivity(cl, className, intent) + } else { + null + } + + @ChecksSdkIntAtLeast(api = VERSION_CODES.P) + private fun isVersionCodeAtLeastP(): Boolean = VERSION.SDK_INT >= VERSION_CODES.P +} diff --git a/runner/monitor/java/androidx/test/runner/MonitoringInstrumentation.java b/runner/monitor/java/androidx/test/runner/MonitoringInstrumentation.java index bf143a2f56..027f08d7ce 100644 --- a/runner/monitor/java/androidx/test/runner/MonitoringInstrumentation.java +++ b/runner/monitor/java/androidx/test/runner/MonitoringInstrumentation.java @@ -45,6 +45,7 @@ import androidx.test.internal.runner.lifecycle.ApplicationLifecycleMonitorImpl; import androidx.test.internal.util.Checks; import androidx.test.internal.util.ProcSummary; +import androidx.test.platform.app.AppComponentFactoryRegistry; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.runner.intent.IntentMonitorRegistry; import androidx.test.runner.intent.IntentStubberRegistry; @@ -150,6 +151,12 @@ public Application newApplication(ClassLoader cl, String className, Context cont // On API <= 15, initialization should have been called in #onCreate(). installMultidexAndExceptionHandler(); } + + Application application = AppComponentFactoryRegistry.instantiateApplication(cl, className); + if (application != null) { + return application; + } + return super.newApplication(cl, className, context); } @@ -859,9 +866,16 @@ public Activity newActivity( @Override public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException { - return interceptingActivityFactory.shouldIntercept(cl, className, intent) - ? interceptingActivityFactory.create(cl, className, intent) - : super.newActivity(cl, className, intent); + if (interceptingActivityFactory.shouldIntercept(cl, className, intent)) { + return interceptingActivityFactory.create(cl, className, intent); + } + + Activity activity = AppComponentFactoryRegistry.instantiateActivity(cl, className, intent); + if (activity != null) { + return activity; + } + + return super.newActivity(cl, className, intent); } /** diff --git a/runner/monitor/javatests/androidx/test/platform/app/AppComponentFactoryRegistryTest.kt b/runner/monitor/javatests/androidx/test/platform/app/AppComponentFactoryRegistryTest.kt new file mode 100644 index 0000000000..4528f8f4c4 --- /dev/null +++ b/runner/monitor/javatests/androidx/test/platform/app/AppComponentFactoryRegistryTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2023 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 androidx.test.platform.app + +import android.app.Activity +import android.app.AppComponentFactory +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@SmallTest +class AppComponentFactoryRegistryTest { + + @Test + @Config(minSdk = 28) + fun shouldReturnNullIfAppComponentHasNotBeenSet() { + assertThat(AppComponentFactoryRegistry.appComponentFactory).isNull() + } + + @Test + @Config(minSdk = 28) + fun shouldSetFactoryIfVersionCodeIsGreaterOrEqualThan28() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + assertThat(appComponentFactory).isNotNull() + } + } + + @Test(expected = IllegalStateException::class) + @Config(maxSdk = 27) + fun shouldThrowIfVersionCodeIsSmallerThan28WhenAppComponentIsSet() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + } + } + + @Test + @Config(minSdk = 28) + fun shouldNotInstantiateActivityIfFactoryNotSet() { + with(AppComponentFactoryRegistry) { + val activity = instantiateActivity( + cl = javaClass.classLoader, + className = TestActivity::class.java.name, + intent = null, + ) + assertThat(activity).isNull() + } + } + + @Test + @Config(maxSdk = 27) + fun shouldNotInstantiateActivityIfFactorySetButVersionCodeSmallerThan28() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + + val activity = + instantiateActivity( + cl = javaClass.classLoader, + className = TestActivity::class.java.name, + intent = null, + ) + + assertThat(activity).isNull() + } + } + + @Test + @Config(minSdk = 28) + fun shouldInstantiateActivityIfFactoryIsSetAndVersionCodeIsGraterOrEqualThan28() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + + val activity = + instantiateActivity( + cl = javaClass.classLoader, + className = TestActivity::class.java.name, + intent = null, + ) + + assertThat(activity).isInstanceOf(TestActivity::class.java) + } + } + + @Test + @Config(minSdk = 28) + fun shouldNotInstantiateApplicationIfFactoryNotSet() { + with(AppComponentFactoryRegistry) { + val activity = + instantiateApplication( + cl = javaClass.classLoader, + className = TestApplication::class.java.name, + ) + + assertThat(activity).isNull() + } + } + + @Test + @Config(maxSdk = 27) + fun shouldNotInstantiateApplicationIfFactorySetButVersionCodeSmallerThan28() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + + val activity = + instantiateApplication( + cl = javaClass.classLoader, + className = TestApplication::class.java.name, + ) + + assertThat(activity).isNull() + } + } + + @Test + @Config(minSdk = 28) + fun shouldInstantiateApplicationIfFactoryIsSetAndVersionCodeIsGraterOrEqualThan28() { + with(AppComponentFactoryRegistry) { + appComponentFactory = AppComponentFactory() + + val activity = + instantiateApplication( + cl = javaClass.classLoader, + className = TestApplication::class.java.name, + ) + + assertThat(activity).isInstanceOf(TestApplication::class.java) + } + } + + private class TestActivity : Activity() + + private class TestApplication : Application() +} diff --git a/runner/monitor/javatests/androidx/test/runner/MonitoringInstrumentationTest.java b/runner/monitor/javatests/androidx/test/runner/MonitoringInstrumentationTest.java index e5b7201d83..f1706dce35 100644 --- a/runner/monitor/javatests/androidx/test/runner/MonitoringInstrumentationTest.java +++ b/runner/monitor/javatests/androidx/test/runner/MonitoringInstrumentationTest.java @@ -22,22 +22,28 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.sameInstance; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.Assert.assertEquals; +import static org.hamcrest.core.IsEqual.equalTo; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import android.app.Activity; +import android.app.AppComponentFactory; +import android.app.Application; import android.content.Intent; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.MediumTest; +import androidx.test.filters.SdkSuppress; +import androidx.test.platform.app.AppComponentFactoryRegistry; import androidx.test.runner.intercepting.InterceptingActivityFactory; import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -@MediumTest @RunWith(AndroidJUnit4.class) +@MediumTest public class MonitoringInstrumentationTest { private MonitoringInstrumentation instrumentation; @@ -47,6 +53,37 @@ public void setUp() throws Exception { instrumentation = (MonitoringInstrumentation) getInstrumentation(); } + @Test + @SdkSuppress(minSdkVersion = 28) + public void shouldCreateNewAppComponentsUsingAppComponentFactorySet() { + final TestApplication testApplication = mockTestApplication(); + final TestActivity testActivity = mockTestActivity(); + final AppComponentFactory factory = + new TestAppComponentFactory( + /* application= */ testApplication, /* activity= */ testActivity); + AppComponentFactoryRegistry.setAppComponentFactory(factory); + + final AtomicReference activity = new AtomicReference<>(); + retrieveActivityOnMainThread(TestActivity.class, activity); + + instrumentation.waitForIdleSync(); + assertThat(activity.get(), equalTo(testActivity)); + assertThat(activity.get().getApplication(), equalTo(testApplication)); + } + + @Test + @SdkSuppress(minSdkVersion = 28) + public void shouldUseDefaultMechanismForCreatingAppComponentsIfAppComponentFactoryNotSet() { + AppComponentFactoryRegistry.setAppComponentFactory(null); + + final AtomicReference activity = new AtomicReference<>(); + retrieveActivityOnMainThread(TestActivity.class, activity); + + instrumentation.waitForIdleSync(); + assertThat(activity.get(), instanceOf(TestActivity.class)); + assertThat(activity.get().getApplication(), instanceOf(TestApplication.class)); + } + @Test public void shouldUseDefaultMechanismForCreatingActivityIfInterceptingActivityFactoryNotSet() throws Exception { @@ -64,13 +101,13 @@ public void shouldCreateNewActivityUsingInterceptingActivityFactoryIfItCanCreate final Class testActivityClass = TestActivity.class; final AtomicReference testActivityReference = new AtomicReference<>(); - final TestActivity myTestActivity = mock(TestActivity.class); + final TestActivity myTestActivity = mockTestActivity(); instrumentation.interceptActivityUsing(interceptingActivityFactory(myTestActivity, true)); retrieveActivityOnMainThread(testActivityClass, testActivityReference); instrumentation.waitForIdleSync(); - assertThat(testActivityReference.get(), sameInstance((Activity) myTestActivity)); + assertThat(testActivityReference.get(), sameInstance(myTestActivity)); } @Test @@ -79,13 +116,13 @@ public void shouldNotCreateNewActivityUsingInterceptingActivityFactoryIfItCannot final Class testActivityClass = TestActivity.class; final AtomicReference testActivityReference = new AtomicReference<>(); - final TestActivity myTestActivity = mock(TestActivity.class); + final TestActivity myTestActivity = mockTestActivity(); instrumentation.interceptActivityUsing(interceptingActivityFactory(myTestActivity, false)); retrieveActivityOnMainThread(testActivityClass, testActivityReference); instrumentation.waitForIdleSync(); - assertThat(testActivityReference.get(), not(sameInstance((Activity) myTestActivity))); + assertThat(testActivityReference.get(), not(sameInstance(myTestActivity))); } @Test @@ -93,13 +130,13 @@ public void shouldNotCreateNewActivityUsingInterceptingActivityFactoryIfReset() final Class testActivityClass = TestActivity.class; final AtomicReference activity = new AtomicReference<>(); - final TestActivity myTestActivity = mock(TestActivity.class); + final TestActivity myTestActivity = mockTestActivity(); instrumentation.interceptActivityUsing(interceptingActivityFactory(myTestActivity, true)); instrumentation.useDefaultInterceptingActivityFactory(); retrieveActivityOnMainThread(testActivityClass, activity); instrumentation.waitForIdleSync(); - assertThat(activity.get(), not(sameInstance((Activity) myTestActivity))); + assertThat(activity.get(), not(sameInstance(myTestActivity))); } @Test @@ -113,7 +150,7 @@ public void runOnMainSyncShouldRethrowAssertionException() { + " thread."); } catch (Throwable t) { assertThat(t, is(instanceOf(AssertionError.class))); - assertEquals(expectedErrorMessage, t.getMessage()); + assertThat(expectedErrorMessage, equalTo(t.getMessage())); } } @@ -151,7 +188,53 @@ public Activity create(ClassLoader classLoader, String className, Intent intent) }; } + /** + * Required for checking a custom Application has been created outside Robo environment. Apart + * from constructing the object, the mock object is not exercised. + */ + @SuppressWarnings("DoNotMock") + private TestApplication mockTestApplication() { + return mock(TestApplication.class); + } + + /** + * Required for checking a custom Activity has been created outside Robo environment. Apart from + * constructing the object, the mock object is not exercised. + */ + @SuppressWarnings("DoNotMock") + private TestActivity mockTestActivity() { + return mock(TestActivity.class); + } + + public static class TestApplication extends Application {} + public static class TestActivity extends Activity {} public static class SomeOtherActivity extends Activity {} + + public static class TestAppComponentFactory extends AppComponentFactory { + + @Nullable private final Activity activity; + @Nullable private final Application application; + + TestAppComponentFactory(@Nullable Application application, @Nullable Activity activity) { + this.application = application; + this.activity = activity; + } + + @NonNull + @Override + public Activity instantiateActivity( + @NonNull ClassLoader cl, @NonNull String className, @Nullable Intent intent) + throws ClassNotFoundException, IllegalAccessException, InstantiationException { + return activity != null ? activity : super.instantiateActivity(cl, className, intent); + } + + @NonNull + @Override + public Application instantiateApplication(@NonNull ClassLoader cl, @NonNull String className) + throws ClassNotFoundException, IllegalAccessException, InstantiationException { + return application != null ? application : super.instantiateApplication(cl, className); + } + } }