From c162fa08041d67cf736a9e791d9b03c5d9f02ee2 Mon Sep 17 00:00:00 2001 From: Ralf Wondratschek Date: Fri, 25 Oct 2024 18:51:09 -0700 Subject: [PATCH] Support `expect / actual` for generated factory functions using `@CreateComponent` Due to how Kotlin 2.0 and newer handles source sets, it's likely that the generated factory function for generated components cannot be referenced from common Kotlin code in KMP projects. This change adds a new annotation `@MergeComponent.CreateComponent` to allow defining an `expect fun` in common code where the `actual fun` will be generated. This closes the gap. Fixes #20 --- README.md | 75 +++++-- .../kotlin/inject/anvil/ContextAware.kt | 4 +- ...nInjectExtensionSymbolProcessorProvider.kt | 7 + .../processor/CreateComponentProcessor.kt | 177 +++++++++++++++ .../kotlin/inject/anvil/Compilation.kt | 2 + .../processor/CreateComponentProcessorTest.kt | 207 ++++++++++++++++++ runtime/api/android/runtime.api | 3 + runtime/api/jvm/runtime.api | 3 + .../kotlin/inject/anvil/MergeComponent.kt | 50 ++++- sample/app/build.gradle | 5 +- .../inject/anvil/sample/IosAppComponent.kt | 16 +- .../anvil/sample/IosAppComponentTest.kt | 2 +- sample/lib/build.gradle | 5 +- 13 files changed, 526 insertions(+), 30 deletions(-) create mode 100644 compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessor.kt create mode 100644 compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessorTest.kt rename sample/app/src/{iosSimulatorArm64Main => iosMain}/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt (68%) rename sample/app/src/{iosSimulatorArm64Test => iosTest}/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt (86%) diff --git a/README.md b/README.md index 4684148..8eecc7d 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,16 @@ interface AppIdComponent { class RealAuthenticator : Authenticator // The final kotlin-inject component. -// see the section on "Usage > Merging" to understand -// how AppComponentMerged is generated and must be used. -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) -interface AppComponent : AppComponentMerged +interface AppComponent + +// Instantiate the component at runtime. +val component = AppComponent::class.create() ``` From the above example code snippet: -* `AppIdComponent` will be made a super type of `AppComponent` and the +* `AppIdComponent` will be made a super type of the final component and the provider method is known to the object graph, so you can inject and use AppId anywhere. * A binding for `RealAuthenticator` will be generated and the type `Authenticator` can safely be injected anywhere. * Note that neither `AppIdComponent` nor `RealAuthenticator` need to be referenced anywhere else in your code. @@ -129,7 +129,6 @@ add it to the final component. @ContributesBinding(AppScope::class, multibinding = true) class LoggingInterceptor : Interceptor -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) abstract class AppComponent { @@ -165,23 +164,69 @@ object graph at runtime: @SingleIn(AppScope::class) interface AppComponent ``` -In order to pick up all contributions, you must add the `@MergeComponent` annotation: +In order to pick up all contributions, you must change the `@Component` annotation to +`@MergeComponent`: ```kotlin -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) interface AppComponent ``` -This will generate a new interface `AppComponentMerged` in the same package as `AppComponent`. -This generated interface must be added as super type: +This will generate a new component class with the original `@Component` annotation and merge all +contributions to the scope `AppScope`. + +To instantiate the component at runtime, call the generated `create()` function: +```kotlin +val component = AppComponent::class.create() +``` + +#### Parameters + +Parameters are supported the same way as with `kotlin-inject`: ```kotlin -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) -interface AppComponent : AppComponentMerged +abstract class AppComponent( + @get:Provides val userId: String, +) + +val component = AppComponent::class.create("userId") +``` + +#### Kotlin Multiplatform + +With Kotlin Multiplatform there is a high chance that the generated code cannot be referenced +from common Kotlin code or from common platform code like `iosMain`. This is due to how +[common source folders are separated from platform source folders](https://kotlinlang.org/docs/whatsnew20.html#separation-of-common-and-platform-sources-during-compilation). +For more details and recommendations setting up kotlin-inject in Kotlin Multiplatform projects +see the [official guide](https://github.com/evant/kotlin-inject/blob/main/docs/multiplatform.md). + +To address this issue, you can define an `expect fun` in the common source code next to +component class itself. The `actual fun` will be generated and create the component. The +function must be annotated with `@MergeComponent.CreateComponent`. It's optional to have a +receiver type of `KClass` with your component type as argument. The number of parameters +must match the arguments of your component and the return type must be your component, e.g. +your component in common code could be declared as: +```kotlin +@MergeComponent(AppScope::class) +@SingleIn(AppScope::class) +abstract class AppComponent( + @get:Provides userId: String, +) + +// Create this function next to your component class. The actual function will be generated. +@CreateComponent +expect fun create(appId: String): AppComponent + +// Or with receiver type: +@CreateComponent +expect fun KClass.create(appId: String): AppComponent +``` +The generated `actual fun` will be generated and will look like this: +```kotlin +actual fun create(appId: String): AppComponent { + return KotlinInjectAppComponent::class.create(appId) +} ``` -With this setup any contribution is automatically merged. These steps have to be repeated for -every component in your project. ### Scopes @@ -202,7 +247,6 @@ the `kotlin-inject` components or to make instances a singleton in a scope, e.g. @ContributesBinding(AppScope::class) class RealAuthenticator : Authenticator -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) // scope for kotlin-inject interface AppComponent @@ -238,6 +282,7 @@ and build logic on top of them. For example, assume this is your annotation: ```kotlin @Target(CLASS) +@ContributingAnnotation // see below for details annotation class MyCustomAnnotation ``` diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt index f0a4fc7..792cdc9 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt @@ -54,10 +54,10 @@ internal interface ContextAware { } fun checkIsPublic( - clazz: KSClassDeclaration, + declaration: KSDeclaration, lazyMessage: () -> String = { "Contributed component interfaces must be public." }, ) { - check(clazz.getVisibility() == Visibility.PUBLIC, clazz, lazyMessage) + check(declaration.getVisibility() == Visibility.PUBLIC, declaration, lazyMessage) } fun checkIsInterface( diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt index dfb9c02..b027df8 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt @@ -8,6 +8,7 @@ import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesBinding import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesSubcomponentFactoryProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesSubcomponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesToProcessor +import software.amazon.lastmile.kotlin.inject.anvil.processor.CreateComponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.GenerateKotlinInjectComponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.MergeComponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.extend.ContributingAnnotationProcessor @@ -71,6 +72,12 @@ class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvider { logger = environment.logger, ), ) + addIfEnabled( + CreateComponentProcessor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + ), + ) } return CompositeSymbolProcessor(symbolProcessors) diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessor.kt new file mode 100644 index 0000000..047e3ec --- /dev/null +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessor.kt @@ -0,0 +1,177 @@ +@file:OptIn(KspExperimental::class) + +package software.amazon.lastmile.kotlin.inject.anvil.processor + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSTypeReference +import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier.ACTUAL +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.asTypeName +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import me.tatarka.inject.annotations.Component +import software.amazon.lastmile.kotlin.inject.anvil.ContextAware +import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent +import software.amazon.lastmile.kotlin.inject.anvil.requireQualifiedName +import kotlin.reflect.KClass + +/** + * This processor will generate a function to make instantiating a generated kotlin-inject + * component easier. The function delegates the call to the final kotlin-inject component. + * ``` + * package software.amazon.test + * + * @MergeComponent(AppScope::class) + * @SingleIn(AppScope::class) + * abstract class TestComponent( + * @get:Provides val string: String, + * ) + * + * @CreateComponent + * expect fun createTestComponent(string: String): TestComponent + * ``` + * Will generate: + * ``` + * actual fun createTestComponent(string: String): TestComponent { + * return KotlinInjectTestComponent::class.create(string) + * } + * ``` + */ +internal class CreateComponentProcessor( + private val codeGenerator: CodeGenerator, + override val logger: KSPLogger, +) : SymbolProcessor, ContextAware { + + private val kclassFqName = KClass::class.requireQualifiedName() + + override fun process(resolver: Resolver): List { + resolver + .getSymbolsWithAnnotation(MergeComponent.CreateComponent::class) + .filterIsInstance() + .onEach { function -> + checkIsPublic(function) { + "Factory functions for components annotated with `@CreateComponent` must be public." + } + checkKotlinInjectComponentWillBeGenerated(function) + checkReceiverType(function) + checkArguments(function) + checkIsExpectFunction(function) + } + .forEach { + generateActualFunction(it) + } + + return emptyList() + } + + private fun generateActualFunction(function: KSFunctionDeclaration) { + val component = (function.requireReturnType().resolve().declaration as KSClassDeclaration) + .toClassName() + val generatedComponent = component.peerClass("KotlinInject${component.simpleName}") + + function.requireContainingFile() + + val parametersAsSpec = function.parameters.map { + ParameterSpec + .builder( + name = it.requireName(), + type = it.type.toTypeName(), + ) + .build() + } + + val fileSpec = FileSpec + .builder( + packageName = function.packageName.asString(), + fileName = function.requireContainingFile().fileName.substringBefore(".kt") + + "CreateComponent", + ) + .addFunction( + FunSpec + .builder(function.simpleName.asString()) + .apply { + if (function.extensionReceiver != null) { + receiver( + KClass::class.asTypeName().parameterizedBy(component), + ) + } + } + .addModifiers(ACTUAL) + .addParameters(parametersAsSpec) + .returns(component) + .addStatement( + "return %T::class.create(${parametersAsSpec.joinToString { it.name }})", + generatedComponent, + ) + .build(), + ) + .build() + + fileSpec.writeTo(codeGenerator, aggregating = false) + } + + private fun checkKotlinInjectComponentWillBeGenerated(function: KSFunctionDeclaration) { + val componentClass = function.requireReturnType().resolve().declaration + check(componentClass.isAnnotationPresent(MergeComponent::class), function) { + "The return type ${componentClass.requireQualifiedName()} is not annotated with `@MergeComponent`." + } + check(!componentClass.isAnnotationPresent(Component::class), function) { + "The return type ${componentClass.requireQualifiedName()} should not be annotated " + + "with `@Component`. In this scenario use the built-in annotations from " + + "kotlin-inject itself." + } + } + + private fun checkReceiverType(function: KSFunctionDeclaration) { + val receiverType = + function.extensionReceiver?.resolve()?.declaration?.requireQualifiedName() ?: return + check(receiverType == kclassFqName, function) { + "Only a receiver type on KClass is supported." + } + + val receiverArgument = + function.extensionReceiver?.resolve()?.arguments?.singleOrNull()?.type + ?.resolve()?.declaration?.requireQualifiedName() + val returnType = function.requireReturnType().resolve().declaration.requireQualifiedName() + check(receiverArgument == returnType, function) { + "Only a receiver type on KClass is supported. The argument was different." + } + } + + private fun checkArguments(function: KSFunctionDeclaration) { + val componentParameters = + (function.requireReturnType().resolve().declaration as? KSClassDeclaration) + ?.primaryConstructor?.parameters ?: emptyList() + + check(componentParameters.size == function.parameters.size, function) { + "The number of arguments for the function doesn't match the number of arguments of the component." + } + } + + private fun checkIsExpectFunction(function: KSFunctionDeclaration) { + check(Modifier.EXPECT in function.modifiers, function) { + "Only expect functions can be annotated with @MergeComponent.CreateComponent. " + + "In non-common Kotlin Multiplatform code use the generated `create` extension " + + "function on the class object: YourComponent.create(..)." + } + } + + private fun KSFunctionDeclaration.requireReturnType(): KSTypeReference { + return requireNotNull(returnType, this) { + "Couldn't determine return type for $this" + } + } +} diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Compilation.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Compilation.kt index 1e61e7d..884decb 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Compilation.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Compilation.kt @@ -144,6 +144,7 @@ fun compile( previousCompilationResult: JvmCompilationResult? = null, moduleName: String? = null, useKsp2: Boolean = true, + multiplatform: Boolean = false, exitCode: KotlinCompilation.ExitCode = KotlinCompilation.ExitCode.OK, block: JvmCompilationResult.() -> Unit = { }, ): JvmCompilationResult { @@ -158,6 +159,7 @@ fun compile( if (moduleName != null) { this.moduleName = moduleName } + this.multiplatform = multiplatform } if (previousCompilationResult != null) { diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessorTest.kt new file mode 100644 index 0000000..3c82ae6 --- /dev/null +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/CreateComponentProcessorTest.kt @@ -0,0 +1,207 @@ +@file:OptIn(ExperimentalCompilerApi::class) + +package software.amazon.lastmile.kotlin.inject.anvil.processor + +import assertk.assertThat +import assertk.assertions.contains +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.COMPILATION_ERROR +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test +import software.amazon.lastmile.kotlin.inject.anvil.compile + +// Note that there's no unit test verifying the correctly generated code. We're blocked on testing +// expect-actual multiplatform code in unit tests. +// +// https://github.com/ZacSweers/kotlin-compile-testing/issues/298 +// +// The correctness is verified through the sample app of this project for now. +class CreateComponentProcessorTest { + + @Test + fun `the function must be public`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + + @MergeComponent(AppScope::class) + interface ComponentInterface + + @MergeComponent.CreateComponent + internal expect fun createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "Factory functions for components annotated with `@CreateComponent` must " + + "be public.", + ) + } + } + + @Test + fun `the function return type must generate the kotlin-inject component - final component`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Component + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + + @MergeComponent(AppScope::class) + @Component + interface ComponentInterface : ComponentInterfaceMerged + + @MergeComponent.CreateComponent + expect fun createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "The return type software.amazon.test.ComponentInterface should not be " + + "annotated with `@Component`. In this scenario use the built-in " + + "annotations from kotlin-inject itself.", + ) + } + } + + @Test + fun `the function return type must generate the kotlin-inject component - no @MergeComponent annotation`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent.CreateComponent + + interface ComponentInterface + + @CreateComponent + expect fun createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "The return type software.amazon.test.ComponentInterface is not annotated " + + "with `@MergeComponent`.", + ) + } + } + + @Test + fun `a receiver type is only supported for KClass`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent.CreateComponent + + @MergeComponent(AppScope::class) + interface ComponentInterface + + @CreateComponent + expect fun String.createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "Only a receiver type on KClass is supported.", + ) + } + } + + @Test + fun `a receiver type is only supported for KClass with the right argument`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent.CreateComponent + import kotlin.reflect.KClass + + @MergeComponent(AppScope::class) + interface ComponentInterface + + @CreateComponent + expect fun KClass.createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "Only a receiver type on KClass is supported. " + + "The argument was different.", + ) + } + } + + @Test + fun `the number of arguments must match the number of arguments for the component`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent.CreateComponent + import kotlin.reflect.KClass + + @MergeComponent(AppScope::class) + abstract class ComponentInterface( + @get:Provides val string: String, + ) + + @CreateComponent + expect fun KClass.createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "The number of arguments for the function doesn't match the " + + "number of arguments of the component.", + ) + } + } + + @Test + fun `the function must have the expect modifier`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent.CreateComponent + import kotlin.reflect.KClass + + @MergeComponent(AppScope::class) + interface ComponentInterface + + @CreateComponent + fun KClass.createComponent(): ComponentInterface + """, + multiplatform = true, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "Only expect functions can be annotated with " + + "@MergeComponent.CreateComponent. In non-common Kotlin Multiplatform " + + "code use the generated `create` extension function on the class " + + "object: YourComponent.create(..).", + ) + } + } +} diff --git a/runtime/api/android/runtime.api b/runtime/api/android/runtime.api index d748e44..c8b3984 100644 --- a/runtime/api/android/runtime.api +++ b/runtime/api/android/runtime.api @@ -25,6 +25,9 @@ public abstract interface annotation class software/amazon/lastmile/kotlin/injec public abstract fun scope ()Ljava/lang/Class; } +public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/MergeComponent$CreateComponent : java/lang/annotation/Annotation { +} + public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/extend/ContributingAnnotation : java/lang/annotation/Annotation { } diff --git a/runtime/api/jvm/runtime.api b/runtime/api/jvm/runtime.api index d748e44..c8b3984 100644 --- a/runtime/api/jvm/runtime.api +++ b/runtime/api/jvm/runtime.api @@ -25,6 +25,9 @@ public abstract interface annotation class software/amazon/lastmile/kotlin/injec public abstract fun scope ()Ljava/lang/Class; } +public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/MergeComponent$CreateComponent : java/lang/annotation/Annotation { +} + public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/extend/ContributingAnnotation : java/lang/annotation/Annotation { } diff --git a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt index 1fd08fb..4812614 100644 --- a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt +++ b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt @@ -1,6 +1,7 @@ package software.amazon.lastmile.kotlin.inject.anvil import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.annotation.AnnotationTarget.FUNCTION import kotlin.reflect.KClass /** @@ -21,8 +22,8 @@ import kotlin.reflect.KClass * * Note that in this example `AppComponent` will not implement all contributed interfaces directly. * Instead, the final generated kotlin-inject component will contain all contributions. If this - * is important, e.g. for better IDE support, then you can the `@Component` annotation directly - * to the class with the super type: + * is important, e.g. for better IDE support, then you can add the `@Component` annotation + * directly to the class with the super type: * ``` * @Component * @MergeComponent(AppScope::class) @@ -49,6 +50,41 @@ import kotlin.reflect.KClass * ) * interface AppComponent * ``` + * + * ## Kotlin Multiplatform + * + * With Kotlin Multiplatform there is a high chance that the generated code cannot be referenced + * from common Kotlin code or from common platform code like `iosMain`. This is due to how + * [common source folders are separated from platform source folders](https://kotlinlang.org/docs/whatsnew20.html#separation-of-common-and-platform-sources-during-compilation). + * For more details and recommendations setting up kotlin-inject in Kotlin Multiplatform projects + * see the [official guide](https://github.com/evant/kotlin-inject/blob/main/docs/multiplatform.md). + * + * To address this issue, you can define an `expect fun` in the common source code next to + * component class itself. The `actual fun` will be generated and create the component. The + * function must be annotated with [MergeComponent.CreateComponent]. It's optional to have a + * receiver type of `KClass` with your component type as argument. The number of parameters + * must match the arguments of your component and the return type must be your component, e.g. + * your component in common code could be declared as: + * ``` + * @MergeComponent(AppScope::class) + * @SingleIn(AppScope::class) + * abstract class AppComponent( + * @get:Provides appId: String, + * ) + * + * @CreateComponent + * expect fun create(appId: String): AppComponent + * + * // Or with receiver type: + * @CreateComponent + * expect fun KClass.create(appId: String): AppComponent + * ``` + * The generated `actual fun` would look like this: + * ``` + * actual fun create(appId: String): AppComponent { + * return KotlinInjectAppComponent::class.create(appId) + * } + * ``` */ @Target(CLASS) public annotation class MergeComponent( @@ -62,4 +98,12 @@ public annotation class MergeComponent( * excluded from the component. */ val exclude: Array> = [], -) +) { + /** + * Marks an `expect fun` in common Kotlin Multiplatform code as the builder function to + * create the generated kotlin-inject component at runtime. This annotation is only applicable + * for Kotlin Multiplatform, see [MergeComponent] for more details. + */ + @Target(FUNCTION) + public annotation class CreateComponent +} diff --git a/sample/app/build.gradle b/sample/app/build.gradle index f8e99eb..168bc88 100644 --- a/sample/app/build.gradle +++ b/sample/app/build.gradle @@ -6,17 +6,18 @@ plugins { kotlin { androidTarget() iosSimulatorArm64() + iosArm64() applyDefaultHierarchyTemplate() } dependencies { - kspCommonMainMetadata libs.kotlin.inject.ksp kspAndroid libs.kotlin.inject.ksp + kspIosArm64 libs.kotlin.inject.ksp kspIosSimulatorArm64 libs.kotlin.inject.ksp - kspCommonMainMetadata project(':compiler') kspAndroid project(':compiler') + kspIosArm64 project(':compiler') kspIosSimulatorArm64 project(':compiler') commonMainImplementation project(':runtime') diff --git a/sample/app/src/iosSimulatorArm64Main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt b/sample/app/src/iosMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt similarity index 68% rename from sample/app/src/iosSimulatorArm64Main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt rename to sample/app/src/iosMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt index b32ab28..2930c3a 100644 --- a/sample/app/src/iosSimulatorArm64Main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt +++ b/sample/app/src/iosMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponent.kt @@ -1,22 +1,21 @@ package software.amazon.lastmile.kotlin.inject.anvil.sample -import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Provides import platform.UIKit.UIApplication import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent import software.amazon.lastmile.kotlin.inject.anvil.SingleIn +import kotlin.reflect.KClass /** * Concrete application component for iOS using the scope [SingleIn] [AppScope]. - * [IosAppComponentMerged] is a generated interface. Through this merged interface + * The final kotlin-inject component is generated and will extend * [ApplicationIdProviderComponent], other contributed component interfaces and contributed - * bindings such as from [IosApplicationIdProvider] are implemented. + * bindings such as from [IosApplicationIdProvider]. * * Note that this component lives in an iOS source folder and therefore types such as * [UIApplication] can be provided in the object graph. */ -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) abstract class IosAppComponent( @@ -24,4 +23,11 @@ abstract class IosAppComponent( * The iOS application that is provided to this object graph. */ @get:Provides val application: UIApplication, -) : IosAppComponentMerged +) + +/** + * The `actual fun` will be generated for each iOS specific target. See [MergeComponent] for + * more details. + */ +@MergeComponent.CreateComponent +expect fun KClass.createComponent(application: UIApplication): IosAppComponent diff --git a/sample/app/src/iosSimulatorArm64Test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt b/sample/app/src/iosTest/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt similarity index 86% rename from sample/app/src/iosSimulatorArm64Test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt rename to sample/app/src/iosTest/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt index 2565408..a00d48e 100644 --- a/sample/app/src/iosSimulatorArm64Test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt +++ b/sample/app/src/iosTest/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/IosAppComponentTest.kt @@ -16,6 +16,6 @@ class IosAppComponentTest { private fun component(): T { @Suppress("UNCHECKED_CAST") - return IosAppComponent::class.create(UIApplication.sharedApplication) as T + return IosAppComponent::class.createComponent(UIApplication.sharedApplication) as T } } diff --git a/sample/lib/build.gradle b/sample/lib/build.gradle index 872de75..c79aaf0 100644 --- a/sample/lib/build.gradle +++ b/sample/lib/build.gradle @@ -6,17 +6,18 @@ plugins { kotlin { androidTarget() iosSimulatorArm64() + iosArm64() applyDefaultHierarchyTemplate() } dependencies { - kspCommonMainMetadata libs.kotlin.inject.ksp kspAndroid libs.kotlin.inject.ksp + kspIosArm64 libs.kotlin.inject.ksp kspIosSimulatorArm64 libs.kotlin.inject.ksp - kspCommonMainMetadata project(':compiler') kspAndroid project(':compiler') + kspIosArm64 project(':compiler') kspIosSimulatorArm64 project(':compiler') commonMainImplementation project(':runtime')