From dd64ca28f853c02e53e66b83a4dd7c3eadaf4596 Mon Sep 17 00:00:00 2001 From: Ievgenii Nazaruk Date: Fri, 1 Dec 2023 14:46:20 -0800 Subject: [PATCH] Hilt: allow custom injection support for tests. One of the key decisions with Hilt is bytecode rewriting. It helps simplify the developer experience, but makes things more complicated for testing. As a result Hilt provides additional testing framework that helps mitigate these concerns and allows for great flexibility when it comes to mocking and replacing dependencies for testing. Still, hilt has non-trivial compilation costs. And as the codebase growth, we've observed that the cost for test complication growth even more so than for production code. As a result there is an exploration to avoid using hilt for simpler cases where the value of DI graph in tests is very small, but the additional costs to compile are great. This diff introduces a few small touches to Hilt codegen to allow for a runtime test DI (like a simpler version of Guice) to overtake the injection. Specifically, this diff introduces `TestInjectInterceptor` class with a single empty static method `injectForTesting()`. The codegen for Activities, Fragments, Views, Services, and Broadcasts is adjusted to have the next code: ``` protected void inject() { if (!injected) { injected = true; if (TestInjectInterceptor.injectForTesting(this)) { return; } // rest of Hilt injection code. } ``` For production or tests running under Hilt the additional code does nothing. And for production this code should be eliminated by R8. But for cases where testing framework is able to intercept a call to `TestInjectInterceptor.injectForTesting()` (like Robolectric shadow), the injection can be overtake in a consistent manner for all types of supported android entry points. --- java/dagger/hilt/EntryPoint.java | 3 ++- .../hilt/android/internal/managers/BUILD | 1 + .../managers/TestInjectInterceptor.java | 23 +++++++++++++++++++ .../androidentrypoint/Generators.java | 7 ++++++ java/dagger/hilt/android/proguard-rules.pro | 3 ++- .../android/qualifiers/ActivityContext.java | 2 +- .../qualifiers/ApplicationContext.java | 3 +++ .../hilt/processor/internal/ClassNames.java | 2 ++ 8 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 java/dagger/hilt/android/internal/managers/TestInjectInterceptor.java diff --git a/java/dagger/hilt/EntryPoint.java b/java/dagger/hilt/EntryPoint.java index e216e77c0eb..23e3e1c88cf 100644 --- a/java/dagger/hilt/EntryPoint.java +++ b/java/dagger/hilt/EntryPoint.java @@ -17,6 +17,7 @@ package dagger.hilt; import static java.lang.annotation.RetentionPolicy.CLASS; +import static java.lang.annotation.RetentionPolicy.RUNTIME; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -44,7 +45,7 @@ * * @see Entry points */ -@Retention(CLASS) +@Retention(RUNTIME) @Target(ElementType.TYPE) @GeneratesRootInput public @interface EntryPoint {} diff --git a/java/dagger/hilt/android/internal/managers/BUILD b/java/dagger/hilt/android/internal/managers/BUILD index 950b51129ee..7b5a8838a81 100644 --- a/java/dagger/hilt/android/internal/managers/BUILD +++ b/java/dagger/hilt/android/internal/managers/BUILD @@ -33,6 +33,7 @@ android_library( "SavedStateHandleModule.java", "ServiceComponentManager.java", "ViewComponentManager.java", + "TestInjectInterceptor.java", ], exports = [":saved_state_handle_holder"], deps = [ diff --git a/java/dagger/hilt/android/internal/managers/TestInjectInterceptor.java b/java/dagger/hilt/android/internal/managers/TestInjectInterceptor.java new file mode 100644 index 00000000000..9bbed73e837 --- /dev/null +++ b/java/dagger/hilt/android/internal/managers/TestInjectInterceptor.java @@ -0,0 +1,23 @@ +package dagger.hilt.android.internal.managers; + +/** + * This class does nothing in production or in tests when running under Hilt testing framework. However, the calls + * to TestInjectInterceptor.injectForTesting() are done in a few strategic places just before Hilt does the injection + * into Android Components. + * + * As a result this class enables non-Hilt based frameworks to take over the injection process. + */ +public class TestInjectInterceptor { + /** + * This method always returns false by default. However, if this method is intercepted during testing + * by frameworks like Robolectric, the intercepting code can take over the injection process and + * instruct Hilt to skip doing anything extra for this instance. + * + * Return false if no custom injection was done and Hilt should continue as normal. Return true + * if the testing framework has takes over the injection process and Hilt should skip any extra + * work. + */ + public static boolean injectForTesting(Object injectTo) { + return false; + } +} \ No newline at end of file diff --git a/java/dagger/hilt/android/processor/internal/androidentrypoint/Generators.java b/java/dagger/hilt/android/processor/internal/androidentrypoint/Generators.java index dc2339b6991..a0970977901 100644 --- a/java/dagger/hilt/android/processor/internal/androidentrypoint/Generators.java +++ b/java/dagger/hilt/android/processor/internal/androidentrypoint/Generators.java @@ -379,6 +379,9 @@ && ancestorExtendsGeneratedHiltClass(metadata)) { methodSpecBuilder .beginControlFlow("if (!injected)") .addStatement("injected = true") + .beginControlFlow("if ($T.injectForTesting(this))", ClassNames.TEST_INJECT_INTERCEPTOR) + .addStatement("return") + .endControlFlow() .addStatement( "(($T) $L).$L($L)", metadata.injectorClassName(), @@ -395,6 +398,10 @@ && ancestorExtendsGeneratedHiltClass(metadata)) { .beginControlFlow("if (!injected)") .beginControlFlow("synchronized (injectedLock)") .beginControlFlow("if (!injected)") + .beginControlFlow("if ($T.injectForTesting(this))", ClassNames.TEST_INJECT_INTERCEPTOR) + .addStatement("injected = true") + .addStatement("return") + .endControlFlow() .addStatement( "(($T) $T.generatedComponent(context)).$L($L)", metadata.injectorClassName(), diff --git a/java/dagger/hilt/android/proguard-rules.pro b/java/dagger/hilt/android/proguard-rules.pro index 6fd3a82ead2..799ac372f1d 100644 --- a/java/dagger/hilt/android/proguard-rules.pro +++ b/java/dagger/hilt/android/proguard-rules.pro @@ -1,3 +1,4 @@ # Keep for the reflective cast done in EntryPoints. # See b/183070411#comment4 for more info. --keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class * \ No newline at end of file +-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class * +-assumenosideeffects class dagger.hilt.android.internal.managers.TestInjectInterceptor { *; } \ No newline at end of file diff --git a/java/dagger/hilt/android/qualifiers/ActivityContext.java b/java/dagger/hilt/android/qualifiers/ActivityContext.java index cfcc40efac4..ceb1841a4b5 100644 --- a/java/dagger/hilt/android/qualifiers/ActivityContext.java +++ b/java/dagger/hilt/android/qualifiers/ActivityContext.java @@ -24,6 +24,6 @@ /** Annotation for a {@code Context} that corresponds to the activity. */ @Qualifier -@Retention(RetentionPolicy.CLASS) +@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) public @interface ActivityContext {} diff --git a/java/dagger/hilt/android/qualifiers/ApplicationContext.java b/java/dagger/hilt/android/qualifiers/ApplicationContext.java index 226ef758514..769e36aa000 100644 --- a/java/dagger/hilt/android/qualifiers/ApplicationContext.java +++ b/java/dagger/hilt/android/qualifiers/ApplicationContext.java @@ -17,10 +17,13 @@ package dagger.hilt.android.qualifiers; import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import javax.inject.Qualifier; /** Annotation for an Application Context dependency. */ @Qualifier +@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) public @interface ApplicationContext {} diff --git a/java/dagger/hilt/processor/internal/ClassNames.java b/java/dagger/hilt/processor/internal/ClassNames.java index bc0591cb0ff..36e35f14ad4 100644 --- a/java/dagger/hilt/processor/internal/ClassNames.java +++ b/java/dagger/hilt/processor/internal/ClassNames.java @@ -162,6 +162,8 @@ public final class ClassNames { get("dagger.hilt.android.internal.testing", "InternalTestRoot"); public static final ClassName TEST_INJECTOR = get("dagger.hilt.android.internal.testing", "TestInjector"); + + public static final ClassName TEST_INJECT_INTERCEPTOR = get("dagger.hilt.android.internal.managers", "TestInjectInterceptor"); public static final ClassName TEST_APPLICATION_COMPONENT_MANAGER = get("dagger.hilt.android.internal.testing", "TestApplicationComponentManager"); public static final ClassName TEST_APPLICATION_COMPONENT_MANAGER_HOLDER =