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!
Excellent!!
Some improvements:
– for using #elif
– lines beginning with //// don’t change
– support utf-8 java source code
– correctly comment/uncomment lines where the second character is space (for example “} else {“)
– use directives in upper case and lower case format:
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 ifdefRegex2 = ‘^([ ]*)(\\/\\/)#ifdef \'(.*)\’$’
String elifRegex = ‘^([ ]*)(\\/\\/)#ELIF \'(.*)\’$’
String elifRegex2 = ‘^([ ]*)(\\/\\/)#elif \'(.*)\’$’
String elseRegex = ‘^([ ]*)(\\/\\/)#ELSE$’
String elseRegex2 = ‘^([ ]*)(\\/\\/)#else$’
String endifRegex = ‘^([ ]*)(\\/\\/)#ENDIF$’
String endifRegex2 = ‘^([ ]*)(\\/\\/)#endif$’
String lineRegex = ‘^([ ]*)([^ ][^ ]|[^ ][ ])(.*)$’
String lineRegex2 = ‘^([ ]*)(////)(.*)$’
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(‘UTF-8’)
StringBuilder newContent = new StringBuilder()
IfdefState match = IfdefState.NONE
boolean changed = false;
String buildTypeAndOrFlavor = “”
content.eachLine { line, index ->
// process #IFDEF #ELIF
if (line.matches(ifdefRegex) || line.matches(ifdefRegex2) || line.matches(elifRegex) || line.matches(elifRegex2)) {
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) || line.matches(elseRegex2)) {
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) || line.matches(endifRegex2)) {
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) && !line.matches(lineRegex2)) {
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(lineRegex2)) {
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(),'UTF-8')
}
}
}
}