diff --git a/anvilannotations/.gitignore b/anvilannotations/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/anvilannotations/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/anvilannotations/build.gradle.kts b/anvilannotations/build.gradle.kts new file mode 100644 index 0000000000..80b108d11f --- /dev/null +++ b/anvilannotations/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + api(libs.inject) +} \ No newline at end of file diff --git a/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt new file mode 100644 index 0000000000..e8fdc272c9 --- /dev/null +++ b/anvilannotations/src/main/java/io/element/android/x/anvilannotations/ContributesViewModel.kt @@ -0,0 +1,17 @@ +package io.element.android.x.anvilannotations + +import kotlin.reflect.KClass + +/** + * Adds view model to the specified component graph. + * Equivalent to the following declaration in a dagger module: + * + * @Binds + * @IntoMap + * @ViewModelKey(YourViewModel::class) + * public abstract fun bindYourViewModelFactory(factory: YourViewModel.Factory): AssistedViewModelFactory<*, *> + */ +@Target(AnnotationTarget.CLASS) +annotation class ContributesViewModel( + val scope: KClass<*>, +) \ No newline at end of file diff --git a/anvilcodegen/.gitignore b/anvilcodegen/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/anvilcodegen/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/anvilcodegen/build.gradle.kts b/anvilcodegen/build.gradle.kts new file mode 100644 index 0000000000..4352e20e4c --- /dev/null +++ b/anvilcodegen/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kapt) +} + +/* +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + freeCompilerArgs += listOf( + "-opt-in=com.squareup.anvil.annotations.ExperimentalAnvilApi") + } +} + + */ + +dependencies { + implementation(project(":anvilannotations")) + api(libs.anvil.compiler.api) + implementation(libs.anvil.compiler.utils) + implementation("com.squareup:kotlinpoet:1.10.2") + implementation(libs.dagger) + compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") + kapt("com.google.auto.service:auto-service:1.0.1") +} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b336054c10..9c465615ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,8 @@ plugins { id("io.element.android-compose-application") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) + alias(libs.plugins.anvil) id("com.google.firebase.appdistribution") version "3.0.2" } @@ -131,6 +132,8 @@ dependencies { implementation(libs.timber) implementation(libs.mavericks.compose) + implementation(libs.dagger) + implementation(libs.showkase) ksp(libs.showkase.processor) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1c9d692846..46fb58517a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,9 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.anvil) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kapt) apply false } tasks.register("clean").configure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a97005caaa..2eed05aef1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ constraintlayout = "2.1.4" recyclerview = "1.2.1" lifecycle = "2.5.1" activity_compose = "1.6.1" +fragment = "1.5.5" # Compose compose_compiler = "1.3.2" @@ -46,6 +47,10 @@ showkase = "1.0.0-beta14" compose_destinations = "1.7.23-beta" jsoup = "1.15.3" +# DI +dagger = "2.32" +anvil = "2.4.2" + [libraries] # Project android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } @@ -62,6 +67,7 @@ androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx_lifecycle_viewmodel_compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity_compose" } +androidx_fragment = {module = "androidx.fragment:fragment-ktx", version.ref = "fragment"} androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } androidx_compose_foundation = { group = "androidx.compose.foundation", name = "foundation" } @@ -92,6 +98,7 @@ test_barista = { module = "com.adevinta.android:barista", version.ref = "test_ba test_hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "test_hamcrest" } test_orchestrator = { module = "androidx.test:orchestrator", version.ref = "test_orchestrator" } +# Others mavericks_compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } coil = { module = "io.coil-kt:coil", version.ref = "coil" } @@ -104,6 +111,12 @@ showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +# Di +inject = {module = "javax.inject:javax.inject", version = "1"} +dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } +anvil_compiler_api = { module = "com.squareup.anvil:compiler-api", version.ref = "anvil" } +anvil_compiler_utils = { module = "com.squareup.anvil:compiler-utils", version.ref = "anvil" } + # Composer wysiwyg = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } @@ -113,4 +126,7 @@ wysiwyg = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } android_application = { id = "com.android.application", version.ref = "android_gradle_plugin" } android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file +kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kapt = {id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin"} +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +anvil = {id = "com.squareup.anvil", version.ref = "anvil"} \ No newline at end of file diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index 6706fcdfa8..1afadbd396 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -5,3 +5,9 @@ plugins { android { namespace = "io.element.android.x.core" } + +dependencies { + api(libs.mavericks.compose) + api(libs.dagger) + api(libs.androidx.fragment) +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt b/libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt new file mode 100644 index 0000000000..2b4fe0f779 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/di/AssistedViewModelFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * 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 io.element.android.x.core.di + +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel + +interface AssistedViewModelFactory, S : MavericksState> { + fun create(initialState: S): VM +} diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt b/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt new file mode 100644 index 0000000000..5d808c8c09 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/di/Bindings.kt @@ -0,0 +1,53 @@ +package io.element.android.x.core.di + +import android.content.Context +import android.content.ContextWrapper +import androidx.fragment.app.Fragment + +/** + * Use this to get the Dagger "Bindings" for your module. Bindings are used if you need to directly interact with a dagger component such as: + * * an inject function: `inject(MyFragment frag)` + * * an explicit getter: `fun myClass(): MyClass` + * + * Anvil will make your Dagger component implement these bindings so that you can call any of these functions on an instance of your component. + * + * [bindings] will walk up the Fragment/Activity hierarchy and check for [DaggerComponentOwner] to see if any of its components implement the + * specified bindings. Most of the time this will "just work" and you don't have to think about it. + * + * For example, if your class has @Inject properties: + * 1) Create an bindings interface such as `YourModuleBindings` + * 1) Add an inject function like `fun inject(yourClass: YourClass)` + * 2) Contribute your interface to the correct component via `@ContributesTo(AppScope::class)`. + * 3) Call bindings().inject(this). + */ +inline fun Context.bindings() = bindings(T::class.java) + +/** + * @see bindings + */ +inline fun Fragment.bindings() = bindings(T::class.java) + +/** Use no-arg extension function instead: [Context.bindings] */ +fun Context.bindings(klass: Class): T { + // search dagger components in the context hierarchy + return generateSequence(this) { (it as? ContextWrapper)?.baseContext } + .plus(applicationContext) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: error("Unable to find bindings for ${klass.name}") +} + +/** Use no-arg extension function instead: [Fragment.bindings] */ +fun Fragment.bindings(klass: Class): T { + // search dagger components in fragment hierarchy, then fallback to activity and application + return generateSequence(this, Fragment::getParentFragment) + .filterIsInstance() + .map { it.daggerComponent } + .flatMap { if (it is Collection<*>) it else listOf(it) } + .filterIsInstance(klass) + .firstOrNull() + ?: requireActivity().bindings(klass) +} \ No newline at end of file diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/DaggerComponentOwner.kt b/libraries/core/src/main/java/io/element/android/x/core/di/DaggerComponentOwner.kt new file mode 100644 index 0000000000..d8e469befe --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/di/DaggerComponentOwner.kt @@ -0,0 +1,10 @@ +package io.element.android.x.core.di + +/** + * A [DaggerComponentOwner] is anything that "owns" a Dagger Component. + * + */ +interface DaggerComponentOwner { + /** This is either a component, or a list of components. */ + val daggerComponent: Any +} \ No newline at end of file diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt b/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt new file mode 100644 index 0000000000..974a9b8c9e --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/di/DaggerMavericksViewModelFactory.kt @@ -0,0 +1,68 @@ +package io.element.android.x.core.di + +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModel +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.ViewModelContext + +/** + * To connect Mavericks ViewModel creation with Anvil's dependency injection, add the following to your MavericksViewModel. + * + * Example: + * + * @ContributesViewModel(YourScope::class) + * class MyViewModel @AssistedInject constructor( + * @Assisted initialState: MyState, + * …, + * ): MavericksViewModel(...) { + * … + * + * companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() + * } + */ + +inline fun , S : MavericksState> daggerMavericksViewModelFactory() = DaggerMavericksViewModelFactory(VM::class.java) + +/** + * A [MavericksViewModelFactory] makes it easy to create instances of a ViewModel + * using its AssistedInject Factory. This class should be implemented by the companion object + * of every ViewModel which uses AssistedInject via [daggerMavericksViewModelFactory]. + * + * @param viewModelClass The [Class] of the ViewModel being requested for creation + * + * This class accesses the map of ViewModel class to [AssistedViewModelFactory]s from the nearest [DaggerComponentOwner] and + * uses it to retrieve the requested ViewModel's factory class. It then creates an instance of this ViewModel + * using the retrieved factory and returns it. + * @see daggerMavericksViewModelFactory + */ +class DaggerMavericksViewModelFactory, S : MavericksState>( + private val viewModelClass: Class +) : MavericksViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: S): VM { + val bindings: DaggerMavericksBindings = when (viewModelContext) { + is FragmentViewModelContext -> viewModelContext.fragment.bindings() + else -> viewModelContext.activity.bindings() + } + val viewModelFactoryMap = bindings.viewModelFactories() + val viewModelFactory = viewModelFactoryMap[viewModelClass] ?: error("Cannot find ViewModelFactory for ${viewModelClass.name}.") + + @Suppress("UNCHECKED_CAST") + val castedViewModelFactory = viewModelFactory as? AssistedViewModelFactory + val viewModel = castedViewModelFactory?.create(state) + return viewModel as VM + } +} + +/** + * These Anvil/Dagger bindings are used by [DaggerMavericksViewModelFactory]. The factory will find the nearest [DaggerComponentOwner] + * that implements these bindings. It will then attempt to retrieve the [AssistedViewModelFactory] for the given ViewModel class. + * + * In this example, this bindings class is implemented by [com.airbnb.mvrx.sample.anvil.feature.ExampleFeatureComponent] because + * it provides the [com.airbnb.mvrx.sample.anvil.feature.ExampleFeatureViewModel]. Any component that will generate ViewModels should + * either implement this directly or have this added via `@ContributesTo(YourScope::class)`. + */ +interface DaggerMavericksBindings { + fun viewModelFactories(): Map>, AssistedViewModelFactory<*, *>> +} \ No newline at end of file diff --git a/libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt b/libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt new file mode 100644 index 0000000000..67a259a879 --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/di/ViewModelKey.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2019 New Vector Ltd + * + * 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 io.element.android.x.core.di + +import com.airbnb.mvrx.MavericksViewModel +import dagger.MapKey +import kotlin.reflect.KClass + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@MapKey +annotation class ViewModelKey(val value: KClass>) diff --git a/libraries/daggerscopes/.gitignore b/libraries/daggerscopes/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libraries/daggerscopes/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libraries/daggerscopes/build.gradle.kts b/libraries/daggerscopes/build.gradle.kts new file mode 100644 index 0000000000..80b108d11f --- /dev/null +++ b/libraries/daggerscopes/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + api(libs.inject) +} \ No newline at end of file diff --git a/libraries/daggerscopes/src/main/java/io/element/android/x/di/AppScope.kt b/libraries/daggerscopes/src/main/java/io/element/android/x/di/AppScope.kt new file mode 100644 index 0000000000..ed046bc41a --- /dev/null +++ b/libraries/daggerscopes/src/main/java/io/element/android/x/di/AppScope.kt @@ -0,0 +1,3 @@ +package io.element.android.x.di + +abstract class AppScope private constructor() diff --git a/libraries/daggerscopes/src/main/java/io/element/android/x/di/SessionScope.kt b/libraries/daggerscopes/src/main/java/io/element/android/x/di/SessionScope.kt new file mode 100644 index 0000000000..6884d9bb99 --- /dev/null +++ b/libraries/daggerscopes/src/main/java/io/element/android/x/di/SessionScope.kt @@ -0,0 +1,3 @@ +package io.element.android.x.di + +abstract class SessionScope private constructor() diff --git a/libraries/daggerscopes/src/main/java/io/element/android/x/di/SingleIn.kt b/libraries/daggerscopes/src/main/java/io/element/android/x/di/SingleIn.kt new file mode 100644 index 0000000000..43dd9acfb8 --- /dev/null +++ b/libraries/daggerscopes/src/main/java/io/element/android/x/di/SingleIn.kt @@ -0,0 +1,8 @@ +package io.element.android.x.di + +import javax.inject.Scope +import kotlin.reflect.KClass + +@Scope +@Retention(AnnotationRetention.RUNTIME) +annotation class SingleIn(val clazz: KClass<*>) \ No newline at end of file diff --git a/libraries/matrix/build.gradle.kts b/libraries/matrix/build.gradle.kts index 1af5b3898f..b420b32794 100644 --- a/libraries/matrix/build.gradle.kts +++ b/libraries/matrix/build.gradle.kts @@ -8,7 +8,7 @@ android { } dependencies { - api(project(":libraries:rustSdk")) + api(project(":libraries:rustsdk")) implementation(project(":libraries:core")) implementation(libs.timber) implementation("net.java.dev.jna:jna:5.12.1@aar") diff --git a/plugins/src/main/java/extension/VersionCatalog.kt b/plugins/src/main/java/extension/VersionCatalog.kt index 950ba1db3a..c4f824cc8b 100644 --- a/plugins/src/main/java/extension/VersionCatalog.kt +++ b/plugins/src/main/java/extension/VersionCatalog.kt @@ -2,6 +2,8 @@ package extension import org.gradle.api.artifacts.VersionCatalog +private fun VersionCatalog.getVersion(alias: String) = findVersion(alias).get() + private fun VersionCatalog.getLibrary(library: String) = findLibrary(library).get() private fun VersionCatalog.getBundle(bundle: String) = findBundle(bundle).get() diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ddc144788..b3259aa86f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,7 @@ dependencyResolutionManagement { rootProject.name = "ElementX" include(":app") include(":libraries:core") -include(":libraries:rustSdk") +include(":libraries:rustsdk") include(":libraries:matrix") include(":libraries:textcomposer") include(":libraries:elementresources") @@ -28,3 +28,6 @@ include(":features:login") include(":features:roomlist") include(":features:messages") include(":libraries:designsystem") +include(":libraries:daggerscopes") +include(":anvilannotations") +include(":anvilcodegen")