From 400e756a2720df173d64df96be1184a92189679d Mon Sep 17 00:00:00 2001 From: Senthil Raja R Date: Tue, 16 Jun 2026 12:37:39 +0530 Subject: [PATCH] Add @HiltWorker annotation processor support with @AssistedInject integration Add support for Hilt injection of WorkManager ListenableWorker classes via the @HiltWorker annotation. This enables Hilt to inject dependencies into WorkManager workers, including support for @AssistedInject constructors. Changes include: - @HiltWorker annotation to mark Worker classes for Hilt injection - HiltWorkerMap qualifier for internal multibinding map - HiltWorkerProcessor (Javac) and KspHiltWorkerProcessor (KSP) - HiltWorkerProcessingStep that processes @HiltWorker annotations - HiltWorkerMetadata validation (checks: extends ListenableWorker, has @Inject/@AssistedInject constructor, non-private, non-scoped) - HiltWorkerModuleGenerator generates: - _HiltModules class with @Binds and @Provides module bindings - _AssistedFactory interface (for @AssistedInject workers) annotated with @AssistedFactory so Dagger generates the factory implementation - HiltWorkerValidationPlugin prevents direct injection of @HiltWorker classes - Tests for @Inject and @AssistedInject worker scenarios Fixes #4490 --- .../android/internal/work/HiltWorkerMap.java | 38 ++++ .../dagger/hilt/android/work/HiltWorker.java | 61 ++++++ .../processor/internal/AndroidClassNames.java | 9 + .../internal/worker/HiltWorkerMetadata.kt | 113 ++++++++++ .../worker/HiltWorkerModuleGenerator.kt | 186 ++++++++++++++++ .../worker/HiltWorkerProcessingStep.kt | 39 ++++ .../internal/worker/HiltWorkerProcessor.kt | 32 +++ .../worker/HiltWorkerValidationPlugin.kt | 102 +++++++++ .../internal/worker/KspHiltWorkerProcessor.kt | 38 ++++ .../worker/HiltWorkerProcessorTest.kt | 206 ++++++++++++++++++ 10 files changed, 824 insertions(+) create mode 100644 hilt-android/main/java/dagger/hilt/android/internal/work/HiltWorkerMap.java create mode 100644 hilt-android/main/java/dagger/hilt/android/work/HiltWorker.java create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerMetadata.kt create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerModuleGenerator.kt create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessingStep.kt create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessor.kt create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerValidationPlugin.kt create mode 100644 hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/KspHiltWorkerProcessor.kt create mode 100644 javatests/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessorTest.kt diff --git a/hilt-android/main/java/dagger/hilt/android/internal/work/HiltWorkerMap.java b/hilt-android/main/java/dagger/hilt/android/internal/work/HiltWorkerMap.java new file mode 100644 index 00000000000..35de388c3fc --- /dev/null +++ b/hilt-android/main/java/dagger/hilt/android/internal/work/HiltWorkerMap.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.internal.work; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.inject.Qualifier; + +/** + * Internal qualifier for the multibinding map of @HiltWorker workers. + */ +@Qualifier +@Retention(RetentionPolicy.CLASS) +@Target({ElementType.METHOD, ElementType.PARAMETER}) +public @interface HiltWorkerMap { + + /** Internal qualifier for the multibinding set of class names annotated with @HiltWorker. */ + @Qualifier + @Retention(RetentionPolicy.CLASS) + @Target({ElementType.METHOD, ElementType.PARAMETER}) + @interface KeySet {} +} diff --git a/hilt-android/main/java/dagger/hilt/android/work/HiltWorker.java b/hilt-android/main/java/dagger/hilt/android/work/HiltWorker.java new file mode 100644 index 00000000000..ca5ca0e0d3a --- /dev/null +++ b/hilt-android/main/java/dagger/hilt/android/work/HiltWorker.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.work; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.CLASS; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Annotates a class that is a WorkManager {@link + * androidx.work.ListenableWorker} to enable injection with Hilt. + * + *

Hilt currently supports the following types of workers: + * + *

+ * + *

The annotated class must have a single constructor annotated with + * {@code @Inject} or {@code @AssistedInject}. + * + *

Example usage with a Worker: + *

+ *   @HiltWorker
+ *   public class MyWorker extends Worker {
+ *     @Inject
+ *     public MyWorker(
+ *         @Assisted Context context,
+ *         @Assisted WorkerParameters workerParams,
+ *         MyDependency myDependency
+ *     ) {
+ *       super(context, workerParams);
+ *     }
+ *
+ *     @Override
+ *     public Result doWork() { ... }
+ *   }
+ * 
+ */ +@Documented +@Retention(CLASS) +@Target(TYPE) +public @interface HiltWorker {} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/AndroidClassNames.java b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/AndroidClassNames.java index 1236544eff8..e5a572c56a9 100644 --- a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/AndroidClassNames.java +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/AndroidClassNames.java @@ -137,5 +137,14 @@ public final class AndroidClassNames { public static final ClassName INJECT_VIA_ON_CONTEXT_AVAILABLE_LISTENER = get("dagger.hilt.android", "InjectViaOnContextAvailableListener"); + // WorkManager-related class names + public static final ClassName LISTENABLE_WORKER = get("androidx.work", "ListenableWorker"); + public static final ClassName WORKER_PARAMETERS = get("androidx.work", "WorkerParameters"); + public static final ClassName HILT_WORKER = get("dagger.hilt.android.work", "HiltWorker"); + public static final ClassName HILT_WORKER_MAP_QUALIFIER = + get("dagger.hilt.android.internal.work", "HiltWorkerMap"); + public static final ClassName HILT_WORKER_KEYS_QUALIFIER = + get("dagger.hilt.android.internal.work", "HiltWorkerMap", "KeySet"); + private AndroidClassNames() {} } diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerMetadata.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerMetadata.kt new file mode 100644 index 00000000000..b4845817b7d --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerMetadata.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.codegen.toJavaPoet +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import androidx.room3.compiler.processing.XProcessingEnv +import androidx.room3.compiler.processing.XTypeElement +import com.squareup.javapoet.ClassName +import dagger.hilt.android.processor.internal.AndroidClassNames +import dagger.hilt.processor.internal.ClassNames +import dagger.hilt.processor.internal.LazyString +import dagger.hilt.processor.internal.ProcessorErrors +import dagger.hilt.processor.internal.Processors +import dagger.internal.codegen.xprocessing.XAnnotations +import dagger.internal.codegen.xprocessing.XElements +import dagger.internal.codegen.xprocessing.XTypes + +@OptIn( + ExperimentalProcessingApi::class, + com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview::class +) +internal class HiltWorkerMetadata +private constructor( + val workerElement: XTypeElement, + val isAssistedInject: Boolean, +) { + val className = workerElement.asClassName().toJavaPoet() + + val assistedFactoryClassName: ClassName = + ClassName.get(workerElement.packageName, "${className.simpleNames().joinToString("_")}_AssistedFactory") + + val modulesClassName = + ClassName.get( + workerElement.packageName, + "${className.simpleNames().joinToString("_")}_HiltModules", + ) + + companion object { + internal fun create( + processingEnv: XProcessingEnv, + workerElement: XTypeElement, + ): HiltWorkerMetadata? { + ProcessorErrors.checkState( + XTypes.isSubtype( + workerElement.type, + processingEnv.requireType(AndroidClassNames.LISTENABLE_WORKER), + ), + workerElement, + "@HiltWorker is only supported on types that subclass %s.", + AndroidClassNames.LISTENABLE_WORKER, + ) + + val injectConstructors = + workerElement.getConstructors().filter { constructor -> + Processors.isAnnotatedWithInject(constructor) || + constructor.hasAnnotation(ClassNames.ASSISTED_INJECT) + } + + ProcessorErrors.checkState( + injectConstructors.size == 1, + workerElement, + "@HiltWorker annotated class should contain exactly one @Inject or @AssistedInject annotated constructor.", + ) + + val injectConstructor = injectConstructors.single() + + ProcessorErrors.checkState( + !injectConstructor.isPrivate(), + injectConstructor, + "%s annotated constructors must not be private.", + if (injectConstructor.hasAnnotation(ClassNames.ASSISTED_INJECT)) { + "@Inject or @AssistedInject" + } else { + "@Inject" + }, + ) + + ProcessorErrors.checkState( + !workerElement.isNested() || workerElement.isStatic(), + workerElement, + "@HiltWorker may only be used on inner classes if they are static.", + ) + + Processors.getScopeAnnotations(workerElement).let { scopeAnnotations -> + ProcessorErrors.checkState( + scopeAnnotations.isEmpty(), + workerElement, + "@HiltWorker classes should not be scoped. Found: %s", + LazyString.of { scopeAnnotations.joinToString { XAnnotations.toStableString(it) } }, + ) + } + + val isAssistedInject = injectConstructor.hasAnnotation(ClassNames.ASSISTED_INJECT) + + return HiltWorkerMetadata(workerElement, isAssistedInject) + } + } +} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerModuleGenerator.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerModuleGenerator.kt new file mode 100644 index 00000000000..05fc8d71abe --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerModuleGenerator.kt @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import androidx.room3.compiler.processing.XProcessingEnv +import androidx.room3.compiler.processing.addOriginatingElement +import com.squareup.javapoet.AnnotationSpec +import com.squareup.javapoet.ClassName +import com.squareup.javapoet.JavaFile +import com.squareup.javapoet.MethodSpec +import com.squareup.javapoet.TypeName +import com.squareup.javapoet.TypeSpec +import dagger.hilt.android.processor.internal.AndroidClassNames +import dagger.hilt.processor.internal.ClassNames +import dagger.hilt.processor.internal.Processors +import javax.lang.model.element.Modifier + +@OptIn( + ExperimentalProcessingApi::class, + com.squareup.kotlinpoet.javapoet.KotlinPoetJavaPoetPreview::class +) +internal class HiltWorkerModuleGenerator( + private val processingEnv: XProcessingEnv, + private val workerMetadata: HiltWorkerMetadata, +) { + fun generate() { + generateModules() + + if (workerMetadata.isAssistedInject) { + generateAssistedFactory() + } + } + + private fun generateModules() { + val modulesTypeSpec = + TypeSpec.classBuilder(workerMetadata.modulesClassName) + .apply { + addOriginatingElement(workerMetadata.workerElement) + Processors.addGeneratedAnnotation(this, processingEnv, HiltWorkerProcessor::class.java) + addAnnotation( + AnnotationSpec.builder(ClassNames.ORIGINATING_ELEMENT) + .addMember( + "topLevelClass", + "$T.class", + workerMetadata.className.topLevelClassName(), + ) + .build() + ) + addModifiers(Modifier.PUBLIC, Modifier.FINAL) + addType(getBindsModuleTypeSpec()) + addType(getKeyModuleTypeSpec()) + addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()) + } + .build() + + processingEnv.filer.write( + JavaFile.builder(workerMetadata.modulesClassName.packageName(), modulesTypeSpec).build() + ) + } + + private fun getBindsModuleTypeSpec() = + createModuleTypeSpec("BindsModule") + .addModifiers(Modifier.ABSTRACT) + .addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()) + .addMethod( + if (workerMetadata.isAssistedInject) getAssistedWorkerBindsMethod() + else getWorkerBindsMethod() + ) + .build() + + private fun getWorkerBindsMethod() = + MethodSpec.methodBuilder("binds") + .addAnnotation(ClassNames.BINDS) + .addAnnotation(ClassNames.INTO_MAP) + .addAnnotation( + AnnotationSpec.builder(ClassNames.LAZY_CLASS_KEY) + .addMember("value", "$T.class", workerMetadata.className) + .build() + ) + .addAnnotation(AndroidClassNames.HILT_WORKER_MAP_QUALIFIER) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .returns(AndroidClassNames.LISTENABLE_WORKER) + .addParameter(workerMetadata.className, "worker") + .build() + + private fun getAssistedWorkerBindsMethod() = + MethodSpec.methodBuilder("bind") + .addAnnotation(ClassNames.BINDS) + .addAnnotation(ClassNames.INTO_MAP) + .addAnnotation( + AnnotationSpec.builder(ClassNames.LAZY_CLASS_KEY) + .addMember("value", "$T.class", workerMetadata.className) + .build() + ) + .addAnnotation(AndroidClassNames.HILT_WORKER_MAP_QUALIFIER) + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .addParameter(workerMetadata.assistedFactoryClassName, "factory") + .returns(TypeName.OBJECT) + .build() + + private fun getKeyModuleTypeSpec() = + createModuleTypeSpec("KeyModule") + .addModifiers(Modifier.FINAL) + .addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build()) + .addMethod(getWorkerKeyProvidesMethod()) + .build() + + private fun getWorkerKeyProvidesMethod() = + MethodSpec.methodBuilder("provide") + .addAnnotation(ClassNames.PROVIDES) + .addAnnotation(ClassNames.INTO_MAP) + .addAnnotation( + AnnotationSpec.builder(ClassNames.LAZY_CLASS_KEY) + .addMember("value", "$T.class", workerMetadata.className) + .build() + ) + .addAnnotation(AndroidClassNames.HILT_WORKER_KEYS_QUALIFIER) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(Boolean::class.java) + .addStatement("return true") + .build() + + private fun createModuleTypeSpec(innerClassName: String) = + TypeSpec.classBuilder(innerClassName) + .addOriginatingElement(workerMetadata.workerElement) + .addAnnotation(ClassNames.MODULE) + .addAnnotation( + AnnotationSpec.builder(ClassNames.INSTALL_IN) + .addMember("value", "$T.class", ClassNames.SINGLETON_COMPONENT) + .build() + ) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + + /** + * Generates an @AssistedFactory interface for the worker class. + * + * Should generate: + * ``` + * @AssistedFactory + * interface PeriodicReminderWorker_AssistedFactory { + * PeriodicReminderWorker create(Context context, WorkerParameters params); + * } + * ``` + */ + private fun generateAssistedFactory() { + val factoryTypeSpec = + TypeSpec.interfaceBuilder(workerMetadata.assistedFactoryClassName) + .apply { + addOriginatingElement(workerMetadata.workerElement) + Processors.addGeneratedAnnotation(this, processingEnv, HiltWorkerProcessor::class.java) + addAnnotation(ClassNames.ASSISTED_FACTORY) + addModifiers(Modifier.PUBLIC) + addMethod(getCreateMethod()) + } + .build() + + processingEnv.filer.write( + JavaFile.builder( + workerMetadata.assistedFactoryClassName.packageName(), factoryTypeSpec + ).build() + ) + } + + private fun getCreateMethod() = + MethodSpec.methodBuilder("create") + .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT) + .addParameter(AndroidClassNames.CONTEXT, "context") + .addParameter(AndroidClassNames.WORKER_PARAMETERS, "workerParameters") + .returns(workerMetadata.className) + .build() +} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessingStep.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessingStep.kt new file mode 100644 index 00000000000..b50f567cf57 --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessingStep.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import androidx.room3.compiler.processing.XElement +import androidx.room3.compiler.processing.XProcessingEnv +import com.google.common.collect.ImmutableSet +import com.squareup.javapoet.ClassName +import dagger.hilt.android.processor.internal.AndroidClassNames +import dagger.hilt.processor.internal.BaseProcessingStep +import dagger.internal.codegen.xprocessing.XElements + +@OptIn(ExperimentalProcessingApi::class) +class HiltWorkerProcessingStep(env: XProcessingEnv) : BaseProcessingStep(env) { + + override fun annotationClassNames() = ImmutableSet.of(AndroidClassNames.HILT_WORKER) + + override fun processEach(annotation: ClassName, element: XElement) { + val typeElement = XElements.asTypeElement(element) + HiltWorkerMetadata.create(processingEnv(), typeElement)?.let { workerMetadata -> + HiltWorkerModuleGenerator(processingEnv(), workerMetadata).generate() + } + } +} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessor.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessor.kt new file mode 100644 index 00000000000..84cad03f6fa --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import com.google.auto.service.AutoService +import dagger.hilt.processor.internal.BaseProcessingStep +import dagger.hilt.processor.internal.JavacBaseProcessingStepProcessor +import javax.annotation.processing.Processor +import net.ltgt.gradle.incap.IncrementalAnnotationProcessor +import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType + +@AutoService(Processor::class) +@IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.ISOLATING) +class HiltWorkerProcessor : JavacBaseProcessingStepProcessor() { + @OptIn(ExperimentalProcessingApi::class) + override fun processingStep(): BaseProcessingStep = HiltWorkerProcessingStep(xProcessingEnv) +} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerValidationPlugin.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerValidationPlugin.kt new file mode 100644 index 00000000000..24278f94945 --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/HiltWorkerValidationPlugin.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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. + */ + +@file:OptIn(ExperimentalProcessingApi::class) + +package dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import androidx.room3.compiler.processing.XProcessingEnv +import androidx.room3.compiler.processing.XProcessingEnv.Companion.create +import com.google.auto.service.AutoService +import com.google.common.graph.EndpointPair +import com.google.common.graph.ImmutableNetwork +import dagger.hilt.android.processor.internal.AndroidClassNames +import dagger.hilt.processor.internal.hasAnnotation +import dagger.spi.model.Binding +import dagger.spi.model.BindingGraph +import dagger.spi.model.BindingGraph.Edge +import dagger.spi.model.BindingGraph.Node +import dagger.spi.model.BindingGraphPlugin +import dagger.spi.model.BindingKind +import dagger.spi.model.DaggerProcessingEnv +import dagger.spi.model.DiagnosticReporter +import javax.tools.Diagnostic.Kind + +/** + * Plugin to validate users do not inject @HiltWorker classes directly. + */ +@AutoService(BindingGraphPlugin::class) +class HiltWorkerValidationPlugin : BindingGraphPlugin { + + private lateinit var env: XProcessingEnv + private lateinit var daggerProcessingEnv: DaggerProcessingEnv + + override fun init(processingEnv: DaggerProcessingEnv, options: MutableMap) { + daggerProcessingEnv = processingEnv + } + + override fun onProcessingRoundBegin() { + env = daggerProcessingEnv.toXProcessingEnv() + } + + override fun visitGraph(bindingGraph: BindingGraph, diagnosticReporter: DiagnosticReporter) { + if (bindingGraph.rootComponentNode().isSubcomponent()) { + return + } + + val network: ImmutableNetwork = bindingGraph.network() + bindingGraph.dependencyEdges().forEach { edge -> + val pair: EndpointPair = network.incidentNodes(edge) + val target: Node = pair.target() + val source: Node = pair.source() + if (target !is Binding) { + return@forEach + } + if (isHiltWorkerBinding(target) && !isInternalHiltWorkerUsage(source)) { + diagnosticReporter.reportDependency( + Kind.ERROR, + edge, + "\nInjection of an @HiltWorker class is prohibited since it does not create a " + + "Worker instance correctly.\nAccess the Worker via the WorkManager APIs " + + "(e.g. WorkManager.enqueue()) instead." + + "\nInjected Worker: ${target.key().type()}\n", + ) + } + } + } + + private fun isHiltWorkerBinding(target: Binding): Boolean { + return target.kind() == BindingKind.INJECTION && + target.key().type().hasAnnotation(AndroidClassNames.HILT_WORKER) + } + + private fun isInternalHiltWorkerUsage(source: Node): Boolean { + return source is Binding && + source.key().qualifier().isPresent() && + source.key().qualifier().get().getQualifiedName() == + AndroidClassNames.HILT_WORKER_MAP_QUALIFIER.canonicalName() && + source.key().multibindingContributionIdentifier().isPresent() + } +} + +private fun DaggerProcessingEnv.toXProcessingEnv(): XProcessingEnv { + return when (backend()) { + DaggerProcessingEnv.Backend.JAVAC -> create(javac()) + DaggerProcessingEnv.Backend.KSP -> create(ksp(), resolver()) + else -> error("Backend ${backend()} not supported yet.") + } +} diff --git a/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/KspHiltWorkerProcessor.kt b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/KspHiltWorkerProcessor.kt new file mode 100644 index 00000000000..7fbac1dd457 --- /dev/null +++ b/hilt-compiler/main/java/dagger/hilt/android/processor/internal/worker/KspHiltWorkerProcessor.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import com.google.auto.service.AutoService +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import dagger.hilt.processor.internal.BaseProcessingStep +import dagger.hilt.processor.internal.KspBaseProcessingStepProcessor + +class KspHiltWorkerProcessor(symbolProcessorEnvironment: SymbolProcessorEnvironment?) : + KspBaseProcessingStepProcessor(symbolProcessorEnvironment) { + @OptIn(ExperimentalProcessingApi::class) + override fun processingStep(): BaseProcessingStep = HiltWorkerProcessingStep(xProcessingEnv) + + @AutoService(SymbolProcessorProvider::class) + class Provider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return KspHiltWorkerProcessor(environment) + } + } +} diff --git a/javatests/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessorTest.kt b/javatests/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessorTest.kt new file mode 100644 index 00000000000..ca3a92618d6 --- /dev/null +++ b/javatests/dagger/hilt/android/processor/internal/worker/HiltWorkerProcessorTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2024 The Dagger Authors. + * + * 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 dagger.hilt.android.processor.internal.worker + +import androidx.room3.compiler.processing.ExperimentalProcessingApi +import dagger.hilt.android.testing.compile.HiltCompilerTests +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalProcessingApi::class) +@RunWith(JUnit4::class) +class HiltWorkerProcessorTest { + @Test + fun validWorkerWithInject() { + val myWorker = + HiltCompilerTests.javaSource( + "dagger.hilt.android.test.MyWorker", + """ + package dagger.hilt.android.test; + + import androidx.work.ListenableWorker; + import androidx.work.WorkerParameters; + import android.content.Context; + import dagger.hilt.android.work.HiltWorker; + import javax.inject.Inject; + + @HiltWorker + public class MyWorker extends ListenableWorker { + @Inject + public MyWorker( + @dagger.assisted.Assisted Context context, + @dagger.assisted.Assisted WorkerParameters workerParams + ) { + super(context, workerParams); + } + } + """ + .trimIndent() + ) + HiltCompilerTests.hiltCompiler(myWorker) + .withAdditionalJavacProcessors(HiltWorkerProcessor()) + .withAdditionalKspProcessors(KspHiltWorkerProcessor.Provider()) + .compile { subject -> subject.hasErrorCount(0) } + } + + @Test + fun validWorkerWithAssistedInject() { + val myWorker = + HiltCompilerTests.javaSource( + "dagger.hilt.android.test.MyWorker", + """ + package dagger.hilt.android.test; + + import androidx.work.ListenableWorker; + import androidx.work.WorkerParameters; + import android.content.Context; + import dagger.assisted.Assisted; + import dagger.assisted.AssistedInject; + import dagger.hilt.android.work.HiltWorker; + + @HiltWorker + public class MyWorker extends ListenableWorker { + @AssistedInject + public MyWorker( + @Assisted Context context, + @Assisted WorkerParameters workerParams, + String dependency + ) { + super(context, workerParams); + } + } + """ + .trimIndent() + ) + HiltCompilerTests.hiltCompiler(myWorker) + .withAdditionalJavacProcessors(HiltWorkerProcessor()) + .withAdditionalKspProcessors(KspHiltWorkerProcessor.Provider()) + .compile { subject -> subject.hasErrorCount(0) } + } + + @Test + fun verifyWorkerExtendsListenableWorker() { + val myWorker = + HiltCompilerTests.javaSource( + "dagger.hilt.android.test.MyWorker", + """ + package dagger.hilt.android.test; + + import dagger.hilt.android.work.HiltWorker; + import javax.inject.Inject; + + @HiltWorker + public class MyWorker { + @Inject + public MyWorker() { } + } + """ + .trimIndent() + ) + + HiltCompilerTests.hiltCompiler(myWorker) + .withAdditionalJavacProcessors(HiltWorkerProcessor()) + .withAdditionalKspProcessors(KspHiltWorkerProcessor.Provider()) + .compile { subject -> + subject.compilationDidFail() + subject.hasErrorCount(1) + subject.hasErrorContainingMatch( + "@HiltWorker is only supported on types that subclass androidx.work.ListenableWorker." + ) + } + } + + @Test + fun verifyHasInjectConstructor() { + val myWorker = + HiltCompilerTests.javaSource( + "dagger.hilt.android.test.MyWorker", + """ + package dagger.hilt.android.test; + + import androidx.work.ListenableWorker; + import androidx.work.WorkerParameters; + import android.content.Context; + import dagger.hilt.android.work.HiltWorker; + + @HiltWorker + public class MyWorker extends ListenableWorker { + public MyWorker( + Context context, + WorkerParameters workerParams + ) { + super(context, workerParams); + } + } + """ + .trimIndent() + ) + + HiltCompilerTests.hiltCompiler(myWorker) + .withAdditionalJavacProcessors(HiltWorkerProcessor()) + .withAdditionalKspProcessors(KspHiltWorkerProcessor.Provider()) + .compile { subject -> + subject.compilationDidFail() + subject.hasErrorCount(1) + subject.hasErrorContaining( + "@HiltWorker annotated class should contain exactly one @Inject or @AssistedInject annotated constructor." + ) + } + } + + @Test + fun verifyNonPrivateConstructor() { + val myWorker = + HiltCompilerTests.javaSource( + "dagger.hilt.android.test.MyWorker", + """ + package dagger.hilt.android.test; + + import androidx.work.ListenableWorker; + import androidx.work.WorkerParameters; + import android.content.Context; + import dagger.hilt.android.work.HiltWorker; + import javax.inject.Inject; + + @HiltWorker + public class MyWorker extends ListenableWorker { + @Inject + private MyWorker( + Context context, + WorkerParameters workerParams + ) { + super(context, workerParams); + } + } + """ + .trimIndent() + ) + + HiltCompilerTests.hiltCompiler(myWorker) + .withAdditionalJavacProcessors(HiltWorkerProcessor()) + .withAdditionalKspProcessors(KspHiltWorkerProcessor.Provider()) + .compile { subject -> + subject.compilationDidFail() + subject.hasErrorCount(2) + subject.hasErrorContaining("Dagger does not support injection into private constructors") + subject.hasErrorContaining( + "@Inject annotated constructors must not be private." + ) + } + } +}