Using Gradle for processing Android flavor and buildType with #IFDEF in Java code

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!