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
- How to write a Gradle plugin following modern best practices.
- How to do it in Kotlin, and with the Kotlin DSL.
- How to hook into Android-specific project features, such as being variant-aware by default.
- How to verify your plugin works.
- How to make your builds insult Donald Trump, the (somehow) president of the United States of America.
Pre-requisites
- You have Android Studio or IDEA installed and know how to use it.
- You have Gradle installed (I don't mean the wrapper). I use SDKMAN to manage my Gradle installation.
- 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, includingProject
(for project plugins),Settings
(for settings plugins), andGradle
(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 typeAppExtension
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:
insultDebug
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
.
/**
* 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
.
@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.
Top comments (3)
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.
build-src/my-plugin/build.gradle.kts
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?
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.
Thanks Tony!