This is the second part of an undecided number of posts about creating a Gradle plugin for Android. Part one can be found here.

First up I'd like to thank everyone for their feedback on my previous post. It seems that I'm not the only one who found the documentation on creating a standalone Gradle plugin a little lacking.

Last time out we left our plugin whole, but rather lacking in substance. If you recall it just added an extra task that ran adb devices. Let's try for something a little more adventurous.

(A quick aside. To make the code listings below a little shorter and more concise I haven't included the package definition or the imports. And whilst we're on the side I'm going to assume that you've done part one and you have an Android Studio project open that uses the plugin we're creating.)

Step 0 - Striking out on our own

We're going to start by moving our simple task into it's own class. It's all very well coding it straight into the Plugin class, but it's going to get a bit unwieldy after a while.

Create a new Groovy class - ShowDevicesTask - and make it extend DefaultTask. You'll see that there aren't any red squigglies indicating that there's a missing method.

To specify the method to be called when we execute our task we annotate it with @TaskAction. Move the implementation of the showDevices task out of the BlogPlugin and into the ShowDevicesTask class and take the group and description to make them fields of the class, so it looks something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class ShowDevicesTask extends DefaultTask {
    String group = "blogplugin"
    String description = "Runs adb devices command"

    @TaskAction
    def showDevices() {
        def adbExe = project.android.getAdbExe().toString()
        println "${adbExe} devices".execute().text
    }
}

Note that there is one subtle difference here. When the implementation was part of the BlogPlugin the Gradle Project was passed in to the apply() method as the target parameter. In our Task there are no parameters. Luckily we can get the Gradle Project via the project field of our task. A simple swap in our adbExe def and we're done.

Back in the BlogPlugin class change the apply() method to create the task from the new class:

1
2
3
void apply(Project target) {
    target.tasks.create(name: "showDevices", type: ShowDevicesTask)
}

Running the uploadArchives task in IntelliJ and Sync Project with Gradle Files in the Android Studio project and you'll see...well, not a lot has changed. In fact nothing has changed. Make sure the showDevices task still exists and runs. Yay!

But at least we have our Task in it's own class. That's something...right?

Ok, I know. I promised that the task would do something a bit more interesting. How about if we take a file of words - one per line - and make our plugin create a class that contains an enum with all the words in it? That should be a little more interesting. Not much I grant you, but a little.

Step 1 - A little detour

As you probably know there are two "build types" by default with an Android project; debug and release. A large number of the Gradle build tasks for Android will have two versions, one for each. So there's an assembleDebug and assembleRelease for example. Of course you can create more build types of your own and then you'd have a task for each type.

You're probably also aware that you can have different flavours for your app too. By default there aren't any, but if you add some you will get even more build tasks; there will be one per build type per flavour. So if you kept with the two standard build types and created an "internal" and "external" flavour for example you would end up with the following assemble tasks

  • assembleInternalDebug
  • assembleExternalDebug
  • assembleInternalRelease
  • assembleExternalRelease

Each one of these is known as a variant. You can get a list of the variants from the Android plugin which is available from the Gradle project passed into the apply() method as a field called android. To make things a little simpler we are only going to concern ourselves with a plugin for an Android applications, not a library.

In the apply() method of our plugin we can reference target.android.applicationVariants.all to get a list of all the variants.

Why am I telling you this now? All will become clear...

Step 2 - The ins and outs

I'm not going to try and create a class file directly for our enum. What we will do with our plugin is to read the list of words from a file and write out a Java source file that will get compiled into our app as sure as if we had coded it ourselves. To do this we need to know where to get the words from and where to write the Java file out.

Start by creating a new Groovy class called WordsToEnumTask, make it extend DefaultTask as our ShowDevicesTask did. Add a couple of String fields for description and group and add an empty method called makeWordsIntoEnums that is annotated with @TaskAction. It should look a little like

1
2
3
4
5
6
7
8
class WordsToEnumTask extends DefaultTask {
    String group = "blogplugin"
    String description = "Makes a list of words into an enum"

    @TaskAction
    def makeWordsIntoEnums() {
    }
}

We're going to put our words in a file called plugin_words.txt that will live at the top level of our app's module. Unless you've changed it, the module is called 'app' and so is the directory that the file will live in. If you created a test app in Android Studio to play along look for the directory that has the proguard-rules.pro in it. In the Android Studio "Project" view it will look like this:

Words File

Create a file and put a handful of words in it, one word per line. As we are coding for information and learning we won't be doing any real error handling, so make the file simple; one word per line, no blank lines, no duplicates and make each word a valid Java symbol that you would see in an enum.

How do we locate this file from within our task? As it turns out, pretty easily. There's a lovely field we can access from our project that is passed in to our apply() method in our plugin called projectDir. This is the top level of our module, which is exactly where we have just placed our file of words.

Writing the output file is almost as simple; buildDir gives us the root of the build directory, which is were things are placed during the build process. Unless you've done something really weird, this is likely to be a directory called build. Confusingly the Android project has one build directory at the top level and one in each module. We're looking at the module one.

Within build there's a generated directory, and within that a source directory. I can't find the post at the moment but I'm pretty sure the advice I read a while ago was to create your own directory for your plugin to place its generated source in. So we will.

Remember back to the little detour above? Here's where it kicks in. Each variant has a dirName field and we use this to place our Java file. The result of all of this is that we will need to create a task per variant that writes our Java source file into a variant-appropriate directory.

Add this to the apply() method in BlogPlugin:

1
2
3
4
5
target.android.applicationVariants.all { variant ->
    File inputWordFile = new File(target.projectDir, "plugin_words.txt")
    File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
    def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask)
}

We are looping over all the variants, finding the input file and the output directory, and creating a new task for each variant. As we are creating multiple tasks in the loop we need to make name of each task unique. So we take the variant name, give it a capital letter at the start to make it feel important, and stick that on the end of "wordsToEnum".

If we uploadArchives in IntelliJ and sync our project in Android Studio we will have a wordsToEnumDebug task and a wordsToEnumRelease task in our blogplugin section of Gradle tasks.

If you want to try a little experiment, open the build.gradle on your Android app and in the android section add this:

productFlavors {
    dev {}
    qa {}
}

When you sync your project you'll find four wordsToEnum tasks; wordsToEnumDevDebug, wordsToEnumDevRelease, wordsToEnumQaDebug and wordsToEnumQaRelease. Each flavour has a buildtype version.

Remove the productFlavours section to avoid confusion for now.

Step 3 - Passing the info to the task

To get the input file and output directory information into the task we can take advantage of the power of Groovy. Create two fields in the WordsToEnumTask of type File and call them wordsFile and outDir and add a bit of code to print the path of these files, like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class WordsToEnumTask extends DefaultTask {
    String group = "blogplugin"
    String description = "Makes a list of words into an enum"

    File wordsFile
    File outDir

    @TaskAction
    def makeWordsIntoEnums() {
        println wordsFile.absolutePath
        println outDir.absolutePath
    }
}

Then add a closure (a thing in curly brackets) to the end of the task create command that assigns values to them, like this:

1
2
3
4
5
6
7
8
target.android.applicationVariants.all { variant ->
    File inputWordFile = new File(target.projectDir, "plugin_words.txt")
    File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
    def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask) {
        outDir = outputDir
        wordsFile = inputWordFile
    }
}

Another uploadArchives and project sync and when you run one of the wordsToEnum tasks you will see output a path to the words list and a path to where our Java file will be written to. You can use this to check that you've put the plugin_words.txt file in the right place. :)

Step 4 - A little Gradle magic

Yes? What is it at the back? Can you speak up? Good question.

For those of you who didn't hear that, the question was "Won't this slow the build down if every time we compile our app this task will run to turn the word list into an Java class file?"

Gradle is clever enough to not do unnecessary work. If the output of a task or the input of a task haven't changed then it won't waste time by running the task again. We can designate the inputs and outputs of our task with more annotations.

Annotate the wordsFile field in the WordsToEnumTask with @InputFile and the outDir with @OutputDirectory. Yup. That's it. Simple huh?

If you really don't believe me add a simple println statement into the makeWordsIntoEnums() method. You'll see, when all the code is complete, that you'll only see your output when you clean your project or when you update the words list.

Step 5 - Let's do this thing

Time to write some code. In the WordsToEnumTask write some really clever code to read the words from the wordsFile file and write a Java source file to the ourDir.

I'm going to cheat and make use of the excellent JavaPoet by Square to create the Java source file. To do this add compile 'com.squareup:javapoet:1.5.1' to the build.gradle of your plugin. Hands up all those to remembered to do a refresh in your Gradle tool window? That was the big tip from the last post! (If you prefer to use IntelliJ commands via CTRL/CMD + SHIFT + A, "Refresh all external projects" does the trick.)

Here's my complete task:

class WordsToEnumTask extends DefaultTask {
    String group = "blogplugin"
    String description = "Makes a list of words into an enum"

    @InputFile
    File wordsFile

    @OutputDirectory
    File outDir

    @TaskAction
    def makeWordsIntoEnums() {
        Builder wordsEnumBuilder = enumBuilder("WordsEnum").addModifiers(Modifier.PUBLIC)
        wordsFile.readLines().each {
            wordsEnumBuilder.addEnumConstant(it).build()
        }
        TypeSpec wordsEnum = wordsEnumBuilder.build();
        JavaFile javaFile = JavaFile.builder("com.afterecho.android.util", wordsEnum).build();
        javaFile.writeTo(outDir)
    }
}

Once again with the uploadArchive and project sync, and when you run one of the wordsToEnum tasks... well, once again we see very little. If you change your project view from Android to Project or browse your project directory with your favourite file manager or shell, and look deep under app/build/generated/source/wordsToEnum/ you'll eventually find a WordsEnum.java that will have an enum definition with all of our words in.

The Output

We've turned something like this:

THIS
IS
A
JOURNEY
THROUGH
SPACE

into this:

public enum WordsEnum {
    THIS,

    IS,

    A,

    JOURNEY,

    THROUGH,

    SPACE
}

So we're done? Not quite.

Step 6 - The missing link

We have this nice enum but Android Studio doesn't know about it. Switch back to the Android view of the project if you switched to Project view and try using WordsEnum in one of your test app's source files. Android Studio will not import the enum. Nor will it open the class if you use the Navigate Class keyboard shortcut. What gives?

The missing thing is letting Android Studio know there's a new task that generates Java files and where it places them. Back in the BlogPlugin we need to add a single line within our loop: variant.registerJavaGeneratingTask task, outputDir

Our complete BlogPlugin class now looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class BlogPlugin implements Plugin<Project> {

    @Override
    void apply(Project target) {
        target.tasks.create(name: "showDevices", type: ShowDevicesTask)

        target.android.applicationVariants.all { variant ->
            File inputWordFile = new File(target.projectDir, "plugin_words.txt")
            File outputDir = new File(target.buildDir, "generated/source/wordsToEnum/${variant.dirName}")
            def task = target.tasks.create(name: "wordsToEnum${variant.name.capitalize()}", type: WordsToEnumTask) {
                outDir = outputDir
                wordsFile = inputWordFile
            }
            variant.registerJavaGeneratingTask task, outputDir
        }
    }
}

You know the drill by now. uploadArchives and sync your Android Studio project and run one of the wordsToEnum tas...but hold on a second! Registering our task as a Java Generating Task has has an interesting side effect: we no longer need to run the wordsToEnum task by hand. It is now run as part of the standard assemble task. So whenever you run your app from within Android Studio, or do a rebuild, or even build it from the command line our WordsEnum will be there.

So there you have it. A Gradle plugin that creates a Java source file that you can use in your Android app. In the next installment we'll take some of that horrible hard-coded stuff, like the filename of the words list and the package name of the enum, and move it into the build.gradle of our Android project so it can be changed without having to recompile the plugin each time.

I've uploaded the IntelliJ project for these posts to Github. Each part will be in its own branch. This one is at https://github.com/afterecho/gradle-plugin-tutorial/tree/part-two

Darren @ Æ


Comments

comments powered by Disqus