Version Catalog & Convention Plugin
Introduction:
You’re in charge of a complex Android project with dozens of dependencies, each with its own version. Managing these dependencies manually can be a nightmare, leading to compatibility issues, bugs, and wasted time. However, there’s a hero in the world of Gradle build systems: the Gradle Version Catalog and Convention Plugin. In this article, I will take you on a journey through this powerful duo, exploring how they can revolutionize your dependency management and streamline your development process.
The Dependency Dilemma:
Version mismatches
One of the most common challenges in Android development is dealing with version mismatches. When multiple libraries and dependencies are included in a project, they may rely on different versions of the same underlying libraries or APIs. This can result in conflicts, runtime errors, and unpredictable behavior.
Example:Â Imagine a scenario where you are using two third-party libraries, LibraryA and LibraryB, in your Android project. LibraryA relies on Retrofit version 2.5.0, while LibraryB requires Retrofit version 2.7.1. Without a robust dependency management system, you would have to manually resolve this version mismatch, potentially leading to runtime issues.
Compatibility issues
Android devices come in various shapes and sizes, each with its own set of hardware capabilities and Android versions. Ensuring that your dependencies are compatible with the target devices and Android versions can be challenging.
Example: Let’s say you are developing an Android app that targets both smartphones and tablets. You are using a library called DeviceUtils, which provides hardware-specific functionality. Without proper management, you might inadvertently include tablet-specific features on smartphones or vice versa, leading to poor user experience and compatibility issues.
Circular dependency
Circular dependencies occur when two or more components depend on each other, forming a loop. This can result in compilation errors, making it difficult to build and maintain your Android project.
Example:Â Suppose you have two modules in your Android project, ModuleA and ModuleB. ModuleA depends on classes from ModuleB, and ModuleB depends on classes from ModuleA. This circular dependency creates a situation where compiling either module without errors is impossible. Breaking this circular dependency manually can be a tedious and error-prone process.
How Gradle Version Catalog and Convention Plugin Address These Issues
Gradle Version Catalog:
Gradle Version Catalog is a powerful feature that allows you to define and centralize dependency versions in a single location, ensuring that all dependencies use consistent versions throughout your project. This solves the version mismatch problem by providing a single source of truth for dependency versions.
Example:
In your project’s Gradle build file, you can define a version catalog like this:
// This is a sample for pseudo-code to show how the Version catalog is written dependencies { versionCatalogs { myVersionCatalog { commonLibVersion = "1.0.0" retrofitVersion = "2.7.1" // Add other dependency versions here } } }
When you declare dependencies in your modules, you can reference these versions from the catalog, ensuring that all modules use the same versions.
Convention Plugin:
The Gradle Convention Plugin allows you to enforce project-specific conventions, making it easier to manage compatibility and prevent circular dependencies. You can define and enforce coding and project structure conventions across your Android project
Example:
Consider that you build a plugin based on your clean architectural layers, and in each module that has its own task, only the desired plugin is added. This will apply all related configurations in that module
Enter the Gradle Version Catalog:
A common build.gradle.kts
 is something like this:
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") } android { namespace = "garousi.dev.conventionpluginsample" compileSdk = 34 defaultConfig { applicationId = "garousi.dev.conventionpluginsample" minSdk = 21 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") implementation("androidx.activity:activity-compose:1.7.2") implementation(platform("androidx.compose:compose-bom:2023.08.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") }
with powerful convention plugins, we can break this file into specific convention plugins which of those plugins can apply to every module by just applying them with our custom ID:
id("taravaz.android.library)
but how can we do this magic?
let’s step forward by taking some small steps:
From the first point of view, it is a big ghoul, but don’t worry I will explain it all
Programming is an acquired science, so from here on you may see some questions, please read the article completely to understand. I don’t miss anything
in the first step, you should create a file named libs.versions.toml
[versions] [libraries] [plugins] [bundles]
this file is responsible for holding all dependencies, plugins, and their version numbers in a proper way:
let’s move our dependencies and their versions to this file:
[versions] activity-compose = "1.7.2" androidx-junit = "1.1.5" core-ktx = "1.12.0" espresso-core = "3.5.1" junit = "4.13.2" lifecycle-runtime-ktx = "2.6.2" composeBOM = "2023.08.00" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBOM" } compose-ui-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-ui-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } compose-ui-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-ui-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } compose-material3-material3 = { group = "androidx.compose.material3", name = "material3" } [plugins] [bundles]
and our dependencies look like this:
dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui.ui) implementation(libs.compose.ui.ui.graphics) implementation(libs.compose.ui.ui.tooling.preview) implementation(libs.compose.material3.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.ui.ui.test.junit4) debugImplementation(libs.compose.ui.ui.tooling) debugImplementation(libs.compose.ui.ui.test.manifest) }
In this task, we move our dependencies to a central location so that they can be utilized in all modules. Next comes the convention plugins: I will call it build-logic. This directory includes three new important files and a change in the settings.gradle.kts
 of the root project as follows:
build.gradle.kts
 ==> responsible for registering our custom plugins
plugins { `kotlin-dsl` } group = "garousi.dev.conventionpluginsample.buildlogic"
gradle.properties
 ==> responsible for configuring this specific build-logic workflow
# Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534 org.gradle.parallel=true org.gradle.caching=true org.gradle.configureondemand=true
settings.gradle.kts
 ==> responsible for managing build-logic
 build automation and configuring our version catalog
dependencyResolutionManagement { repositories { google() mavenCentral() } versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } rootProject.name = "build-logic" include(":convention")
root settings.gradle.kts
 ==>
before:
pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "ConventionPluginSample" include(":app")
after
Job Offers
pluginManagement { includeBuild("build-logic") repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } rootProject.name = "ConventionPluginSample" include(":app")
In the following step, let’s open our app module build.gradle
 file and separate its contents into this folder:
Now we will make our first plugin named AndroidApplicationConventionPlugin
class AndroidApplicationConventionPlugin : Plugin<Project> { override fun apply(project: Project) { with(project) { pluginManager.apply { apply("com.android.application") apply("org.jetbrains.kotlin.android") } } } }
Our work is still incomplete, so we moved an application-level build.gradle.kts
 plugins to here and applied it. We can move the shared blocks of code into a Kotlin file and use it for all plugins, such as configuring kotlinOptions
, buildFeatures
, and compileOptions
 which could all go in one place.
When we tried to move the targetSdk
, we encountered an IDE error that required us to access ApplicationExtension
in order to do this; however, importing this was not possible.
How can we resolve this dilemma? We should incorporate Kotlin and Gradle Plugins into our convention build.gradle.kts
 file.
[versions] // previus versuins kotlin = "1.9.0" androidGradlePlugin = "8.2.0-beta05" [libraries] // previus libereries # Dependencies of the included build-logic kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } [plugins] [bundles]
and then use it in build.gradle.kts
 file of convention directory:
plugins { `kotlin-dsl` } group = "garousi.dev.conventionpluginsample.buildlogic" dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) }
If you hit the Sync Project With Gradle Files option located under File, it will cause the IDE to show this:
class AndroidApplicationConventionPlugin : Plugin<Project> { override fun apply(project: Project) { with(project) { pluginManager.apply { apply("com.android.application") apply("org.jetbrains.kotlin.android") } val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") extensions.configure<ApplicationExtension> { defaultConfig.targetSdk = Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString()) } } } }
as you see we get libs and then set it, but keep in mind we want to access libs in many places. the best work we can do is create an extension over Project for getting libs:
let’s do this:
targetSdk
 from the version catalog but we don’t add it in libs.versions.toml
 file[versions] // previus versions projectApplicationId = "garousi.dev.conventionpluginsample" projectVersionName = "1.0" projectMinSdkVersion = "21" projectTargetSdkVersion = "34" projectCompileSdkVersion = "34" projectVersionCode = "1"
let’s create a KotlinAndroid
 file and do some stuff here:
import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.provideDelegate import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions internal fun Project.configureKotlinAndroid( commonExtension: CommonExtension<*, *, *, *, *> ) { commonExtension.apply { compileSdk = Integer.parseInt(libs.findVersion("projectCompileSdkVersion").get().toString()) defaultConfig { minSdk = Integer.parseInt(libs.findVersion("projectMinSdkVersion").get().toString()) } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project allWarningsAsErrors = warningsAsErrors.toBoolean() // Set JVM target to 17 jvmTarget = JavaVersion.VERSION_17.toString() } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } } fun CommonExtension<*, *, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { (this as ExtensionAware).extensions.configure("kotlinOptions", block) }
AndroidApplicationConventionPlugin
 looks like this
class AndroidApplicationConventionPlugin : Plugin<Project> { override fun apply(project: Project) { with(project) { pluginManager.apply { apply("com.android.application") apply("org.jetbrains.kotlin.android") } extensions.configure<ApplicationExtension> { configureKotlinAndroid(this) defaultConfig.targetSdk = Integer.parseInt(libs.findVersion("projectTargetSdkVersion").get().toString()) } } } }
let’s register our custom plugin for being usable by the gradle build system:
plugins { `kotlin-dsl` } group = "garousi.dev.conventionpluginsample.buildlogic" dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) } gradlePlugin { plugins { register("androidApplication") { id = "conventionpluginsample.android.application" implementationClass = "AndroidApplicationConventionPlugin" } } }
Let’s go to app-level build.gradle.kts
 file and refactor it in order to use our new convention plugin
plugins { id("conventionpluginsample.android.application") } android { namespace = "garousi.dev.conventionpluginsample" defaultConfig { applicationId = "garousi.dev.conventionpluginsample" versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui.ui) implementation(libs.compose.ui.ui.graphics) implementation(libs.compose.ui.ui.tooling.preview) implementation(libs.compose.material3.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.compose.ui.ui.test.junit4) debugImplementation(libs.compose.ui.ui.tooling) debugImplementation(libs.compose.ui.ui.test.manifest) }
I will cease further rambling for the sake of brevity. However, more information can be found through researching the ComposeNews project, where I accomplished many noteworthy tasks.
Ready to Master Dependency Management?
Explore the power of Gradle Version Catalog and Convention Plugin to streamline your Android project’s dependency management. Start revolutionizing your development process and say goodbye to version conflicts and compatibility issues. If you’re hungry for more knowledge and want to see these tools in action, don’t forget to check out our project, ComposeNews
, where we’ve applied these principles extensively. Get started today and experience smoother, more efficient Android development!
This article was previously published on proandroiddev.com