DEV Community

Tony Robalik
Tony Robalik

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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'")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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'")
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

pluginManager.withPlugin("com.android.application") { ... }
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

❤️

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")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

❤️ ❤️

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.

Top comments (3)

Collapse
 
tomkoptel profile image
tomkoptel

Hello Tony!

Nice and funny article :D
I have a question maybe you can help. I am trying to setup the plugin as a composite build. I have also used compileOnly to specify Android plugin sources. The issue appears when I do execute the test with Gradle Test Kit.

I have the following setup.

app/
build-src/my-plugin/build.gradle.kts
build-src/settings.gradle.kts
build-src/build.gradle.kts
settings.gradle.kts
gradlew
Enter fullscreen mode Exit fullscreen mode

build-src/my-plugin/build.gradle.kts

plugins {
    kotlin("jvm") version "1.3.72"
    id("java-gradle-plugin")
    `kotlin-dsl`
}

val pluginVersion = "1.0"
version = pluginVersion

dependencies {
    implementation(kotlin("stdlib-jdk8"))
    implementation(gradleApi())
    compileOnly("com.android.tools.build:gradle:4.0.2")
    testImplementation("junit:junit:4.13.1")
}

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

gradlePlugin {
    plugins {
        val pluginId = "com.my.plugin"
        create(pluginId) {
            id = pluginId
            implementationClass = "$pluginId.AndroidKeystorePlugin"
            version = pluginVersion
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
abstract class AndroidKeystorePlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.plugins.withType(AppPlugin::class.java) {
            val extension = project.extensions.getByType(BaseAppModuleExtension::class.java)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When I do execute cd build-src/my-plugin/ && ../../gradlew test I do receive an error.

Could not generate a decorated class for type AndroidKeystorePlugin. com/android/build/gradle/BaseExtension

Any ideas why this happens?

Collapse
 
autonomousapps profile image
Tony Robalik

Hey, thanks for asking. I would suggest joining the gradle community slack, which is open to all, and you could ask in either the #plugin-development or #android channels.

Collapse
 
tomkoptel profile image
tomkoptel

Thanks Tony!