If you are a ‘C’ or ‘C++’ programmer you are likely familiar with the #IFDEF syntax used for pre-processing source code. Unfortunately, Java has no pre-processor for managing conditional code like this. When building Android apps, a developer will typically handle dependencies on ‘flavor’ and ‘buildType’ by placing modified versions of source under specially named directories. For example, handling code differences between a ‘paid’ vs. ‘free’ app may lead to 4 versions: paidRelease, freeRelease, paidDebug and freeDebug. Code duplication can become even more severe when more than 2 flavors or more than 2 build types are used. That means app maintenance complexity increases exponentially; the developer must remember to change code similarly in all versions of a duplicated class.
Gradle build rules can be employed to allow a Java source file to contain conditional source for processing all flavor and build type combinations. This post shows you how to do that. I assume you are already familiar with using Gradle and Android Studio for app development.
First create a new file: ‘preprocessor.gradle‘ under your project’s root directory (The directory containing the ‘app’ folder). That file should contain the following code (click to expand):
// -------------------------------------------------------------------------------------------------
// Android seems to want to duplicate code using 'buildTypes' and 'flavors' but when minor differences exist
// it is inefficient to maintain mostly duplicate copies of code this way. The code below allows java source code
// to be edited in place. Source sections are commented or uncommented based on specially crafted comments:
//
//#IFDEF 'configuration'
// java code for the specified 'configuration'
//#ELSE
// java code for NOT the specified 'configuration'
//#ENDIF
//
// The 'configuration' specified above can be a BUILD_TYPE or FLAVOR or BUILD_TYPE+FLAVOR or FLAVOR+BUILD_TYPE
// For example: 'debug' or 'release' or 'paid' or 'free'
// or 'debugpaid' or 'debugfree 'or 'releasepaid' or 'releasefree'
// or 'paiddebug' or 'freedebug' or 'paidrelease' or 'freerelease'..
// these are all valid 'configuration' entries and will be processed by #IFDEF depending on buildType and flavor.
// Note that nested #IFDEF statements are not supported (and there is no actual need to nest).
// Also the 'configuration' is case independent
//
// To use this preprocessor, add the following line to your app/build.gradle:
// apply from: '../preprocessor.gradle'
//
// Then in your java source with build dependencies, do something like this:
//
//#IFDEF 'paidRelease'
//Log.v(TAG, "example of #IFDEF 'paidRelease'");
//#ELSE
//Log.v(TAG, "example of NOT #IFDEF 'paidRelease'");
//#ENDIF
//
// Now during a gradle build, the appropriate lines of java code will be commented and uncommented as required.
//
// Author: Lee Hounshell - lee.hounshell@gmail.com - Jan 11, 2016
// See: http://harlie.com/?p=38
String sourceDirectory = 'src'
FileTree javaFiles = fileTree(sourceDirectory) {
include '**/*.java'
}
// auto comment and uncomment source lines between #IFDEF 'configuration' and #ELSE or #ENDIF
// each matching java source file is edited in-place
class PreProcessor {
public enum IfdefState {
NONE,
IFDEF,
ELSE
}
public static void preProcessSourceCode (FileTree javaFiles, String buildType, String flavor) {
buildType = buildType.toLowerCase()
flavor = flavor.toLowerCase()
println("---> preProcessSourceCode BUILD_TYPE="+buildType+" FLAVOR="+flavor)
String buildTypeAndFlavor = buildType + flavor
String flavorAndBuildType = flavor + buildType
String ifdefRegex = '^([ ]*)(\\/\\/)#IFDEF \'(.*)\'$'
String elseRegex = '^([ ]*)(\\/\\/)#ELSE$'
String endifRegex = '^([ ]*)(\\/\\/)#ENDIF$'
String lineRegex = '^([ ]*)([^ ][^ ])(.*)$'
String singleCharLineRegex = '^([ ]*)([^ ])$'
String comment = "//"
String newline = System.getProperty("line.separator")
javaFiles.each { File javaFile ->
println "checking for '$ifdefRegex' in $javaFile.name"
String content = javaFile.getText()
StringBuilder newContent = new StringBuilder()
IfdefState match = IfdefState.NONE
boolean changed = false;
String buildTypeAndOrFlavor = "<undefined>"
content.eachLine { line, index ->
// process #IFDEF
if (line.matches(ifdefRegex)) {
buildTypeAndOrFlavor = (line.split('\'')[1]).toLowerCase()
println("--> #IFDEF on line $index for $buildTypeAndOrFlavor")
if (buildTypeAndOrFlavor.equals(buildType)) {
match = IfdefState.IFDEF
println("--> $buildTypeAndOrFlavor IS A MATCH FOR BUILD_TYPE $buildType")
}
else if (buildTypeAndOrFlavor.equals(flavor)) {
match = IfdefState.IFDEF
println("--> $buildTypeAndOrFlavor IS A MATCH FOR FLAVOR $flavor")
}
else if (buildTypeAndOrFlavor.equals(buildTypeAndFlavor)) {
match = IfdefState.IFDEF
println("--> $buildTypeAndOrFlavor IS A MATCH FOR COMBO BUILD_TYPE PLUS FLAVOR $buildTypeAndFlavor")
}
else if (buildTypeAndOrFlavor.equals(flavorAndBuildType)) {
match = IfdefState.IFDEF
println("--> $buildTypeAndOrFlavor IS A MATCH FOR COMBO FLAVOR PLUS BUILD_TYPE $flavorAndBuildType")
}
else {
match = IfdefState.ELSE
println("--> $buildTypeAndOrFlavor IS NOT A MATCH FOR BUILD_TYPE $buildType OR FLAVOR $flavor OR COMBO $buildTypeAndFlavor OR COMBO $flavorAndBuildType")
}
}
// process #ELSE
else if (line.matches(elseRegex)) {
println("--> #ELSE on line $index for $buildTypeAndOrFlavor")
if (match != IfdefState.ELSE) {
match = IfdefState.ELSE
println("--> $buildTypeAndOrFlavor IS NOT A MATCH FOR #ELSE")
}
else {
match = IfdefState.IFDEF
println("--> $buildTypeAndOrFlavor IS A MATCH FOR #ELSE")
}
}
// process #ENDIF
else if (line.matches(endifRegex)) {
println("--> #ENDIF on line $index for $buildTypeAndOrFlavor")
match = IfdefState.NONE
}
// comment or uncomment code or leave it unchanged
else {
if (match == IfdefState.IFDEF) { // ifdef: uncomment lines up to #ELSE or #ENDIF, as needed
if (line.matches(lineRegex)) {
def matcher = line =~ lineRegex
if (matcher[0][2].equals(comment)) {
line = matcher[0][1] + matcher[0][3]
changed = true
println(line)
}
}
} else if (match == IfdefState.ELSE) { // else: comment-out lines to #ELSE or #ENDIF, as needed
if (line.matches(lineRegex)) {
def matcher = line =~ lineRegex
if (!matcher[0][2].equals(comment)) {
line = matcher[0][1] + comment + matcher[0][2] + matcher[0][3]
changed = true
println(line)
}
}
else if (line.matches(singleCharLineRegex)) {
def matcher = line =~ singleCharLineRegex
if (!matcher[0][2].equals(comment)) {
line = matcher[0][1] + comment + matcher[0][2]
changed = true
println(line)
}
}
}
}
newContent.append(line + newline)
}
// save the file if was edited
if (changed) {
println("==> EDITING THE FILE <==")
javaFile.setText(newContent.toString())
}
}
}
}
task preProcessSourceCodeDebugFree << {
logger.quiet("---> PreProcessor.preProcessSourceCode(javaFiles, 'debug', 'free')")
description("preprocess free code after //#IFDEF 'debug' to //#ENDIF")
PreProcessor.preProcessSourceCode(javaFiles, 'debug', 'free')
}
task preProcessSourceCodeDebugPaid << {
logger.quiet("---> PreProcessor.preProcessSourceCode(javaFiles, 'debug', 'paid')")
description("preprocess paid code after //#IFDEF 'debug' to //#ENDIF")
PreProcessor.preProcessSourceCode(javaFiles, 'debug', 'paid')
}
task preProcessSourceCodeReleaseFree << {
logger.quiet("---> PreProcessor.preProcessSourceCode(javaFiles, 'release', 'free')")
description("preprocess free code after //#IFDEF 'release' to //#ENDIF")
PreProcessor.preProcessSourceCode(javaFiles, 'release', 'free')
}
task preProcessSourceCodeReleasePaid << {
logger.quiet("---> PreProcessor.preProcessSourceCode(javaFiles, 'release', 'paid')")
description("preprocess paid code after //#IFDEF 'release' to //#ENDIF")
PreProcessor.preProcessSourceCode(javaFiles, 'release', 'paid')
}
tasks.whenTaskAdded { task ->
if (task.name == 'compileFreeDebugJavaWithJavac') {
logger.quiet('---> compileFreeDebugJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugFree
preProcessSourceCodeDebugFree.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compileFreeReleaseJavaWithJavac') {
logger.quiet('---> compileFreeReleaseJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleaseFree
preProcessSourceCodeReleaseFree.outputs.upToDateWhen { false } // always run
}
if (task.name == 'compilePaidDebugJavaWithJavac') {
logger.quiet('---> compilePaidDebugJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugPaid
preProcessSourceCodeDebugPaid.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compilePaidReleaseJavaWithJavac') {
logger.quiet('---> compilePaidReleaseJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleasePaid
preProcessSourceCodeReleasePaid.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compileFreeDebugUnitTestJavaWithJavac') {
logger.quiet('---> compileFreeDebugUnitTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugFree
preProcessSourceCodeDebugFree.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compileFreeReleaseUnitTestJavaWithJavac') {
logger.quiet('---> compileFreeReleaseUnitTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleaseFree
preProcessSourceCodeReleaseFree.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compilePaidDebugUnitTestJavaWithJavac') {
logger.quiet('---> compilePaidDebugUnitTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugPaid
preProcessSourceCodeDebugPaid.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compilePaidReleaseUnitTestJavaWithJavac') {
logger.quiet('---> compilePaidReleaseUnitTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleasePaid
preProcessSourceCodeReleasePaid.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compileFreeDebugAndroidTestJavaWithJavac') {
logger.quiet('---> compileFreeDebugAndroidTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugFree
preProcessSourceCodeDebugFree.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compileFreeReleaseAndroidTestJavaWithJavac') {
logger.quiet('---> compileFreeReleaseAndroidTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleaseFree
preProcessSourceCodeReleaseFree.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compilePaidDebugAndroidTestJavaWithJavac') {
logger.quiet('---> compilePaidDebugAndroidTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeDebugPaid
preProcessSourceCodeDebugPaid.outputs.upToDateWhen { false } // always run
}
else if (task.name == 'compilePaidReleaseAndroidTestJavaWithJavac') {
logger.quiet('---> compilePaidReleaseAndroidTestJavaWithJavac dependsOn preProcessSourceCode')
task.dependsOn preProcessSourceCodeReleasePaid
preProcessSourceCodeReleasePaid.outputs.upToDateWhen { false } // always run
}
}
The code above works by allowing gradle to modify your source code, in place, when a build is run. Source code for your app will be dynamically edited to reflect the current settings for flavor and buildType. Now modify your ‘app/build.gradle‘ and add the line:
apply from: '../preprocessor.gradle'
so that your build includes the new preprocessor.gradle rules. Below is an example showing what the ‘app/build.gradle‘ file might look like if using free and paid builds. Important: Only the second line in the example shown below is needed to pull in the new build rules. This example shows a full build configuration only for completeness:
apply plugin: 'com.android.application'
apply from: '../preprocessor.gradle'
android {
ext.addDependency = {
task, flavor, dependency ->
println('task='+(String)task+'flavor='+(String)flavor+'dependency='+(String)dependency)
}
if (project.hasProperty("MyProject.properties")
&& new File(project.property("MyProject.properties") as String).exists()) {
Properties props = new Properties()
props.load(new FileInputStream(file(project.property("MyProject.properties"))))
signingConfigs {
release {
keyAlias props['keystore.alias']
keyPassword props['keystore.password']
storeFile file(props['keystore'])
storePassword props['keystore.password']
}
debug {
keyAlias props['keystore.alias']
keyPassword props['keystore.password']
storeFile file(props['keystore'])
storePassword props['keystore.password']
}
}
}
compileSdkVersion 'Google Inc.:Google APIs:23'
buildToolsVersion "23.0.2"
defaultConfig {
applicationId "com.example.builditbigger"
minSdkVersion 14
targetSdkVersion 23
versionCode 1
versionName "1.0"
}
buildTypes {
debug {
debuggable true
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
productFlavors {
paid {
applicationId "com.example.builditbigger.paid"
versionName "1.0-Paid"
}
free {
applicationId "com.example.builditbigger.free"
versionName "1.0-Free"
}
}
}
repositories {
mavenCentral()
jcenter()
}
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:23.1.1'
compile 'com.android.support:design:23.1.1'
// Added for AdMob
freeCompile 'com.google.android.gms:play-services:8.4.0'
freeCompile 'com.google.android.gms:play-services-ads:8.4.0'
}
Now your app is ready to use #IFDEF #ELSE and #ENDIF when compiling Java code. Because Java does not recognize these new keywords, we need to prefix them with ‘//‘ so they are marked as comments. Here is an example ‘CheckPlayStore.java‘ class showing how that is done. In this example, the ImageView upgrade_paid will be null for release builds and non-null for free builds (assuming of course that your free layout.xml contains an ImageView with id ‘upgrade_to_paid‘). This means the block of code after
if (upgrade_paid != null)
will only execute for free builds. The included Logging examples also show how to conditionally check for both flavor and buildType together. Note that the #IFDEF ‘configuration’ directive is case-independent:
package com.example.builditbigger.util;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import com.example.builditbigger.R;
public class CheckPlayStore {
private final static String TAG = "EXAMPLE: <" + CheckPlayStore.class.getSimpleName() + ">";
public static void upgradeToPaid(final Activity activity) {
@SuppressWarnings("UnusedAssignment") ImageView upgrade_paid = null;
//---------------------------------------------------------------------------------------------------------
// IMPORTANT NOTE: the following #IFDEF #ELSE and #ENDIF directives are processed in build.gradle prior to javac
// CODE IN THIS BLOCK DEPENDS ON 'BUILD_TYPE' AND/OR 'FLAVOR' AND IS DYNAMICALLY EDITED BY GRADLE
//---------------------------------------------------------------------------------------------------------
//#IFDEF 'free'
//upgrade_paid = (ImageView) activity.findViewById(R.id.upgrade_to_paid);
//#ENDIF
// combo BUILD_TYPE+FLAVOR and FLAVOR+BUILD_TYPE examples..
//#IFDEF 'freeDebug'
//Log.v(TAG, "example of #IFDEF 'freeDebug'");
//#ELSE
//Log.v(TAG, "example of NOT #IFDEF 'freeDebug'");
//#ENDIF
//#IFDEF 'releaseFree'
//Log.v(TAG, "example of #IFDEF 'releaseFree'");
//#ELSE
//Log.v(TAG, "example of NOT #IFDEF 'releaseFree'");
//#ENDIF
//#IFDEF 'DEBUGPAID'
//Log.v(TAG, "example of #IFDEF 'DEBUGPAID'");
//#ELSE
//Log.v(TAG, "example of NOT #IFDEF 'DEBUGPAID'");
//#ENDIF
//#IFDEF 'paidRelease'
//Log.v(TAG, "example of #IFDEF 'paidRelease'");
//#ELSE
//Log.v(TAG, "example of NOT #IFDEF 'paidRelease'");
//#ENDIF
//---------------------------------------------------------------------------------------------------------
//noinspection ConstantConditions
if (upgrade_paid != null) {
Log.v(TAG, "using FREE version.");
upgrade_paid.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
String packageId = activity.getApplicationContext().getPackageName();
packageId = packageId.replace(".free", ".paid");
Log.v(TAG, "packageId="+packageId);
try {
// from: http://stackoverflow.com/questions/3239478/how-to-link-to-android-market-app
String upgradeLink = "http://market.android.com/details?id=" + packageId;
if (CheckPlayStore.isGooglePlayInstalled(activity.getApplicationContext())) {
upgradeLink = "market://details?id=" + packageId;
}
Log.v(TAG, "upgradeLink=" + upgradeLink);
activity.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(upgradeLink)));
}
catch (ActivityNotFoundException e) {
Log.e(TAG, "APP id='"+packageId+"' NOT FOUND ON PLAYSTORE!");
}
}
});
}
else {
Log.v(TAG, "using PAID version.");
}
}
// from: http://stackoverflow.com/questions/15401748/how-to-detect-if-google-play-is-installed-not-market
private static boolean isGooglePlayInstalled(Context context) {
PackageManager pm = context.getPackageManager();
boolean app_installed;
try
{
PackageInfo info = pm.getPackageInfo("com.android.vending", PackageManager.GET_ACTIVITIES);
String label = (String) info.applicationInfo.loadLabel(pm);
app_installed = (label != null && ! label.equals("Market"));
}
catch (PackageManager.NameNotFoundException e)
{
app_installed = false;
}
Log.v(TAG, "isGooglePlayInstalled=" + app_installed);
return app_installed;
}
}
enjoy.
Your support for my work is greatly appreciated!