Blog Infos
Author
Published
Topics
, ,
Published

I recently finished migrating my Gradle scripts from Groovy to Kotlin DSL on my current project. It took me a lot of time searching and investigating different approaches and solutions, so I decided to collect everything in one article to simplify life for everyone else who will face a similar task in the future.

if you are from Ukraine โ€” here isย UA versionย for you!

In this article, I will consider my case โ€” migrating an existing project from Gradle 7+ to a newer version with Gradle Kotlin DSL, Android Kotlin Plugin, Version Catalog, and KSP. As a result, I ended up with this setup:

Android Studio:ย Narwhal 2025.1.2
Gradle:ย 8.11.1
Android Gradle Plugin (AGP):ย 8.10.1
Kotlin:ย 2.1.21
Compose:ย 2.1.21
KSP:ย 2.1.21โ€“2.0.1

Everything is the newest, but already stable enough to be used in production. So, if youโ€™re interested in how to set all this up โ€” letโ€™s dive in!

As always, a little theory firstโ€ฆ

First of all, you need to start with the main thing โ€” the IDE. If you want to work with the latest Gradle features, you need to update to the latest version of Android Studio. Therefore, I highly recommend checking theย tableย that shows the compatible versions of Studio and Gradle.

Personally, I useย Narwhal 2025.1.2ย andย Koala 2024.1.2ย in parallel to work with old and new projects.

If you already need to update, donโ€™t forget to check which version of the Android SDK is supported by your Studio and AGP, so you donโ€™t have to do it twice. Youโ€™ll have to update yourย target-sdkย once a year anyway, so my advice is to check everything in advance so that your Studio, Gradle, and AGP already support the latest SDK version.

The next step is, in fact, the Gradle version. Here is aย linkย to the AGP and Gradle compatibility table. Everything is simple here โ€” choose the appropriate Gradle version to build your project and add it toย gradle/gradle-wrapper.properties:

distributionUrl=https\://services.gradle.org/distributions/gradle-[VERSION]-bin.zip

The last thing you need to choose correctly is the Kotlin version. To be honest, I couldnโ€™t find a corresponding table for this, so I chose almost at random: I set a version and tried to compile. I was lucky, so I conclude thatย Kotlin 2.1.21ย works withย AGP 8.10.

If you, the reader, know where to find the correspondence between Kotlin and Gradle, I would be very grateful for a comment. And if youโ€™re also annoyed that thereโ€™s still no online generator that correctly puts all this together and gives hints โ€” hit a like!

So, we have the versions of all the necessary components: Android Studio, AGP, Gradle, Kotlin. What else do you need? The correct answer is โ€” Compose!

Weโ€™re very lucky that, starting from version 2.0, Compose has become a plugin that has the same version as Kotlin, so we donโ€™t need to look for correspondences, because the plugin will do it itself internally! And choosing the KSP version is the easiest thing of all! Here is the KSP version pattern:ย 2.1.21โ€“2.0.1, where the first part is the compatible Kotlin version, and the second is the KSP version itself. You can find this in the releases onย Github.

Moving on to practice

My project has the following modules: an Android application, an Android library, and several connected binary libs, which are also separated into individual modules for ease of connection and configuration.

I recommend starting from the project root and configuring theย version catalogย first. It should be created inย gradle/libs.versions.toml.

As an example, hereโ€™s what I came up with:

[versions]
sdk-minimal = "26"
sdk-target = "34"
version-kotlin = "2.1.21"
version-ksp = "2.1.21-2.0.1"
version-agp = "8.10.1"
version-dagger = "2.55"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version = "1.10.2" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" }

hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "version-dagger" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "version-dagger" }

androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.10.1" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.10.1" }
androidx-hilt-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }

compose-bom = { module = "androidx.compose:compose-bom", version = "2025.05.01" }
compose-ui = { module = "androidx.compose.ui:ui"}
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-material = { module = "androidx.compose.material:material" }

lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version = "2.2.0" }
lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version = "2.6.1" }
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version = "2.6.1" }
lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.6.1" }

room-runtime = { module = "androidx.room:room-runtime", version.ref = "version-room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "version-room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "version-room" }

firebase-bom = { module = "com.google.firebase:firebase-bom", version = "33.15.0" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }

[plugins]
android-application-gradle-plugin = { id = "com.android.application", version.ref = "version-agp" }
android-library-gradle-plugin = { id = "com.android.library", version.ref = "version-agp" }
kotlin-android-gradle-plugin = { id = "org.jetbrains.kotlin.android", version.ref = "version-kotlin" }
kotlin-parcelize-gradle-plugin = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "version-kotlin" }
compose-compiler-gradle-plugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "version-kotlin" }
google-gms-services-gradle-plugin = { id = "com.google.gms.google-services", version = "4.3.15" }
crashlytics-gradle-plugin = { id = "com.google.firebase.crashlytics", version = "2.9.6" }
ksp-devtools-gradle-plugin = { id = "com.google.devtools.ksp", version.ref = "version-ksp" }
dagger-hilt-gradle-plugin = { id = "com.google.dagger.hilt.android", version.ref = "version-dagger" }

[bundles]
kotlinx = [
    "kotlinx-coroutines-core",
    "kotlinx-coroutines-android",
    "kotlinx-collections-immutable",
]
androidx = [
    "androidx-core-ktx",
    "androidx-appcompat",
    "androidx-activity-compose",
]
lifecycle = [
    "lifecycle-runtime-ktx",
    "lifecycle-viewmodel-ktx",
    "lifecycle-viewmodel-compose",
    "lifecycle-runtime-compose",
    "lifecycle-viewmodel-navigation3",
]

Here are just the basics, and you might have a lot more different libraries, but the point is this: separate your dependencies and keep them together in a special catalog.

The next step is to connect the catalog and configure theย plugin/dependency resolution strategy. If your version catalog has a standard name and path, it will be picked up automatically, so all you have to do is specify the repositories for plugins and libraries. As an example, Iโ€™ll show myย settings.gradle.ktsย configuration:

pluginManagement {
  repositories {
    /// By default, google maven repository has many different libs,
    /// plugins, SDKs and so on. We constraint it to take from gooogle
    /// only those artifacts, which contains android.*, google.*, androidx.*
    google {
      content {
        includeGroupByRegex("com\\.android.*")
        includeGroupByRegex("com\\.google.*")
        includeGroupByRegex("androidx.*")
      }
    }

    mavenCentral()
    gradlePluginPortal()
  }
}

dependencyResolutionManagement {
  /// My recomendation: set this config to include
  /// your modules with pre-generated accessors
  enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    google()
    mavenCentral()
    maven(url = "https://jitpack.io")
  }
}

/// list of your modules. Generally added by Android Studio

Best practices recommend failing project synchronization if modules have custom repositories:ย RepositoriesMode.FAIL_ON_PROJECT_REPOS. But in practice, it’s not always so categorical ๐Ÿ™‚ I had to configure the build so that the repositories in the modules had priority over the repositories inย settings.gradle.kts.

The next step, I recommend configuringย build.gradle.kts, which is in the project root. In the simplest case, here we can add all those plugins from theย version catalog, so that the project “knows” about their existence. In more complex cases, you can add dependencies to theย classpathย (these are also plugins, but they are not in the general Gradle plugin portal) and configureย resolutionStrategyโ€”a strategy for managing identical libraries with different versions. Here is an example of myย build.gradle.kts:

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
  alias(libs.plugins.android.application.gradle.plugin) apply false
  alias(libs.plugins.android.library.gradle.plugin) apply false
  alias(libs.plugins.kotlin.android.gradle.plugin) apply false
  alias(libs.plugins.kotlin.parcelize.gradle.plugin) apply false
  alias(libs.plugins.crashlytics.gradle.plugin) apply false
  alias(libs.plugins.google.gms.services.gradle.plugin) apply false
  alias(libs.plugins.ksp.devtools.gradle.plugin) apply false
  alias(libs.plugins.compose.compiler.gradle.plugin) apply false
  alias(libs.plugins.dagger.hilt.gradle.plugin) apply false
}

buildscript {
  repositories {
    maven("https://aws-mobile-sdk.s3.amazonaws.com/android")
  }

  dependencies {
    classpath("com.amazonaws:aws-android-sdk-appsync-gradle-plugin:3.4.0")
  }
}

subprojects {
  tasks.withType<KotlinCompile>().configureEach {
    kotlinOptions {
      jvmTarget = JavaVersion.VERSION_17.toString()
      /// this is just an example of compiler args.
      /// Do not add them to your project, if you don't need them!
      freeCompilerArgs += listOf(
        "-Xcontext-receivers",
        "-opt-in=kotlin.RequiresOptIn",
        "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
        "-opt-in=kotlinx.coroutines.FlowPreview",
      )
    }
  }

  tasks.withType<Test>().configureEach {
    useJUnitPlatform()
    testLogging {
      events = setOf(
        TestLogEvent.PASSED,
        TestLogEvent.SKIPPED,
        TestLogEvent.FAILED,
      )
    }
  }

  configurations.all {
    resolutionStrategy {
        /// fix transitively added different versions of kotlin 
        /// with specific version we need.
        eachDependency {
          if (requested.group == "org.jetbrains.kotlin") {
            useVersion(libs.versions.version.kotlin.get())
          }
        }
      }

      /// ensure that we are using specific version of androidx-core,
      /// event if any legacy lib uses old one
      force("androidx.core:core-ktx:[our_specific_version]")
    }
  }
}

I had to pull one of the plugins through theย classpathย because it is only distributed from AWS repositories.

In addition to the Gradle scripts at the root, there is one more configuration file that is worth paying attention to:ย gradle.properties. I’ll briefly describe what is configured here:

  • Build parameter settings
  • Memory usage control
  • Enabling/disabling experimental features
  • Parameters for the Gradle Daemon
  • Passing parameters to plugins (Compose, KMP, AGP)

The simplest thing you can useย gradle.propertiesย for is to configure how much memory Gradle can use on your workstation. For example, for me it’s 16GB:

org.gradle.jvmargs=-Xmx16g -XX:MaxPermSize=16096m

Thatโ€™s all for the project root configuration โ€” letโ€™s move on to the modules themselves!

I want to say right away that you can configure things here endlessly! But weโ€™ll start in order and first look at whatโ€™s here in general:

android {
  namespace = "com.yourpackage.name"
  compileSdk = 35

  defaultConfig {
  }

  buildTypes {
  }

  productFlavors {
  }

  signingConfigs {
  }

  compileOptions {
  }

  kotlinOptions {
  }

  buildFeatures {
  }

  composeOptions {
  }
}

You can create kotlin extensions (after all, we have Kotlin DSL!), which will automatically configure everything we copy from module to module. For example,ย buildTypes:

val BuildTypes = buildList {
  this += BuildTypeData(
    type = BuildType.Debug,
    applicationIdSuffix = ".dev",
    resValues = buildList {
      this += ResValue(
        type = "string",
        name = "app_name",
        value = "Dev App Name",
      )
    },
    buildConfigFields = buildList {
      this += BuildConfigField(
        type = "String",
        name = "BASE_API_URL",
        value = "\"https://dev.api.example.com/\""
      )
    }
  )

  this += BuildTypeData(
    type = BuildType.Stage,
    applicationIdSuffix = ".stage",
    isAnalyticsEnabled = true,
    isCrashlyticsEnabled = true,
    resValues = buildList {
      this += ResValue(
        type = "string",
        name = "app_name",
        value = "Stage App Name",
      )
    },
    buildConfigFields = buildList {
      this += BuildConfigField(
        type = "String",
        name = "BASE_API_URL",
        value = "\"https://stage.api.example.com/\""
      )
    }
  )

  this += BuildTypeData(
    type = BuildType.Release,
    isProGuardEnabled = true,
    isMinifyEnabled = true,
    isShrinkResources = true,
    isAnalyticsEnabled = true,
    resValues = buildList {
      this += ResValue(
        type = "string",
        name = "app_name",
        value = "Release App Name",
      )
    },
    buildConfigFields = buildList {
      this += BuildConfigField(
        type = "String",
        name = "BASE_API_URL",
        value = "\"https://prod.api.example.com/\""
      )
    }
  )
}

In my case, I createdย BuildTypesโ€”a special container where all build types (debug, release, stage, etc.) are stored. And here’s how you can apply all this in practice inย app/build.gradle.kts:

inline fun <reified T> NamedDomainObjectContainer<T>.ensureBuildTypeBy(name: String, closure: T.() -> Unit = {}) {
    closure(findByName(name) ?: create(name))
}

buildTypes {
  BuildTypes.forEach {
    ensureBuildTypeBy(it.type.gradleName()).apply {
      versionNameSuffix = it.previewSuffix
      isDebuggable = (it.type == BuildType.Debug)
      it.resValues.forEach { (type, name, value) -> resValue(type, name, value) }
      it.buildConfigFields.forEach { (type, name, value) -> buildConfigField(type, name, value) }
    }
  }
}

In addition to build types, there are two other important things that can be configured in a similar way:ย flavorsย andย signingConfig. Everything is the same: you need to create a data model with the appropriate fields, fill them with data (forย signingConfig, this data can be taken from a localย .propertiesย file, but don’t forget to exclude it from the commit!) and iterate through and apply it to different types inย signingConfig.

Letโ€™s move on โ€” configuring the compatible Java version (for Kotlin 2.0+, this is, at a minimum, version 11):

compileOptions {
  sourceCompatibility = JavaVersion.VERSION_11
  targetCompatibility = JavaVersion.VERSION_11
}

kotlinOptions {
  jvmTarget = JavaVersion.VERSION_11.toString()
}

I recommend putting this in constants somewhere at theย root/build.gradle.ktsย level or inย buildSrcย orย build-logic, if your project has them.

Another important block for configuration isย buildFeatures. Below, I will list the most popular flags that can be configured in this section:

  • aidlย enables AIDL support (inter-application-process communication viaย .aidlย files).
  • buildConfigย generates aย BuildConfigย class with constants (disabled in new versions becauseย BuildConfigย is deprecated).
  • composeย enables Jetpack Compose support.
  • dataBindingย enables Android DataBinding (do not confuse with ViewBinding).
  • viewBindingย enables ViewBinding (safe access to Views withoutย findViewById).

The last block isย composeOptions. It’s simple here; it serves to configure Compose in the project:

  • kotlinCompilerExtensionVersionโ€”specifies the compatible version of the Kotlin compiler plugin.
  • kotlinCompilerVersionโ€”specifies the compatible Kotlin version.
  • useLiveLiteralsโ€”enables hot reload support for string literals, numbers, etc.

I think the last flag will be the most useful ๐Ÿ™‚

Dependencies, where would we be without them?

Weโ€™ve covered all possible and impossible configurations above, and now for the most important thing that we use Gradle for 90% of the time โ€”ย dependencies.

Press enter or click to view image in full size

source

Letโ€™s briefly consider 2 cases: configuring aย library-moduleย and anย app-moduleย (letโ€™s start with the app):

dependencies {
    implementation(projects.libModule1)
    implementation(projects.libModule2)

    /// other library modules
    implementation(libs.bundles.androidx)
    implementation(libs.bundles.lifecycle)
    implementation(libs.bundles.kotlinx)

    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth)
    implementation(libs.firebase.crashlytics)
    implementation(libs.firebase.analytics)

    implementation(libs.room.ktx)
    implementation(libs.room.runtime)
    ksp(libs.room.compiler)

    implementation(libs.hilt.android)
    implementation(libs.hilt.compose)
    ksp(libs.hilt.compiler)

    /// ...
}

Since all our dependencies are in the version catalog, we access them through the generated type-safe accessors, which also work well with autocompletion in the IDE, so we donโ€™t have to remember what everything is called.

Regarding dependency configuration in aย library-module, if your project already has modules, I recommend moving common libraries to one that is named something likeย commonย orย coreย (for example, you can moveย kotlinxย configuration orย lifecycleย dependencies, or evenย retrofit/ktorโ€”in general, everything that is basic can be in a basic module):

dependencies {
    api(libs.bundles.androidx)
    api(libs.bundles.lifecycle)
    api(libs.bundles.kotlinx)
}

Now, theย app-moduleย configuration will look simpler:

dependencies {
    implementation(projects.common)
    /// ...

    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth)
    implementation(libs.firebase.crashlytics)
    implementation(libs.firebase.analytics)

    /// ...
}

In more complex cases, you can move this toย build-logicย and create a plugin there that will set up all the main dependencies.

What else can be configured?

Some plugins provide the ability to configure themselves by adding additional sections to the config:

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
}

room {
    schemaDirectory("$projectDir/schemas")
}

This is usually indicated directly in the documentation, so I want to move on to less obvious but no less useful things:

The name of the output file

android {
    applicationVariants.all {
        val variant = this
        val formattedName = "app-${it.name}-$versionName-$versionCode"
    
        variant.outputs.all {
            this as ApkVariantOutputImpl
            outputFileName = "$formattedName.apk"
        }
    
        project.tasks.named("sign${variant.name.capitalized()}Bundle", FinalizeBundleTask::class.java) {
            val artifactDestination = finalBundleFile.asFile.get().parentFile
            finalBundleFile.set(File(artifactDestination, "$formattedName.aab"))
        }
    }
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

In this way, we can set the name of the artifact for different build types (release/debug/custom) with support for app bundles and the old APK format. This is very convenient for CI, when we also specify the version in the artifact name, so QA or a customer can distinguish what kind of build it is.

Version and code can be passed as script parameters

Usually, we manually setย versionNameย andย versionCode, but what if we have CI and want to automatically set the build code? This can be easily done like this:

android {
    defaultConfig {
        versionCode = applicationVersionCode
        versionName = applicationVersionName
    }
}

/// ...

val Project.applicationVersionCode: Int
    get() = obtainPropertyBy<String>("versionCode")?.toInt() ?: 1

val Project.applicationVersionName: String
    get() = obtainPropertyBy<String>("versionName") ?: "dev build"

inline fun <reified T : Any> Project.obtainPropertyBy(name: String): T? {
    return when {
        hasProperty(name) -> property(name) as T
        else -> null
    }
}

In such a configuration, on CI, we can pass parameters through bash/shell scripts (or through the custom tasks of your CI provider), and they will automatically get into the necessary fields of the artifact. The same applies toย signingConfigโ€”we can pass it in the parameters.

What does it all look like together?

Finally, I want to show you what my Gradle KTS file looks like after all the work:

plugins {
    alias(libs.plugins.android.application.gradle.plugin)
    alias(libs.plugins.kotlin.android.gradle.plugin)
    alias(libs.plugins.ksp.devtools.gradle.plugin)
    alias(libs.plugins.compose.compiler.gradle.plugin)
    alias(libs.plugins.dagger.hilt.gradle.plugin)
}

android {
    namespace = "com.sagrishin.mycleanapp"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.sagrishin.mycleanapp"

        minSdk = 24
        targetSdk = 35

        versionCode = applicationVersionCode
        versionName = applicationVersionName
    }

    applicationVariants.all {
        val variant = this
        val formattedName = "app-${it.name}-$versionName-$versionCode"
    
        variant.outputs.all {
            this as ApkVariantOutputImpl
            outputFileName = "$formattedName.apk"
        }
    
        project.tasks.named("sign${variant.name.capitalized()}Bundle", FinalizeBundleTask::class.java) {
            val artifactDestination = finalBundleFile.asFile.get().parentFile
            finalBundleFile.set(File(artifactDestination, "$formattedName.aab"))
        }
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")

            buildConfigField("String", "API_URL", "\"https://api.hostname.com/api\"")
            resValue("string", "push_notification_activity", "$namespace.HOME_ACTIVITY")
        }

        debug {
            buildConfigField("String", "API_URL", "\"https://dev.hostname.com/api\"")
            resValue("string", "push_notification_activity", "$namespace.HOME_ACTIVITY")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_17.toString()
    }

    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }

    buildFeatures {
        compose = true
        buildConfig = true
    }
}

dependencies {
    implementation(libs.kotlin.reflect)
    implementation(libs.bundles.kotlinx)
    implementation(libs.bundles.androidx)
    implementation(libs.bundles.lifecycle)
    implementation(libs.bundles.networking)

    implementation(platform(libs.compose.bom))
    implementation(libs.bundles.compose)
    debugImplementation(libs.compose.ui.tooling)

    implementation(platform(libs.firebase.bom))
    implementation(libs.firebase.auth.ktx)
    implementation(libs.firebase.crashlytics.ktx)
    implementation(libs.firebase.firestore.ktx)
    implementation(libs.firebase.analytics.ktx)

    implementation(libs.gms.play.services.auth)

    implementation(libs.localstorage.room.runtime)
    implementation(libs.localstorage.room.ktx)
    ksp(libs.localstorage.room.compiler)

    implementation(libs.hilt.android)
    implementation(libs.hilt.compose)
    ksp(libs.hilt.compiler)

    implementation(libs.navigation.ui.ktx)
    implementation(libs.navigation.compose)

    implementation(libs.google.play.update)
    implementation(libs.google.play.update.ktx)

    implementation(libs.serialization.gson)
}

room {
    schemaDirectory("$projectDir/schemas")
}

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
}

Thatโ€™s all from me. Thank you very much for reading my article to the end! I would also be grateful for a like if it was useful!

This article was previously published on proandroiddev.com.

Menu