Gradle Tutorial for Android: Getting Started – Part 2

In this Gradle Build Script tutorial, you’ll learn build types, product flavors, build variants, and how to add additional information such as the date to the APK file name. By Ricardo Costeira.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Using the new Variants API

Enough dwelling in the past. You’ll now write a task to add the build date to your APK name, using the new Variants API. For some added complexity, you’ll do it for release builds only.

You already saw how to create a simple task, so you’ll create an enhanced one now. To start, you have to encapsulate the behavior inside a class. You can have this class in the module-level build.gradle.kts file, but it won’t be visible from the outside that way. If you needed it to be visible from the outside, you could place it in the src/main/java directory of the buildSrc, for example.

For now though, the module-level’s build.gradle.kts is enough. Below the android block, add:

// 1
abstract class AddCurrentDateTask: DefaultTask() {
  // 2
  @get:Input
  abstract val buildFlavor: Property<String>

  @get:Input
  abstract val buildType: Property<String>
  // 3
  @TaskAction
  fun taskAction() {
    // 4
    val oldFileName = "app-${buildFlavor.get()}-${buildType.get()}.apk"
    val sourcePath = "${project.buildDir}/outputs/apk/${buildFlavor.get()}/${buildType.get()}/"
    // 5
    val date = SimpleDateFormat("dd-MM-yyyy").format(Date())
    // 6
    project.copy {
      from(sourcePath + oldFileName)
      into(sourcePath)
      rename { date + "_" + oldFileName  }
    }
  }
}

This code does a lot of different things:

  1. You define an abstract class that extends DefaultTask. All task classes must extend this class.
  2. You define the task’s properties. You can have different kinds of values (not necessarily Property), and different kinds of inputs and outputs, marked by different annotations. Here, you have two Property variables that you’ll use as inputs — hence the @Input annotation.
  3. You define the task’s action. It doesn’t matter what you name the method, as long as you annotate it with @TaskAction.
  4. You build the original file name and source path through the input parameters.
  5. You get the current date.
  6. You use copy (one of Gradle’s built-in enhanced tasks) to copy the file into the same directory, but prefixing the file name with the date.

If you were using build.gradle, you could write it either in Groovy or Java. Here’s the Java version, to honor Android’s old and dark times: :]

// 1
abstract class AddCurrentDateTask extends DefaultTask {
  // 2
  @Input
  abstract Property<String> getBuildFlavor()

  @Input
  abstract Property<String> getBuildType()
  // 3
  @TaskAction
  void taskAction() {
    // 4
    String oldFileName = "app-${buildFlavor.get()}-${buildType.get()}.apk"
    String sourcePath = "${project.buildDir}/outputs/apk/${buildFlavor.get()}/${buildType.get()}/"
    // 5
    String date = new SimpleDateFormat("dd-MM-yyyy").format(new Date())
    // 6
    project.copy {
      from(sourcePath + oldFileName)
      into(sourcePath)
      rename { date + "_" + oldFileName  }
    }
  }
}

You need to add in both import java.util.Date and import java.text.SimpleDateFormat for build.gradle.kts, and import java.text.SimpleDateFormat for build.gradle.

One thing to keep in mind is that you could have the class variables as simple String types. However, declaring them as Property is a good practice. Property properties are lazy, which means that Gradle will defer calculating their values until they’re needed. This provides a few advantages, like performance improvements during the configuration phase, Gradle being able to automatically infer task dependencies through their Property inputs and outputs, among others.

You have your enhanced task, but you can’t use it just like this — it’s an abstract class with abstract properties, after all! Same programming rules apply here: you need to instantiate a class in order to set up those abstract properties.

In order to to so, add the following code just below it in build.gradle.kts:

// 1
androidComponents {
  // 2
  onVariants(selector().all()) { variant ->
    // 3
    val addCurrentDateTask = project.tasks.register(
      name = variant.name + "AddCurrentDateTask",
      type = AddCurrentDateTask::class
    ) {
      // 4
      buildFlavor.set(variant.flavorName.orEmpty())
      buildType.set(variant.buildType)
    }
    // 5 and 6
    val assembleTaskName = "assemble${variant.name.capitalized()}"
    // 7
    project.tasks.matching { it.name == assembleTaskName }.configureEach {
      finalizedBy(addCurrentDateTask)
    }
  }
}

Here’s what’s happening:

  1. With the old Variants API, you’d use android. to access Android build properties and artefacts. Using the new API, you wrap the whole thing with this androidComponents.
  2. You use the onVariants callback to access the variant objects, passing in selector().all() so that all available variants are considered. For instance, if you just wanted to run this code for release variants, you could pass in selector().withBuildType("release").
  3. You finally create a task of type AddCurrentDateTask, using both the class name and the variant’s name to register a different task for each variant.
  4. In the task’s scope, you set the values for the task’s properties.
  5. You get the variant name and capitalize the word. As you can see, it’s a lot simpler with Kotlin. :]
  6. You use the capitalized variant name to figure out the assembleX task name. For instance, assemblePaidDebug, assembleFreeRelease, etc.
  7. Using the value from the previous step, you find the actual task through the matching function in the tasks object. Then, you configure the matched task to be finalizedBy your task. This finalizedBy method is a built-in Task method that tells Gradle to run whatever task (or tasks) you pass into it when the task you call it on finishes.

Here’s the corresponding Groovy (Java!) version:

// 1
androidComponents {
  // 2
  onVariants(selector().all(), { variant ->
    // 3
    TaskProvider addCurrentDateTask = project.tasks.register(
      variant.getName() + "AddCurrentDateTask",
      AddCurrentDateTask.class
    ) {
      // 4
      buildType.set(variant.getBuildType())
      buildFlavor.set(variant.getFlavorName())
    }
 
    // 5
    String capitalizedVariantName = variant.getName()
        .substring(0, 1)
        .toUpperCase()
        .concat(variant.getName().substring(1))

    // 6
    String assembleTaskName = "assemble$capitalizedVariantName"

    // 7
    project.tasks.matching { it.name == assembleTaskName }.configureEach {
      finalizedBy(addCurrentDateTask)
    }
  })
}

Something important regarding onVariants: This callback is executed after the variant artifacts are created, and it cannot be changed. In case you need to perform any changes to the artifacts before they’re created, you can use beforeVariants.

Sync the project. Go to the terminal and remove the generated outputs folder again:

rm -rf app/build/outputs/apk

Next, run the command:

./gradlew assembleDebug

When the command finishes, check out the results with the commands:

ls -R app/build/outputs/apk

You’ll see that you now have generated APKs with the date on their names:

free paid

app/build/outputs/apk/free:
debug

app/build/outputs/apk/free/debug:
01-10-2023_app-free-debug.apk app-free-debug.apk            output-metadata.json

app/build/outputs/apk/paid:
debug

app/build/outputs/apk/paid/debug:
01-10-2023_app-paid-debug.apk app-paid-debug.apk            output-metadata.json

Creating Custom Plugins

It’s usually a good idea to factor out your code into smaller pieces so it can be reused. Similarly, you can factor out your tasks into a custom behavior for the building process as a plugin. This will allow you to reuse the same behavior in other modules you may add to your project.

To create a plugin, add the following class below the AddCurrentDateTask class in the module-level build.gradle.kts file:

class AddCurrentDatePlugin : Plugin<Project> {
  override fun apply(target: Project) {
    target.androidComponents {
      onVariants(selector().all()) { variant ->
        val addCurrentDateTask = target.tasks.register(
          name = variant.name + "AddCurrentDateTask",
          type = AddCurrentDateTask::class
        ) {
          buildFlavor.set(variant.flavorName.orEmpty())
          buildType.set(variant.buildType) 
        }

        val assembleTaskName = "assemble${variant.name.capitalized()}"

        target.tasks.matching { it.name == assembleTaskName }.configureEach {
          finalizedBy(addCurrentDateTask)
        }
      }
    }
  }
}

In plain old Groovy world, you could go with a Java version:

class AddCurrentDatePlugin implements Plugin<Project> {
  @Override
  void apply(Project target) {
    target.androidComponents {
      onVariants(selector().all(), { variant ->
        TaskProvider addCurrentDateTask = target.tasks.register(
          variant.getName() + "AddCurrentDateTask",
          AddCurrentDateTask.class
        ) {
          buildType.set(variant.getBuildType())
          buildFlavor.set(variant.getFlavorName())
        }

        String capitalizedVariantName = variant.getName()
            .substring(0, 1)
            .toUpperCase()
            .concat(variant.getName().substring(1))

        String assembleTaskName = "assemble$capitalizedVariantName"

        target.tasks.matching { it.name == assembleTaskName }.configureEach {
          finalizedBy(addCurrentDateTask)
        }
      })
    }
  }
}

As you can see, the code inside target.androidComponents is exactly the same code you have where you defined the task. That said, you can delete the androidComponents block you defined earlier.

To use the plugin, you now need to apply it. At the top of the build.gradle.kts, between the plugins and android blocks, add:

apply<AddCurrentDatePlugin>()

In a build.gradle file, you would need to do this instead:

apply plugin: AddCurrentDatePlugin

Sync Gradle, delete the apk folder like before, and run ./gradlew assembleDebug again. You’ll see that you got the same output as before: the APK files with the current date prefixed to their names:

free paid

app/build/outputs/apk/free:
debug

app/build/outputs/apk/free/debug:
01-10-2023_app-free-debug.apk app-free-debug.apk            output-metadata.json

app/build/outputs/apk/paid:
debug

app/build/outputs/apk/paid/debug:
01-10-2023_app-paid-debug.apk app-paid-debug.apk            output-metadata.json