loading...

Writing Gradle Plugins for Android; or, Donald Trump is a Huge Tool

autonomousapps profile image Tony Robalik ・11 min read

So you'd like to write a Gradle plugin, but most of the tutorials out there focus on Java projects. Not only are you not targeting the JVM (we're mobile, baby!), you're not even writing in Java! So let's do this -- let's write a Gradle plugin, in Kotlin, that targets Android projects (written in Java or Kotlin).

What you'll learn

  1. How to write a Gradle plugin following modern best practices.
  2. How to do it in Kotlin, and with the Kotlin DSL.
  3. How to hook into Android-specific project features, such as being variant-aware by default.
  4. How to verify your plugin works.
  5. How to make your builds insult Donald Trump, the (somehow) president of the United States of America.

Pre-requisites

  1. You have Android Studio or IDEA installed and know how to use it.
  2. You have Gradle installed (I don't mean the wrapper). I use SDKMAN to manage my Gradle installation.
  3. An understanding that Donald Trump is a huge tool.

Donald Trump is a Huge Tool

Our playground for this tutorial will be the Trump Insulter Plugin, which we will develop together over the course of this tutorial.

If you're not American and would prefer to insult your own Great Leader, feel free to replace that name with, say, Scott Morrison or Boris Johnson.

Getting started

The best way to get started on any new Gradle project is to use the init task. This is the only time you'll be required to use the command line. Please note the use of gradle and not ./gradlew! This is the only time you'll be using the Gradle distribution installed on your computer.

$ cd some-dir && gradle init

This will start an interactive flow where the task asks you what kind of Gradle project you want to create. Select "Gradle plugin" for type, "Kotlin" for implementation language, and "Kotlin" for build script DSL. Now pick a good project name and source package (defaults are based on your present working directory).

Now open your new project in your IDE and let's take a look around.

The basics

Let's start by looking at our build script. Open the build.gradle.kts that was generated by the init task.

Plugins

Two plugins are already applied, the java-gradle-plugin for developing Gradle plugins, and the "org.jetbrains.kotlin.jvm" plugin for editing and compiling Kotlin source. We're going to make some changes here.

plugins {
    `java-gradle-plugin`
    id("org.jetbrains.kotlin.jvm") version "1.3.61"
    `kotlin-dsl`
    id("com.gradle.plugin-publish") version "0.10.1"
}

The kotlin-dsl plugin will give us access to the Kotlin DSL API in our actual plugin code, and the Gradle Plugin-Publishing Plugin will be used later for publishing your plugin to the Gradle Plugin Portal. And of course we also bumped the Kotlin plugin to the latest stable version (at time of writing).

Repositories

Add a repo to your repositories {} block:

repositories {
    jcenter()
    google()
}

We'll need google() so we can add the Android Gradle Plugin source in our dependencies block in just a moment.

Group-Artifact-Version (GAV) Coordinates

In order to use your plugin in a real project, it will need GAV coordinates. There are two ways of specifying these coordinates; we'll get to the second one later. For now, add this to your build script, below repositories {}

version = "0.1.0"
group = "com.autonomousapps"

With this set, your plugin's default coordinates are now "com.autonomousapps:project-name:0.1.0", where project-name is set in settings.gradle.kts as rootProject.name; this is auto-generated by the init task. Someone would only need to know these coordinates if they wanted to add your plugin to their dependencies block, in order to extend it (similarly to what we'll be doing with the Android Gradle Plugin).

Target Java 8

In order to use Java 8 features (or 11, or 13...), you must tell Gradle what your target is. Add the following:

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Now Java and Kotlin source in your project will be compiled to target Java 8.

Dependencies

Because we want to target Android projects, we need access to AGP (Android Gradle Plugin) source. Edit your dependencies {} block as follows:

dependencies {
    ...

    compileOnly("com.android.tools.build:gradle:3.5.3") {
        because("Auto-wiring into Android projects")
    }
}

This will let us compile against AGP 3.5.3 without also constraining us to only that version at runtime. The exact runtime will be provided by the project to which you apply your plugin. This implies you must carefully test your plugin against different versions of AGP. For a quick list of recent AGP versions, I like to use the @AGPVersions Twitter account.

Plugin metadata

The final thing we'll look at in the build script, for now, is the gradlePlugin {} block. Yours will look something like this:

gradlePlugin {
    // Define the plugin
    val greeting by plugins.creating {
        id = "com.example.greeting"
        implementationClass = "com.example.ExamplePlugin"
    }
}

The id is how you apply your plugin. Given the above, you would apply your plugin like so:

plugins {
    id("com.example.greeting") version "0.1.0"
}

The implementationClass field is the fully-qualified class name of the Plugin class. Let's change this to the following:

gradlePlugin {
    // Define the plugin
    val insulter by plugins.creating {
        id = "com.autonomousapps.trump-insulter"
        implementationClass = "com.autonomousapps.TrumpInsulterPlugin"
    }
}

And that's it for build.gradle.kts! (for now)

Plugin implementation

Navigate to the auto-generated plugin class. It should be something like com.example.ExamplePlugin. The first thing we want to do is change its name to match what we set in the gradlePlugin block from earlier. So, change its name to TrumpInsulterPlugin and ensure the package matches as well.

Warning: IDE refactoring tools do not ensure that the FQCN of your Plugin class matches what you set in gradlePlugin. You must keep these in sync manually.

With that out of the way, look at the code itself. It should look like this:

/**
 * A simple 'hello world' plugin.
 */
class TrumpInsulterPlugin: Plugin<Project> {
    override fun apply(project: Project) {
        // Register a task
        project.tasks.register("greeting") { task ->
            task.doLast {
                println("Hello from plugin 'temp.greeting'")
            }
        }
    }
}

Here we already see the most basic and important thing about a plugin, which is that it must extend the Plugin<T> interface and implement the apply(T target) function.

T can be a variety of types, including Project (for project plugins), Settings (for settings plugins), and Gradle (init script plugins).

You might already have an error highlighted by your IDE in this code. This is because we have added the kotlin-dsl plugin, which adds some syntactic sugar to the standard Gradle API you'd usually have access to while writing plugins. To fix the error, remove task -> and task. in front of doLast. In other words:

project.tasks.register("greeting") {
    doLast {
        println("Hello from plugin 'temp.greeting'")
    }
}

But there's still a problem. This plugin adds a task to emit a friendly greeting on execution. Let's fix that.

project.tasks.register("insult") {
    doLast {
        println("Donald Trump is such a huge tool")
    }
}

Much better.

See it in action

Before we do anything else, it would be nice to see this in action and really know that it works. Let's make that happen.

The following takes advantage of Gradle's support for composite builds. This is a way to compose builds together that exist in separate roots or repositories.

First, select another of your projects and open it in your favorite editor. Open the settings.gradle[.kts] file and add the following at bottom:

includeBuild("../trump-insulter-plugin")

That string is the relative path from your other gradle project, to your plugin project. If you have this open in an IDE, you can run gradle sync and, when that's done, you should see your plugin code in the Project window at left.

Next, open app/build.gradle[.kts] (or similar) and add

plugins {
    id("com.autonomousapps.trump-insulter") version "0.1.0"
}

Sync and execute the insult task from the Gradle pane on right, or on the command line, execute ./gradlew app:insult and bathe in the warm glow of insulting the most powerful moron on planet Earth.

Needs more Kotlin

Now that we know our plugin works, let's iterate. The savvy reader will note there's not nearly enough Kotlin in that auto-generated plugin code. Let's fix that.

class TrumpInsulterPlugin: Plugin<Project> {
    override fun apply(project: Project): Unit = project.run {
        tasks.register("insult") {
            doLast {
                println("Donald Trump is such a huge tool")
            }
        }
    }
}

Mm, an inline extension function from the Kotlin stdlib. Much better. However, while it's true that our Kotlin quotient has increased, it must also be observed that the project instance is now the implicit receiver inside our apply block, making that code slightly more succinct.

Needs More Android

This plugin is already pretty great, and fulfills the goal of letting you insult Trump at will, with a Gradle task. But can we do better? I think so.

class TrumpInsulterPlugin: Plugin<Project> {
    override fun apply(project: Project): Unit = project.run {
        pluginManager.withPlugin("com.android.application") {
            the<AppExtension>().applicationVariants.all {
                tasks.register("insult${name.capitalize()}") {
                    doLast {
                        println("Donald Trump is such a huge tool")
                    }
                }
            }
        }
    }
}

Whoa, that's a lot of new stuff. Let's go over it bit by bit.

pluginManager.withPlugin("com.android.application") { ... }

You know how, sometimes, you'll encounter a Gradle plugin, and the README says "apply this plugin last"? pluginManager.withPlugin() solves that problem. It tells Gradle to apply the code inside the block immediately after the specified plugin has been applied. Checkout the javadoc for more information.

the<AppExtension>()

The what now? the is an inline reified extension function on Project (so much Kotlin!) that makes it trivially easy to configure various bits of DSL contributed by other Gradle plugins. If you combine that with the knowledge that AppExtension is the type that you're configuring when you are editing an android {} block in a build script, then the way to read this line is "the app extension". It's meant to be read like prose, hence "the" weird name.

This block is inside pluginManager.withPlugin(...) because otherwise we would have no assurance that the Android plugin has been applied, and with it an object of type AppExtension made available.

the<AppExtension>().applicationVariants

You can think of applicationVariants as a collection of ApplicationVariant instances -- one for each variant in your app project. Standard variants are "debug" and "release", but if you use product flavors, you will have variants like "flavor1Debug", "flavor2Debug", etc.

the<AppExtension>().applicationVariants.all { ... }

all {} is a Gradle method for iterating over a particular kind of container. The exact type is outside the scope of this tutorial (DomainObjectCollection). For our purposes, it is sufficient to think of it as a live collection. all {} will execute the supplied action against all current and future members of the given collection. This is the method that saves us from having to use Project.afterEvaluate and waiting for the android {} block to finish being configured.

the<AppExtension>().applicationVariants.all {
    tasks.register("insult${name.capitalize()}") {
        doLast {
            println("Donald Trump is such a huge tool")
        }
    }
}

And finally, the task. See how we've changed the name to be dynamic. Now we'll have tasks like:

  1. insultDebug
  2. insultRelease

Not only is this Cool ™️, but it's also necessary. Without this, Gradle would complain about us trying to add multiple tasks with the same name, which is not allowed.

Give it a try. Sync your project and execute your new tasks.

I still don't think there's enough Android

I think you're right. The plugin is better than ever, capable of insulting Trump per-variant, but is it enough? I mean, we still have to manually trigger the task, which seems kind of lame. Can we make it more... automagic?

Yes. Yes, we can.

IDE magic

Maybe there's something in applicationVariants that could help? Let's find out. Use your IDE to navigate to source on that method call. You should see

public DomainObjectSet<ApplicationVariant> getApplicationVariants() {
    return applicationVariantList;
}

Ignore most of that for purposes of this tutorial. We must go deeper. Navigate to source on ApplicationVariant.

We need to go deeper

/**
 * A Build variant and all its public data.
 */
public interface ApplicationVariant extends ApkVariant, TestedVariant {}

Well, that was underwhelming. Is that really it? Let's navigate to source one more time. Take a deeper look at ApkVariant.

We need to go deeper

@Nullable
TaskProvider<PackageAndroidArtifact> getPackageApplicationProvider();

Jackpot! That's what we want. We can use the TaskProvider returned by that method to hook our insult tasks up to the APK-packaging task, and insult Trump on each and every build! Let's see what that looks like:

class TrumpInsulterPlugin: Plugin<Project> {
    override fun apply(project: Project): Unit = project.run {
        pluginManager.withPlugin("com.android.application") {
            the<AppExtension>().applicationVariants.all {
                val insultTask = tasks.register("insult${name.capitalize()}") {
                    doLast {
                        println("Donald Trump is such a huge tool")
                    }
                }

                packageApplicationProvider.configure { 
                    finalizedBy(insultTask)
                }
            }
        }
    }
}

First we get a reference to the TaskProvider returned by tasks.register(...) and then we tell the packageApplicationProvider TaskProvider that our task finalizes it. This just means it will execute our task after it finishes its own work.

Let's see it in action.

$ ./gradlew app:assembleDebug

(or use your IDE)

> Task :app:insultDebug
Donald Trump is such a huge tool

Deprecated Gradle features were used in this build, making it incompatible with Gradle 7.0.
Use '--warning-mode all' to show the individual deprecation warnings.
See https://docs.gradle.org/6.0.1/userguide/command_line_interface.html#sec:command_line_warnings

BUILD SUCCESSFUL in 40s
77 actionable tasks: 63 executed, 10 from cache, 4 up-to-date

❤️

Oh but wait

We've come this far. What if we want to publish this plugin to a public repository, so anyone can easily insult Donald Trump during each of their Android builds? Easy as pie. You'll remember we already added the "com.gradle.plugin-publish" plugin to our project. This lets us publish directly to the Gradle Plugin Portal (available via gradlePluginPortal() in a repositories {} block, and added by default to all Gradle builds).

Full instruction for using the Gradle Plugin-Publishing Plugin are outside the scope of this tutorial, and anyway the Gradle docs cover it well. For our purposes, the main thing you need to do is add this to your build script:

pluginBundle {
    website = "https://path/to/your/website"
    vcsUrl = "https://path/to/your/git/repo"

    description = "A plugin to insult Donald Trump, apparently the president of the United States"

    (plugins) {
        "trumpInsultingPlugin" {
            displayName = "Trump Insulter"
            tags = listOf("insults", "tutorial")
        }
    }
}

Please read the docs carefully. You will need to generate a key and a secret for publishing to the Plugin Portal.

With your Plugin Portal account set up and your build script configured to publish your plugin, all that's left is to do it! From your IDE, open the Gradle tasks pane and expand the "plugin portal" group. You'll see login and publishPlugins. login is for when you don't have your key and secret available as project properties (read the docs!), and publishPlugins does what it says. Let's do it:

$ ./gradlew publishPlugins

And the result (for me):

> Task :publishPlugins
Publishing plugin com.autonomousapps.trump-insulter version 0.1.0
Publishing artifact build/libs/gradle-plugin-trump-insulter-0.1.0.jar
Publishing artifact build/libs/gradle-plugin-trump-insulter-0.1.0-sources.jar
Publishing artifact build/libs/gradle-plugin-trump-insulter-0.1.0-javadoc.jar
Publishing artifact build/publish-generated-resources/pom.xml
Activating plugin com.autonomousapps.trump-insulter version 0.1.0

❤️ ❤️

The code

You can find all the code on Github at https://github.com/autonomousapps/trump-insulter-plugin, and instructions for adding to your Gradle projects are available at the Gradle Plugin Portal at https://plugins.gradle.org/plugin/com.autonomousapps.trump-insulter.

Extra credit

There's so much more we could do! We could extract our insult task into its own class and make it configurable (what if we want different insults for different days of the week?). We could add an insult {} extension to enable easy configuration for users. Maybe we want a boolean like isClean (defaults to true for the kids, but can be set to false for adult eyes). Maybe we want to post a message to Slack, or send a Tweet, or anything? Sky's the limit, my friends.

Posted on by:

Discussion

markdown guide