diff --git a/android/activity/build.gradle b/android/activity/build.gradle new file mode 100644 index 00000000..c22d7dbb --- /dev/null +++ b/android/activity/build.gradle @@ -0,0 +1,28 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + compileSdk = 30 + defaultConfig { + minSdk = 14 + } + buildFeatures { + buildConfig = false + resValues = false + } +} + +dependencies { + api("androidx.activity:activity-ktx:1.2.2") +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = "1.8" +} + +ext.groupId = 'me.tatarka.inject.android' +ext.artifactId = 'kotlin-inject-viewmodel' + +//apply from: "$rootProject.projectDir/publish.gradle" \ No newline at end of file diff --git a/android/activity/src/main/AndroidManifest.xml b/android/activity/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3d893557 --- /dev/null +++ b/android/activity/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/activity/src/main/java/me/tatarka/inject/android/activity/ActivityViewModelLazy.kt b/android/activity/src/main/java/me/tatarka/inject/android/activity/ActivityViewModelLazy.kt new file mode 100644 index 00000000..09f543f2 --- /dev/null +++ b/android/activity/src/main/java/me/tatarka/inject/android/activity/ActivityViewModelLazy.kt @@ -0,0 +1,71 @@ +package me.tatarka.inject.android.activity + +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider + +/** + * Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, the instance returned from [factory] will be + * used to create [ViewModel] first time. + * + * ``` + * @Inject class MyViewModel + * + * @Component interface ActivityComponent { + * val myViewModel: () -> MyViewModel + * } + * + * class MyComponentActivity : ComponentActivity() { + * val component by lazy { ActivityComponent::class.create() } + * val viewModel by viewModels { component.myViewModel() } + * } + * ``` + * + * This property can be accessed only after the Activity is attached to the Application, + * and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun ComponentActivity.viewModels(crossinline factory: () -> VM): Lazy = + ViewModelLazy(VM::class, { viewModelStore }, { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = factory() as T + } + }) + +/** + * Returns a [Lazy] delegate to access the ComponentActivity's ViewModel, the instance returned from [factory] will be + * used to create [ViewModel] first time. + * + * ``` + * @Inject class MyViewModel(handle: SavedStateHandle) + * + * @Component interface ActivityComponent { + * val myViewModel: (SavedStateHandle) -> MyViewModel + * } + * + * class MyComponentActivity : ComponentActivity() { + * val component by lazy { ActivityComponent::class.create() } + * val viewModel by viewModels { handle -> component.myViewModel(handle) } + * } + * ``` + * + * This property can be accessed only after the Activity is attached to the Application, + * and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun ComponentActivity.viewModels(crossinline factory: (SavedStateHandle) -> VM): Lazy = + ViewModelLazy(VM::class, { viewModelStore }, { + object : AbstractSavedStateViewModelFactory(this, intent?.extras) { + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T = factory(handle) as T + } + }) \ No newline at end of file diff --git a/android/fragment-factory-compiler/build.gradle b/android/fragment-factory-compiler/build.gradle new file mode 100644 index 00000000..9e6d269c --- /dev/null +++ b/android/fragment-factory-compiler/build.gradle @@ -0,0 +1,24 @@ +plugins { + id("org.jetbrains.kotlin.jvm") +} + +dependencies { + implementation(project(":kotlin-inject-compiler:ksp")) + implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation("com.squareup:kotlinpoet:1.5.0") + // for access to CompilationErrorException until ksp properly fails on errors + implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:$kotlin_version") + implementation("com.google.devtools.ksp:symbol-processing-api:$ksp_version") +} + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = "1.8" +} + +ext.groupId = 'me.tatarka.inject.android' +ext.artifactId = 'kotlin-inject-fragment-factory' + +apply from: "$rootProject.projectDir/publish.gradle" \ No newline at end of file diff --git a/android/fragment-factory-compiler/src/main/kotlin/me/tatarka/inject/android/fragment/compiler/FragmentFactoryProcessor.kt b/android/fragment-factory-compiler/src/main/kotlin/me/tatarka/inject/android/fragment/compiler/FragmentFactoryProcessor.kt new file mode 100644 index 00000000..e6e5eb30 --- /dev/null +++ b/android/fragment-factory-compiler/src/main/kotlin/me/tatarka/inject/android/fragment/compiler/FragmentFactoryProcessor.kt @@ -0,0 +1,120 @@ +package me.tatarka.inject.android.fragment.compiler + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +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.KSDeclaration +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import me.tatarka.inject.compiler.ksp.asClassName +import me.tatarka.inject.compiler.ksp.findAnnotation +import java.util.Locale + +class FragmentFactoryProcessor : SymbolProcessor { + private lateinit var codeGenerator: CodeGenerator + private lateinit var logger: KSPLogger + + override fun init( + options: Map, + kotlinVersion: KotlinVersion, + codeGenerator: CodeGenerator, + logger: KSPLogger + ) { + this.codeGenerator = codeGenerator + this.logger = logger + } + + override fun process(resolver: Resolver): List { + val injectFragments = resolver.getSymbolsWithAnnotation(INJECT.canonicalName).mapNotNull { + if (it is KSClassDeclaration && it.extendsFragment()) it else null + }.groupBy { fragment -> + val intoComponent = fragment.findAnnotation(INTO_COMPONENT.packageName, INTO_COMPONENT.simpleName) + intoComponent?.arguments?.first { it.name?.asString() == "value" }?.value + } + for (element in resolver.getSymbolsWithAnnotation(GENERATE_FRAGMENT_FACTORY.canonicalName)) { + if (element !is KSDeclaration) continue + val generateFragmentFactory = + element.findAnnotation(GENERATE_FRAGMENT_FACTORY.packageName, GENERATE_FRAGMENT_FACTORY.simpleName)!! + val isDefault = + generateFragmentFactory.arguments.first { it.name?.asString() == "default" }.value as Boolean + val key = if (isDefault) null else TODO() + val fragments = injectFragments[key] ?: emptyList() + val generatedClassName = "FragmentFactory${element.simpleName.asString()}" + val file = element.containingFile!! + val fileSpec = FileSpec.builder(element.packageName.asString(), generatedClassName) + .addType( + TypeSpec.interfaceBuilder(generatedClassName) + .apply { + if (fragments.isNotEmpty()) { + for (fragment in fragments) { + val name = fragment.simpleName.asString().decapitalize(Locale.US) + val type = fragment.asClassName() + addProperty( + PropertySpec.builder(name, FRAGMENT_ENTRY) + .receiver(LambdaTypeName.get(returnType = type)) + .getter( + FunSpec.getterBuilder() + .addAnnotation(PROVIDES) + .addAnnotation(INTO_MAP) + .addStatement("return %N(this)", FRAGMENT_ENTRY) + .build() + ).build() + ) + addProperty(name, FRAGMENT_ENTRY) + } + } else { + addProperty( + PropertySpec.builder( + "noFragments", ClassName("kotlin.collections", "Map").parameterizedBy( + ClassName("kotlin", "String"), + LambdaTypeName.get(returnType = FRAGMENT) + ) + ).getter( + FunSpec.getterBuilder() + .addAnnotation(PROVIDES) + .addStatement("return emptyMap()") + .build() + ).build() + ) + } + } + .build() + ).build() + codeGenerator.createNewFile( + Dependencies(true, file), + fileSpec.packageName, + fileSpec.name + ).bufferedWriter().use { fileSpec.writeTo(it) } + } + return emptyList() + } + + private fun KSClassDeclaration.extendsFragment(): Boolean { + return getAllSuperTypes().any { + val decl = it.declaration + decl.packageName.asString() == FRAGMENT.packageName && decl.simpleName.asString() == FRAGMENT.simpleName + } + } + + companion object { + private const val ANNOTATION_PACKAGE_NAME = "me.tatarka.inject.annotations" + private const val ANDROID_ANNOTATION_PACKAGE_NAME = "me.tatarka.inject.android.annotations" + val PROVIDES = ClassName(ANNOTATION_PACKAGE_NAME, "Provides") + val INTO_MAP = ClassName(ANNOTATION_PACKAGE_NAME, "IntoMap") + val FRAGMENT = ClassName("androidx.fragment.app", "Fragment") + val INJECT = ClassName(ANDROID_ANNOTATION_PACKAGE_NAME, "Inject") + val GENERATE_FRAGMENT_FACTORY = ClassName(ANDROID_ANNOTATION_PACKAGE_NAME, "GenerateFragmentFactory") + val INTO_COMPONENT = ClassName(ANDROID_ANNOTATION_PACKAGE_NAME, "IntoComponent") + val FRAGMENT_ENTRY = (ClassName("me.tatarka.inject.android.fragment", "FragmentEntry")) + } +} \ No newline at end of file diff --git a/android/fragment-factory-compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor b/android/fragment-factory-compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor new file mode 100644 index 00000000..ba4704b3 --- /dev/null +++ b/android/fragment-factory-compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor @@ -0,0 +1 @@ +me.tatarka.inject.android.fragment.compiler.FragmentFactoryProcessor \ No newline at end of file diff --git a/android/fragment/build.gradle b/android/fragment/build.gradle new file mode 100644 index 00000000..fc6ab921 --- /dev/null +++ b/android/fragment/build.gradle @@ -0,0 +1,29 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + compileSdk = 30 + defaultConfig { + minSdk = 14 + } + buildFeatures { + buildConfig = false + resValues = false + } +} + +dependencies { + api(project(":kotlin-inject-runtime")) + api("androidx.fragment:fragment-ktx:1.3.3") +} + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = "1.8" +} + +ext.groupId = 'me.tatarka.inject.android' +ext.artifactId = 'kotlin-inject-fragment' + +//apply from: "$rootProject.projectDir/publish.gradle" \ No newline at end of file diff --git a/android/fragment/src/main/AndroidManifest.xml b/android/fragment/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cc145b34 --- /dev/null +++ b/android/fragment/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/fragment/src/main/java/me/tatarka/inject/android/annotations/Annotations.kt b/android/fragment/src/main/java/me/tatarka/inject/android/annotations/Annotations.kt new file mode 100644 index 00000000..ff2ea859 --- /dev/null +++ b/android/fragment/src/main/java/me/tatarka/inject/android/annotations/Annotations.kt @@ -0,0 +1,11 @@ +package me.tatarka.inject.android.annotations + +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +annotation class GenerateFragmentFactory(val default: Boolean = false) + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +annotation class IntoComponent(val value: KClass) \ No newline at end of file diff --git a/android/fragment/src/main/java/me/tatarka/inject/android/fragment/FragmentViewModelLazy.kt b/android/fragment/src/main/java/me/tatarka/inject/android/fragment/FragmentViewModelLazy.kt new file mode 100644 index 00000000..fae4b954 --- /dev/null +++ b/android/fragment/src/main/java/me/tatarka/inject/android/fragment/FragmentViewModelLazy.kt @@ -0,0 +1,160 @@ +package me.tatarka.inject.android.fragment + +import androidx.annotation.MainThread +import androidx.fragment.app.Fragment +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelLazy +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner + +/** + * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]: + * ``` + * @Inject + * class MyFragment(myViewModel: () -> MyViewModel) : Fragment() { + * val viewModel = viewModels(myViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.viewModels( + crossinline factory: () -> VM +): Lazy = viewModels({ this }, factory) + +/** + * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]: + * ``` + * @Inject + * class MyFragment(myViewModel: () -> MyViewModel) : Fragment() { + * val viewModel = viewModels(myViewModel) + * } + * ``` + * + * Default scope may be overridden with parameter [ownerProducer]: + * ``` + * class MyFragment(myParentViewModel: () -> MyParentViewModel) : Fragment() { + * val viewModel = viewModels({requireParentFragment()}, myParentViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.viewModels( + noinline ownerProducer: () -> ViewModelStoreOwner, + crossinline factory: () -> VM +): Lazy = ViewModelLazy(VM::class, { ownerProducer().viewModelStore }, { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = factory() as T + } +}) + +/** + * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]: + * ``` + * @Inject + * class MyFragment(myViewModel: (SavedStateHandle) -> MyViewModel) : Fragment() { + * val viewModel = viewModels(myViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.viewModels( + crossinline factory: (SavedStateHandle) -> VM +): Lazy = viewModels({ this }, factory) + +/** + * Returns a property delegate to access [ViewModel] by **default** scoped to this [Fragment]: + * ``` + * @Inject + * class MyFragment(myViewModel: (SavedStateHandle) -> MyViewModel) : Fragment() { + * val viewModel = viewModels(myViewModel) + * } + * ``` + * + * Default scope may be overridden with parameter [ownerProducer]: + * ``` + * class MyFragment(parentViewModel: (SavedStateHandle) -> ParentViewModel) : Fragment() { + * val viewModel = viewModels({requireParentFragment()}, parentViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.viewModels( + noinline ownerProducer: () -> ViewModelStoreOwner, + crossinline factory: (SavedStateHandle) -> VM, +): Lazy = ViewModelLazy(VM::class, { ownerProducer().viewModelStore }, { + object : AbstractSavedStateViewModelFactory(this, arguments) { + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T = factory(handle) as T + } +}) + +/** + * Returns a property delegate to access parent activity's [ViewModel], the [factory] will be used to create [ViewModel] + * first time. + * + * ``` + * @Inject + * class MyFragment(myActivityViewModel: () -> MyActivityViewModel) : Fragment() { + * val viewModel by activityViewModels(myActivityViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.activityViewModels( + crossinline factory: () -> VM +): Lazy = ViewModelLazy(VM::class, { requireActivity().viewModelStore }, { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = factory() as T + } +}) + +/** + * Returns a property delegate to access parent activity's [ViewModel], the [factory] will be used to create [ViewModel] + * first time. + * + * ``` + * @Inject + * class MyFragment(myActivityViewModel: (SavedStateHandel) -> MyActivityViewModel) : Fragment() { + * val viewModel by activityViewModels(myActivityViewModel) + * } + * ``` + * + * This property can be accessed only after this Fragment is attached i.e., after + * [Fragment.onAttach()], and access prior to that will result in IllegalArgumentException. + */ +@MainThread +inline fun Fragment.activityViewModels( + crossinline factory: (SavedStateHandle) -> VM +): Lazy = ViewModelLazy(VM::class, { requireActivity().viewModelStore }, { + object : AbstractSavedStateViewModelFactory(this, arguments) { + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T = factory(handle) as T + } +}) \ No newline at end of file diff --git a/android/fragment/src/main/java/me/tatarka/inject/android/fragment/InjectFragmentFactory.kt b/android/fragment/src/main/java/me/tatarka/inject/android/fragment/InjectFragmentFactory.kt new file mode 100644 index 00000000..dbfe694d --- /dev/null +++ b/android/fragment/src/main/java/me/tatarka/inject/android/fragment/InjectFragmentFactory.kt @@ -0,0 +1,39 @@ +package me.tatarka.inject.android.fragment + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import me.tatarka.inject.annotations.Inject + +/** + * The return type you should return from a `@Provides @IntoMap` function to inject it into [InjectFragmentFactory]. + */ +typealias FragmentEntry = Pair Fragment> + +/** + * Creates a [FragmentEntry] from the given fragment [factory]. + */ +inline fun FragmentEntry(noinline factory: () -> F): FragmentEntry = + F::class.java.canonicalName!! to factory + +/** + * A [FragmentFactory] that allows fragment constructor injection. You can provide the fragments to inject with + * `@Provides @IntoMap`. + * + * ``` + * @Component abstract class FragmentsComponent { + * abstract val fragmentFactory: InjectFragmentFactory + * + * val (() -> MyFragment1).myFragment1: FragmentEntry + * @Provides @IntoMap get() = FragmentEntry(this) + * + * val (() -> MyFragment2).myFragment2: FragmentEntry + * @Provides @IntoMap get() = FragmentEntry(this) + * } + * ``` + */ +@Inject +class InjectFragmentFactory(private val fragmentFactories: Map Fragment>) : FragmentFactory() { + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + return fragmentFactories[className]?.invoke() ?: super.instantiate(classLoader, className) + } +} \ No newline at end of file diff --git a/android/integration-tests/build.gradle b/android/integration-tests/build.gradle new file mode 100644 index 00000000..ac60af0c --- /dev/null +++ b/android/integration-tests/build.gradle @@ -0,0 +1,64 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + compileSdk = 30 + + defaultConfig { + applicationId = "me.tatarka.inject.android.test" + minSdk = 21 + targetSdk = 30 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + // allows android studio to see ksp generated code + debug.java { + srcDir("build/generated/ksp/debug/kotlin") + } + release.java { + srcDir("build/generated/ksp/release/kotlin") + } + testDebug.java { + srcDir("build/generated/ksp/debugUnitTest/kotlin") + } + androidTestDebug.java { + srcDir("build/generated/ksp/debugAndroidTest/kotlin") + } + // share code between unit and android tests + test.java { + srcDir("src/commonTest/java") + } + androidTest.java { + srcDir("src/commonTest/java") + } + } +} + +dependencies { + implementation(project(":android:activity")) + implementation(project(":android:fragment")) + + ksp(project(":kotlin-inject-compiler:ksp")) + ksp(project(":android:fragment-factory-compiler")) + + androidTestImplementation("org.jetbrains.kotlin:kotlin-test") + androidTestImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test:core-ktx:1.3.0") + debugImplementation("androidx.fragment:fragment-testing:1.3.3") + androidTestImplementation("androidx.test.ext:junit:1.1.2") + androidTestImplementation("androidx.test:runner:1.3.0") + androidTestImplementation("com.willowtreeapps.assertk:assertk-jvm:0.22") +} + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions.jvmTarget = "1.8" +} \ No newline at end of file diff --git a/android/integration-tests/src/androidTest/java/me/tatarka/inject/android/test/ActivityViewModelTest.kt b/android/integration-tests/src/androidTest/java/me/tatarka/inject/android/test/ActivityViewModelTest.kt new file mode 100644 index 00000000..8876c9ca --- /dev/null +++ b/android/integration-tests/src/androidTest/java/me/tatarka/inject/android/test/ActivityViewModelTest.kt @@ -0,0 +1,115 @@ +package me.tatarka.inject.android.test + +import androidx.activity.viewModels +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import androidx.fragment.app.testing.launchFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.launchActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import assertk.assertThat +import assertk.assertions.isSameAs +import me.tatarka.inject.android.TestChildViewModelFragment +import me.tatarka.inject.android.TestParentViewModelFragment +import me.tatarka.inject.android.TestSavedStateViewModel +import me.tatarka.inject.android.TestViewModel +import me.tatarka.inject.android.TestViewModelActivity +import me.tatarka.inject.android.TestViewModelFragment +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ActivityViewModelTest { + + @Test + fun constructs_and_retains_a_view_model_for_an_activity() { + val scenario = launchActivity() + + lateinit var viewModel: TestViewModel + lateinit var savedStateViewModel: TestSavedStateViewModel + scenario.onActivity { + viewModel = it.viewModel + savedStateViewModel = it.savedStateViewModel + } + scenario.recreate() + scenario.onActivity { + assertThat(it.viewModel).isSameAs(viewModel) + assertThat(it.savedStateViewModel).isSameAs(savedStateViewModel) + } + } + + @Test + fun constructs_and_retains_a_view_model_for_a_fragment() { + val vmFactory = { TestViewModel() } + val vmSavedStateFactory = { handle: SavedStateHandle -> TestSavedStateViewModel(handle) } + val scenario = launchFragment { TestViewModelFragment(vmFactory, vmSavedStateFactory) } + + lateinit var viewModel: TestViewModel + lateinit var savedStateViewModel: TestSavedStateViewModel + scenario.onFragment { + viewModel = it.viewModel + savedStateViewModel = it.savedStateViewModel + } + scenario.recreate() + scenario.onFragment { + assertThat(it.viewModel).isSameAs(viewModel) + assertThat(it.savedStateViewModel).isSameAs(savedStateViewModel) + } + } + + @Test + fun constructs_and_retains_a_view_model_from_the_parent_activity() { + val vmFactory = { TestViewModel() } + val vmSavedStateFactory = { handle: SavedStateHandle -> TestSavedStateViewModel(handle) } + val scenario = launchFragment { TestViewModelFragment(vmFactory, vmSavedStateFactory) } + + lateinit var viewModel: TestViewModel + lateinit var savedStateViewModel: TestSavedStateViewModel + scenario.onFragment { + viewModel = it.activityViewModel + savedStateViewModel = it.savedStateActivityViewModel + } + scenario.recreate() + scenario.onFragment { + val activityViewModel by it.requireActivity().viewModels() + val savedStateActivityViewModel by it.requireActivity().viewModels() + + assertThat(activityViewModel).isSameAs(viewModel) + assertThat(savedStateActivityViewModel).isSameAs(savedStateViewModel) + } + } + + @Test + fun constructs_and_retains_a_view_model_from_the_parent_fragment() { + val vmFactory = { TestViewModel() } + val vmSavedStateFactory = { handle: SavedStateHandle -> TestSavedStateViewModel(handle) } + val scenario = launchFragment(factory = object : FragmentFactory() { + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + return when (className) { + TestParentViewModelFragment::class.java.name -> TestParentViewModelFragment() + TestChildViewModelFragment::class.java.name -> TestChildViewModelFragment( + vmFactory, + vmSavedStateFactory + ) + else -> super.instantiate(classLoader, className) + } + } + }) + + lateinit var viewModel: TestViewModel + lateinit var savedStateViewModel: TestSavedStateViewModel + scenario.onFragment { + viewModel = it.childFragment.parentViewModel + savedStateViewModel = it.childFragment.savedStateParentViewModel + } + scenario.recreate() + scenario.onFragment { + val parentViewModel by it.viewModels() + val savedStateParentViewModel by it.viewModels() + + assertThat(parentViewModel).isSameAs(viewModel) + assertThat(savedStateParentViewModel).isSameAs(savedStateViewModel) + } + } +} \ No newline at end of file diff --git a/android/integration-tests/src/main/AndroidManifest.xml b/android/integration-tests/src/main/AndroidManifest.xml new file mode 100644 index 00000000..abad3115 --- /dev/null +++ b/android/integration-tests/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/integration-tests/src/main/java/me/tatarka/inject/android/TestFragmentFactoryActivity.kt b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestFragmentFactoryActivity.kt new file mode 100644 index 00000000..761f205e --- /dev/null +++ b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestFragmentFactoryActivity.kt @@ -0,0 +1,28 @@ +package me.tatarka.inject.android + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import me.tatarka.inject.android.annotations.GenerateFragmentFactory +import me.tatarka.inject.android.fragment.InjectFragmentFactory +import me.tatarka.inject.annotations.Component +import me.tatarka.inject.annotations.Inject + +@Component +@GenerateFragmentFactory(default = true) +abstract class ActivityComponent : FragmentFactoryActivityComponent { + abstract val fragmentFactory: InjectFragmentFactory +} + +class TestFragmentFactoryActivity : FragmentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + supportFragmentManager.fragmentFactory = ActivityComponent::class.create().fragmentFactory + super.onCreate(savedInstanceState) + } +} + +@Inject +class Fragment1 : Fragment() + +@Inject +class Fragment2 : Fragment() \ No newline at end of file diff --git a/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelActivity.kt b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelActivity.kt new file mode 100644 index 00000000..d42c5c87 --- /dev/null +++ b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelActivity.kt @@ -0,0 +1,19 @@ +package me.tatarka.inject.android + +import androidx.activity.ComponentActivity +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import me.tatarka.inject.android.activity.viewModels + +class TestViewModelActivity : ComponentActivity() { + val vmFactory = { TestViewModel() } + val vmSavedStateFactory = { handle: SavedStateHandle -> TestSavedStateViewModel(handle) } + + val viewModel by viewModels(vmFactory) + val savedStateViewModel by viewModels(vmSavedStateFactory) +} + +class TestViewModel : ViewModel() { +} + +class TestSavedStateViewModel(val handle: SavedStateHandle) : ViewModel() \ No newline at end of file diff --git a/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelFragment.kt b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelFragment.kt new file mode 100644 index 00000000..b47db3b1 --- /dev/null +++ b/android/integration-tests/src/main/java/me/tatarka/inject/android/TestViewModelFragment.kt @@ -0,0 +1,43 @@ +package me.tatarka.inject.android + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.SavedStateHandle +import me.tatarka.inject.android.fragment.activityViewModels +import me.tatarka.inject.android.fragment.viewModels + +class TestViewModelFragment( + vmFactory: () -> TestViewModel, + vmSavedStateFactory: (SavedStateHandle) -> TestSavedStateViewModel +) : Fragment() { + + val viewModel by viewModels(vmFactory) + val savedStateViewModel by viewModels(vmSavedStateFactory) + + val activityViewModel by activityViewModels(vmFactory) + val savedStateActivityViewModel by activityViewModels(vmSavedStateFactory) +} + +class TestParentViewModelFragment : Fragment() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + childFragmentManager.commit { + add(TestChildViewModelFragment::class.java, null, "test") + } + } + } + + val childFragment: TestChildViewModelFragment + get() = childFragmentManager.findFragmentByTag("test") as TestChildViewModelFragment +} + +class TestChildViewModelFragment( + vmFactory: () -> TestViewModel, + vmSavedStateFactory: (SavedStateHandle) -> TestSavedStateViewModel +) : Fragment() { + + val parentViewModel by viewModels({ requireParentFragment() }, vmFactory) + val savedStateParentViewModel by viewModels({ requireParentFragment() }, vmSavedStateFactory) +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2ee2e1cd..ec46e801 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,8 @@ plugins { id 'org.jetbrains.kotlin.jvm' version "$kotlin_version" apply false + id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false id 'com.google.devtools.ksp' version "$ksp_version" apply false + id 'com.android.library' version '4.1.0' apply false id "de.marcphilipp.nexus-publish" version "0.4.0" apply false id "io.gitlab.arturbosch.detekt" version "1.15.0" } diff --git a/gradle.properties b/gradle.properties index 9613f6d3..84624233 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ kotlin_version=1.4.30 ksp_version=1.4.30-1.0.0-alpha05 org.gradle.parallel=true -ksp.incremental=true \ No newline at end of file +ksp.incremental=true +android.useAndroidX=true \ No newline at end of file diff --git a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/Ast.kt b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/Ast.kt index 9eb1d07a..faafbf3a 100644 --- a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/Ast.kt +++ b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/Ast.kt @@ -47,6 +47,8 @@ abstract class AstClass : AstElement(), AstHasModifiers { abstract val name: String + abstract val isError: Boolean + abstract val companion: AstClass? abstract val isObject: Boolean diff --git a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/FailedToGenerateException.kt b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/FailedToGenerateException.kt index b14340ff..98beed56 100644 --- a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/FailedToGenerateException.kt +++ b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/FailedToGenerateException.kt @@ -2,3 +2,6 @@ package me.tatarka.inject.compiler class FailedToGenerateException(message: String, val element: AstElement? = null) : Exception(message) + + +class ErrorTypeException: Exception() \ No newline at end of file diff --git a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeCollector.kt b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeCollector.kt index 27097139..574f0ce5 100644 --- a/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeCollector.kt +++ b/kotlin-inject-compiler/core/src/main/kotlin/me/tatarka/inject/compiler/TypeCollector.kt @@ -117,6 +117,9 @@ class TypeCollector private constructor(private val provider: AstProvider, priva var elementScope: AstType? = null astClass.visitInheritanceChain { parentClass -> + if (parentClass.isError) { + throw ErrorTypeException() + } val parentScope = parentClass.scopeType(options) if (parentScope != null) { if (scopeClass == null) { diff --git a/kotlin-inject-compiler/kapt/src/main/kotlin/me/tatarka/inject/compiler/kapt/ModelAst.kt b/kotlin-inject-compiler/kapt/src/main/kotlin/me/tatarka/inject/compiler/kapt/ModelAst.kt index f1e565f3..eac96833 100644 --- a/kotlin-inject-compiler/kapt/src/main/kotlin/me/tatarka/inject/compiler/kapt/ModelAst.kt +++ b/kotlin-inject-compiler/kapt/src/main/kotlin/me/tatarka/inject/compiler/kapt/ModelAst.kt @@ -215,6 +215,7 @@ private class PrimitiveModelAstClass( override val packageName: String = "kotlin" override val name: String = type.toString() + override val isError: Boolean = false override val visibility: AstVisibility = AstVisibility.PUBLIC override val isAbstract: Boolean = false override val isInterface: Boolean = false @@ -257,6 +258,8 @@ private class ModelAstClass( override val name: String get() = element.simpleName.toString() + override val isError: Boolean = false + override val visibility: AstVisibility get() = astVisibility(element, kmClass?.flags) diff --git a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/InjectProcessor.kt b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/InjectProcessor.kt index c5d1c8bf..d5661930 100644 --- a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/InjectProcessor.kt +++ b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/InjectProcessor.kt @@ -5,7 +5,10 @@ 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.validate import me.tatarka.inject.compiler.COMPONENT +import me.tatarka.inject.compiler.ErrorTypeException import me.tatarka.inject.compiler.FailedToGenerateException import me.tatarka.inject.compiler.InjectGenerator import me.tatarka.inject.compiler.Options @@ -37,7 +40,12 @@ class InjectProcessor(private val profiler: Profiler? = null) : SymbolProcessor, val generator = InjectGenerator(this, options) - for (element in resolver.getSymbolsWithClassAnnotation(COMPONENT.packageName, COMPONENT.simpleName)) { + val (elements, failedToValidate) = resolver.getSymbolsWithClassAnnotation( + COMPONENT.packageName, + COMPONENT.simpleName + ).partition { it.validate() } + + for (element in elements) { val astClass = element.toAstClass() try { @@ -52,7 +60,9 @@ class InjectProcessor(private val profiler: Profiler? = null) : SymbolProcessor, profiler?.onStop() - return emptyList() + logger.warn("failed to process: ${failedToValidate.joinToString(", ")}") + + return failedToValidate } override fun finish() { diff --git a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/KSAst.kt b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/KSAst.kt index 747fadd0..53cd28a4 100644 --- a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/KSAst.kt +++ b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/KSAst.kt @@ -26,6 +26,7 @@ import com.google.devtools.ksp.symbol.Modifier import com.google.devtools.ksp.symbol.Nullability import com.google.devtools.ksp.symbol.Variance import com.google.devtools.ksp.symbol.Visibility +import com.google.devtools.ksp.validate import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.MemberName @@ -125,7 +126,7 @@ object KSAstMessenger : Messenger { } } -private interface KSAstAnnotated : AstAnnotated, KSAstProvider { +interface KSAstAnnotated : AstAnnotated, KSAstProvider { val declaration: KSAnnotated override fun hasAnnotation(packageName: String, simpleName: String): Boolean { @@ -158,6 +159,9 @@ private class KSAstClass(provider: KSAstProvider, override val declaration: KSCl override val name: String get() = declaration.simpleName.asString() + override val isError: Boolean + get() = !declaration.validate() + override val visibility: AstVisibility get() = declaration.getVisibility().astVisibility() diff --git a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/Util.kt b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/Util.kt index cd801322..bc9f1d84 100644 --- a/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/Util.kt +++ b/kotlin-inject-compiler/ksp/src/main/kotlin/me/tatarka/inject/compiler/ksp/Util.kt @@ -34,6 +34,10 @@ fun KSAnnotated.annotationAnnotatedWith(packageName: String, simpleName: String) return null } +fun KSAnnotated.findAnnotation(packageName: String, simpleName: String): KSAnnotation? { + return annotations.first { it.hasName(packageName, simpleName) } +} + fun KSAnnotated.hasAnnotation(packageName: String, simpleName: String): Boolean { return annotations.any { it.hasName(packageName, simpleName) } } diff --git a/kotlin-inject-runtime/build.gradle b/kotlin-inject-runtime/build.gradle index 5d801919..ddd7d5d8 100644 --- a/kotlin-inject-runtime/build.gradle +++ b/kotlin-inject-runtime/build.gradle @@ -13,4 +13,4 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { kotlinOptions.jvmTarget = "1.8" } -apply from: '../publish.gradle' +apply from: "$rootProject.projectDir/publish.gradle" \ No newline at end of file diff --git a/local.properties b/local.properties new file mode 100644 index 00000000..ff7f1c05 --- /dev/null +++ b/local.properties @@ -0,0 +1,10 @@ +## This file is automatically generated by Android Studio. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file should *NOT* be checked into Version Control Systems, +# as it contains information specific to your local configuration. +# +# Location of the SDK. This is only used by Gradle. +# For customization when using a Version Control System, please read the +# header note. +sdk.dir=/Users/evantatarka/Library/Android/sdk \ No newline at end of file diff --git a/publish.gradle b/publish.gradle index 673b77d1..12ec13b7 100644 --- a/publish.gradle +++ b/publish.gradle @@ -23,11 +23,15 @@ nexusPublishing { connectTimeout.set(Duration.ofMinutes(5)) } +def customGroupId = project.ext.find("groupId") def customArtifactId = project.ext.find("artifactId") publishing { publications { lib(MavenPublication) { + if (customGroupId != null) { + groupId customGroupId + } if (customArtifactId != null) { artifactId customArtifactId } diff --git a/settings.gradle b/settings.gradle index 0463ad12..4686fe0a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,13 @@ pluginManagement { gradlePluginPortal() google() } + resolutionStrategy { + eachPlugin { + if (requested.id.id.startsWith("com.android")) { + useModule("com.android.tools.build:gradle:${requested.version}") + } + } + } } rootProject.name='kotlin-inject' @@ -16,4 +23,8 @@ include ':integration-tests:kapt' include ':integration-tests:ksp' include ':integration-tests:module' include ':integration-tests:kapt-companion' -include ':integration-tests:ksp-companion' \ No newline at end of file +include ':integration-tests:ksp-companion' +include ':android:activity' +include ':android:fragment' +include ':android:fragment-factory-compiler' +include ':android:integration-tests' \ No newline at end of file