Defining Custom Pre and Post-Processing Tasks in Gradle

Crashlytics Plugin for Gradle

(Note: Xavier Ducrohet, tech lead for the Android SDK at Google, helped with portions of this post. Thanks, Xavier!)

Since our launch of Crashlytics for Android, it’s been our mission to leverage our infrastructure, along with the tools you use everyday, to make developing apps as easy as possible. We’re always on the lookout for the best ways to integrate with your existing workflow.

When Google announced at I/O 2013 that they would be backing Gradle as a build system for Android development, we embarked on a ground-up approach to integrate Gradle into our supported build systems.

Introducing Gradle

Gradle was introduced as a new build system to provide a more efficient and powerful way to build mobile apps. Backed by the Groovy language, Gradle is flexible, enabling the specification of not just what a build is doing but how. The Android Gradle plugin comes with built-in support for creating multiple flavors and build types for your project. However, this simple support adds more complexity to the actual build process under-the-hood.

Building for Simplicity

It’s not uncommon for a build script to specify pre-processing work (e.g., generating data) and post-processing work (e.g., pushing files to a staging machine). With Ant and Maven, our build tools automatically manage ProGuard mapping files so that we can tell you the exact line of code that causes your app to crash.

We set out to make Gradle just as easy to use. Instead of having to explicitly add Crashlytics tasks for every combination of build type and flavor, wouldn’t it be much easier if you could just write “apply ‘preprocessor’” and be done with it?

We’ve got you covered. We made sure that using the Crashlytics plugin for Gradle was as easy as possible for our users!

Getting Started

We built an example plugin to demonstrate automatic task insertion.  To begin, we first defined a plugin in Groovy code:

1 package com.crashlytics.examples.gradle
2
3 import org.gradle.api.Project
4 import org.gradle.api.Plugin
5
6 class PreprocessorPlugin implements Plugin {
7 	void apply(Project project) {

Since this plugin depends on the Gradle Android plugin, we added:

1 	project.configure(project) {
2 		if(it.hasProperty("android")) {

The Android Gradle plugin has several individual build tasks for a flavor and build type (e.g., the compileDebug task) that do not exist at the time a project is being configured. These tasks are added dynamically, so we listened to them as they were added to make our task a dependency.

1 		tasks.whenTaskAdded { theTask ->

Update: Xavier Ducrohet recommends using the following, which iterates over the list of variants (i.e. build type and flavor combinations) and simplifies the process of getting the path to an AndroidMainifest.xml:

1 android.applicationVariants.all { variant -> ...

We then added a task to this project. As an example of how to add a task, here’s how to print the size of the AndroidManifest.xml (for the release build of a project with no flavors):

 1 			if("compileRelease".toString().equals(theTask.name.toString())) {
 2 				def yourTaskName = "helloManifestRelease"
 3 				project.task(yourTaskName) << {
 4 				description = 'Outputs the manifest file size'
 5 				def manifest = new File("build/manifests/release/AndroidManifest.xml")
 6 				logger.warn("Hello World! Manifest Size: " + manifest.length())
 7 			}
 8 			theTask.dependsOn(yourTaskName)
 9 			def processTask = "processReleaseResources"
10 			project.(yourTaskName.toString()).dependsOn(processTask)
11 		}}}}}}

Supporting Multiple Flavors  

The creation of a single pre-processing task for a specific flavor and build type is simple. However, we wanted to add a task for each flavor and build type (note: Xavier’s update above also solves this). Instead of using only one build type and flavor, we iterated over all of them, as seen in the snippet below:

1 		// Returns an empty list if the plugin only has the default flavor.
2 		// But we still need something to iterate over, so let’s make an empty flavor.
3 		def projectFlavorNames = project.("android").productFlavors.collect { it.name }
4 		projectFlavorNames = projectFlavorNames.size() != 0 ? projectFlavorNames : [""]
5 		project.("android").buildTypes.all { build ->
6 		def buildName = build.name
7 		// . . .

What if you need to make a pre-processing task to read your Android manifest before compilation? Here’s how we did it:

First, we referenced the correct path to the Android manifest file (e.g., “build/manifests/FlavorName/BuildName/AndroidManifest.xml”). Gradle creates one manifest for each combination of flavors and builds — that means that each combination has a different AndroidManifest in a different location:

1 		def flavorPath
2 		for (flavorName in projectFlavorNames) {
3 			if (!"".equals(flavorName)) {
4 				flavorPath = "${flavorName}/${buildName}"
5 			} else {
6 			// If we are working with the empty flavor, there’s no second folder.
7 				flavorPath = "${buildName}"
8 			}
9 			def manifestPath = "build/manifests/${flavorPath}/AndroidManifest.xml"

Next, we identified the names of all of the compilation tasks; there is one compilation task for each flavor and build type combination.

The Android Gradle plugin uses camelCase notation for their tasks (e.g., compileFlavorNameBuildName):

1 			def taskAffix
2 			if (!"".equals(flavorName)) {
3 				taskAffix = "${flavorName.capitalize()}${buildName.capitalize()}"
4 			} else {
5 				// If we are working with the empty flavor, there’s no second affix.
6 				taskAffix = "${buildName.capitalize()}"
7 			}
8 			def compileTask = "compile${taskAffix}".toString()

Finally, we made all of these compilation tasks dependent on a new pre-processing task. Once we determined the name of a specific flavor and build type, we hooked onto it (like we did before).

 1 			if(compileTask.equals(theTask.name.toString())) {
 2 				def yourTaskName = "helloManifest${taskAffix}"
 3 				project.task(yourTaskName) << {
 4 				description = 'Outputs the manifest file size'
 5 				def manifest = new File(manifestPath)
 6 				logger.warn("Hello World! Manifest Size: " + manifest.length())
 7 			}
 8 			theTask.dependsOn(yourTaskName)
 9 			def processTask = "process${taskAffix}Resources"
10 			project.(yourTaskName.toString()).dependsOn(processTask)
11 		}}}}}}}}

Implementing the Gradle Plugin

That’s not all — if you’re looking to create your own Gradle processor plugin, head on over to GitHub and use our example to get started! This example also includes a few tests to verify that you’ve correctly built your plugin.

The team is really proud of the functionality provided through our Gradle plugin and how easy it is for developers to use. We’re excited to see how Gradle evolves and are continuing to make improvements to ensure that Crashlytics fits seamlessly into your workflow.

We’re already hard at work on even more functionality. Stay tuned for what’s next!