diff --git a/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt new file mode 100644 index 0000000000..9a858e51a8 --- /dev/null +++ b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesNode.kt @@ -0,0 +1,23 @@ +package io.element.android.x.anvilannotations + +import kotlin.reflect.KClass + +/** + * Adds Node to the specified component graph. + * Equivalent to the following declaration: + * + * @Module + * @ContributesTo(Scope::class) + * abstract class YourNodeModule { + + * @Binds + * @IntoMap + * @NodeKey(YourNode::class) + * abstract fun bindYourNodeFactory(factory: YourNode.Factory): AssistedNodeFactory<*> + *} + + */ +@Target(AnnotationTarget.CLASS) +annotation class ContributesNode( + val scope: KClass<*>, +) diff --git a/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt b/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt new file mode 100644 index 0000000000..6a2d0df036 --- /dev/null +++ b/anvilcodegen/src/main/java/io/element/android/x/anvilcodegen/ContributesNodeCodeGenerator.kt @@ -0,0 +1,138 @@ +@file:OptIn(ExperimentalAnvilApi::class) + +package io.element.android.x.anvilcodegen + +import com.google.auto.service.AutoService +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.ExperimentalAnvilApi +import com.squareup.anvil.compiler.api.AnvilCompilationException +import com.squareup.anvil.compiler.api.AnvilContext +import com.squareup.anvil.compiler.api.CodeGenerator +import com.squareup.anvil.compiler.api.GeneratedFile +import com.squareup.anvil.compiler.api.createGeneratedFile +import com.squareup.anvil.compiler.internal.asClassName +import com.squareup.anvil.compiler.internal.buildFile +import com.squareup.anvil.compiler.internal.fqName +import com.squareup.anvil.compiler.internal.reference.ClassReference +import com.squareup.anvil.compiler.internal.reference.asClassName +import com.squareup.anvil.compiler.internal.reference.classAndInnerClassReferences +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeSpec +import dagger.Binds +import dagger.Module +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.multibindings.IntoMap +import io.element.android.x.anvilannotations.ContributesNode +import org.jetbrains.kotlin.descriptors.ModuleDescriptor +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile +import java.io.File + +/** + * This is an anvil plugin that allows Node to use [ContributesNode] alone and let this plugin automatically + * handle the rest of the Dagger wiring required for constructor injection. + */ +@AutoService(CodeGenerator::class) +class ContributesNodeCodeGenerator : CodeGenerator { + + override fun isApplicable(context: AnvilContext): Boolean = true + + override fun generateCode(codeGenDir: File, module: ModuleDescriptor, projectFiles: Collection): Collection { + return projectFiles.classAndInnerClassReferences(module) + .filter { it.isAnnotatedWith(ContributesNode::class.fqName) } + .flatMap { listOf(generateModule(it, codeGenDir, module), generateAssistedFactory(it, codeGenDir, module)) } + .toList() + } + + private fun generateModule(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val moduleClassName = "${nodeClass.shortName}_Module" + val scope = nodeClass.annotations.single { it.fqName == ContributesNode::class.fqName }.scope() + val content = FileSpec.buildFile(generatedPackage, moduleClassName) { + addType( + TypeSpec.classBuilder(moduleClassName) + .addModifiers(KModifier.ABSTRACT) + .addAnnotation(Module::class) + .addAnnotation(AnnotationSpec.builder(ContributesTo::class).addMember("%T::class", scope.asClassName()).build()) + .addFunction( + FunSpec.builder("bind${nodeClass.shortName}Factory") + .addModifiers(KModifier.ABSTRACT) + .addParameter("factory", ClassName(generatedPackage, "${nodeClass.shortName}_AssistedFactory")) + .returns(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(STAR)) + .addAnnotation(Binds::class) + .addAnnotation(IntoMap::class) + .addAnnotation( + AnnotationSpec.Companion.builder(nodeKeyFqName.asClassName(module)).addMember( + "%T::class", + nodeClass.asClassName() + ).build() + ) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, moduleClassName, content) + } + + private fun generateAssistedFactory(nodeClass: ClassReference.Psi, codeGenDir: File, module: ModuleDescriptor): GeneratedFile { + val generatedPackage = nodeClass.packageFqName.toString() + val assistedFactoryClassName = "${nodeClass.shortName}_AssistedFactory" + val constructor = nodeClass.constructors.singleOrNull { it.isAnnotatedWith(AssistedInject::class.fqName) } + val assistedParameters = constructor?.parameters?.filter { it.isAnnotatedWith(Assisted::class.fqName) }.orEmpty() + if (constructor == null || assistedParameters.size != 2) { + throw AnvilCompilationException( + "${nodeClass.fqName} must have an @AssistedInject constructor with 2 @Assisted parameters", + element = nodeClass.clazz, + ) + } + val contextAssistedParam = assistedParameters[0] + if (contextAssistedParam.name != "buildContext") { + throw AnvilCompilationException( + "${nodeClass.fqName} @Assisted parameter must be named buildContext", + element = contextAssistedParam.parameter, + ) + } + val pluginsAssistedParam = assistedParameters[1] + if (pluginsAssistedParam.name != "plugins") { + throw AnvilCompilationException( + "${nodeClass.fqName} @Assisted parameter must be named plugins", + element = pluginsAssistedParam.parameter, + ) + } + + val nodeClassName = nodeClass.asClassName() + val buildContextClassName = contextAssistedParam.type().asTypeName() + val pluginsClassName = pluginsAssistedParam.type().asTypeName() + val content = FileSpec.buildFile(generatedPackage, assistedFactoryClassName) { + addType( + TypeSpec.interfaceBuilder(assistedFactoryClassName) + .addSuperinterface(assistedNodeFactoryFqName.asClassName(module).parameterizedBy(nodeClassName)) + .addAnnotation(AssistedFactory::class) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE, KModifier.ABSTRACT) + .addParameter("buildContext", buildContextClassName) + .addParameter("plugins", pluginsClassName) + .returns(nodeClassName) + .build(), + ) + .build(), + ) + } + return createGeneratedFile(codeGenDir, generatedPackage, assistedFactoryClassName, content) + } + + companion object { + private val assistedNodeFactoryFqName = FqName("io.element.android.x.architecture.AssistedNodeFactory") + private val nodeKeyFqName = FqName("io.element.android.x.architecture.NodeKey") + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListModule.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListModule.kt deleted file mode 100644 index 698aa4b083..0000000000 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.element.android.x.features.roomlist - -import com.squareup.anvil.annotations.ContributesTo -import dagger.Binds -import dagger.Module -import dagger.multibindings.IntoMap -import io.element.android.x.architecture.AssistedNodeFactory -import io.element.android.x.architecture.NodeKey -import io.element.android.x.di.SessionScope - -@Module -@ContributesTo(SessionScope::class) -abstract class RoomListModule { - - @Binds - @IntoMap - @NodeKey(RoomListNode::class) - abstract fun bindRoomListNodeFactory(factory: RoomListNode.Factory): AssistedNodeFactory<*> -} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt index 72983da1a8..8a994fcd07 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt @@ -9,24 +9,20 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import io.element.android.x.architecture.AssistedNodeFactory +import io.element.android.x.anvilannotations.ContributesNode +import io.element.android.x.architecture.presenterConnector +import io.element.android.x.di.SessionScope import io.element.android.x.features.roomlist.model.RoomListEvents import io.element.android.x.matrix.core.RoomId -import io.element.android.x.architecture.presenterConnector +@ContributesNode(SessionScope::class) class RoomListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenter: RoomListPresenter, ) : Node(buildContext, plugins = plugins) { - @AssistedFactory - interface Factory : AssistedNodeFactory { - override fun create(buildContext: BuildContext, plugins: List): RoomListNode - } - interface Callback : Plugin { fun onRoomClicked(roomId: RoomId) }