Create a Custom Build in Android

TestFairy is a mobile testing platform that helps companies streamline their mobile development process and fix bugs faster. Read more about TestFairy at testfairy.com

In TestFairy, we are commonly asked by our partners to help their way omitting the TestFairy SDK in their final build variants staged for upload to Google Play. To provide a seamless app experience to their users, they test each of their releases with extra care, establishing a living beta environment to easily pinpoint otherwise hard to debug errors. Once a release is battle-tested, the TestFairy SDK is no longer needed.

A build created for beta testing is neither a Debug, nor a Release build. It has some settings such as extra paranoid (potentially privacy unfriendly) error reporting enabled in a controlled userbase consists of people that give proper consent. It can also connect to some endpoints only relevant to the testing environment. In some cases, you may even want to integrate entire frameworks in a test build. Android, thanks to gradle, provides a highly customizable environment for you to define custom build workflows. But before we dive into the specifics, we need to visit the terminology.

Clearing the Confusion: Build Variants, Build Types and Product Flavors

In his overly simplified explanation, Julian A. from Stack Overflow actually provides a good summary. Gradle packages your application with the following logic.

For each build type:
….
For each product flavor:
…….. Choose files defined in the flavor
…….. Package chosen files with the settings defined in the build type
…….. Merge remaining build type settings with flavor settings
…….. Create a build variant.

In a build type, we define things like how to minify the resources to reduce application size and remove unused code, how to obfuscate source code to protect the app from reverse engineering, define global constants (resources) to be used in application logic, enable or disable debug metadata and many other settings listed here.

Most commonly known build types are Debug and Release, which are already provided in a newly created Android Studio project. These build types can be sufficient if the app displays offline content and a frequent update schedule is not planned. However, if the app depends on an API server and interacts with outside services, it is probably more beneficial to have more than two build types. When an end-user facing app gets older, complexities start to pile up. You can no longer tolerate occasional crashes. Placeholder data do not represent the scale of your real life use case. Eventually, a test build will become a necessity.

With a product flavor, a set of rules determining which files to use from the project, which Android features and SDK versions to use, which resources to pick are specified. These rules can be chosen from the same list as build types. When gradle starts building your project, it creates a combined set of settings gathered from currently packaged build type and product flavor to create final build variant.

If you target multiple devices including low-performance budget phones to high-end muscle tablets, creating a flavor for one of each is a common idiom you can utilize. Or if you want to omit some functionality in a trial version of your app, you can create a trial and a paid flavor. It is even possible to use different MainActivity.java files in each of your flavors.

Now we know the difference. Let’s actually create our own variants!

An Example Debug-Prerelease-Release Workflow with Trial-Paid Flavors

Here is the minimum set of instructions to create the workflow in the title, specified in your app’s gradle file.

apply plugin: 'com.android.application'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.testfairy.custombuildsplayground"
        minSdkVersion 28
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        debug {
            minifyEnabled false
            debuggable true
            applicationIdSuffix ".debug"
        }
        
        prerelease {
            minifyEnabled false
            debuggable false
            applicationIdSuffix ".prerelease"
        }
        
        release {
            minifyEnabled true
            debuggable false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }

    flavorDimensions "version"

    productFlavors {
        trial {
            dimension "version"
            applicationIdSuffix ".trial"
            versionNameSuffix "-trial"
        }

        paid {
            dimension "version"
            applicationIdSuffix ".paid"
            versionNameSuffix "-paid"
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

Our debug and prerelease builds will skip the proguard step. Prerelease and release builds will omit to debug meta data in it binary. Finally, release builds will execute proguard to remove unused code as well as obfuscate the sources.

Currently, trial and paid flavors are exactly the same. We will customize the variants according to our needs.

Defining Global Constants

It is possible to declare build constants both in build types and product flavors. We will choose different API server URLs for each of our build types and set a boolean in our flavors to represent whether we are building for paid or users or not.

apply plugin: 'com.android.application'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.testfairy.custombuildsplayground"
        minSdkVersion 28
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        debug {
            minifyEnabled false
            debuggable true
            applicationIdSuffix ".debug"
            buildConfigField "String", "API_SERVER_URL", "http://localhost"
        }
        
        prerelease {
            minifyEnabled false
            debuggable false
            applicationIdSuffix ".prerelease"
            buildConfigField "String", "API_SERVER_URL", "http://test.example.com"
        }
        
        release {
            minifyEnabled true
            debuggable false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
            buildConfigField "String", "API_SERVER_URL", "http://api.example.com"
        }
    }

    flavorDimensions "version"

    productFlavors {
        trial {
            dimension "version"
            applicationIdSuffix ".trial"
            versionNameSuffix "-trial"
            buildConfigField "boolean", "IS_PAID", "false"
        }

        paid {
            dimension "version"
            applicationIdSuffix ".paid"
            versionNameSuffix "-paid"
            buildConfigField "boolean", "IS_PAID", "true"
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

We can now access our new constants in our Java or Kotlin files like this.

BuildConfig.API_SERVER_URL // a String

BuildConfig.IS_PAID // a boolean

Choosing Different Version of the Same File in each Flavor

A trial version of your app shouldn’t include the capabilities of a paid version if released separately. In order to achieve that, you may want to use different a different main activity, XML layouts and values during the packaging. Android Studio projects are bundled with a predefined task which shows you where to put your source files to achieve such behavior.

Open up the Gradle pane in your IDE, navigate to app->Tasks->android and run sourceSets. In your build log, you will be shown an exhaustive list of all the possible folders you can create to differentiate source files in each of your build variants.

15:55:19: Executing task 'sourceSets'...

Executing tasks: [sourceSets]

:app:sourceSets

------------------------------------------------------------
Project :app
------------------------------------------------------------

androidTest
-----------
Compile configuration: androidTestCompile
build.gradle name: android.sourceSets.androidTest
Java sources: [app/src/androidTest/java]
Manifest file: app/src/androidTest/AndroidManifest.xml
Android resources: [app/src/androidTest/res]
Assets: [app/src/androidTest/assets]
AIDL sources: [app/src/androidTest/aidl]
RenderScript sources: [app/src/androidTest/rs]
JNI sources: [app/src/androidTest/jni]
JNI libraries: [app/src/androidTest/jniLibs]
Java-style resources: [app/src/androidTest/resources]

androidTestDebug
----------------
Compile configuration: androidTestDebugCompile
build.gradle name: android.sourceSets.androidTestDebug
Java sources: [app/src/androidTestDebug/java]
Manifest file: app/src/androidTestDebug/AndroidManifest.xml
Android resources: [app/src/androidTestDebug/res]
Assets: [app/src/androidTestDebug/assets]
AIDL sources: [app/src/androidTestDebug/aidl]
RenderScript sources: [app/src/androidTestDebug/rs]
JNI sources: [app/src/androidTestDebug/jni]
JNI libraries: [app/src/androidTestDebug/jniLibs]
Java-style resources: [app/src/androidTestDebug/resources]

androidTestPaid
---------------
Compile configuration: androidTestPaidCompile
build.gradle name: android.sourceSets.androidTestPaid
Java sources: [app/src/androidTestPaid/java]
Manifest file: app/src/androidTestPaid/AndroidManifest.xml
Android resources: [app/src/androidTestPaid/res]
Assets: [app/src/androidTestPaid/assets]
AIDL sources: [app/src/androidTestPaid/aidl]
RenderScript sources: [app/src/androidTestPaid/rs]
JNI sources: [app/src/androidTestPaid/jni]
JNI libraries: [app/src/androidTestPaid/jniLibs]
Java-style resources: [app/src/androidTestPaid/resources]

androidTestPaidDebug
--------------------
Compile configuration: androidTestPaidDebugCompile
build.gradle name: android.sourceSets.androidTestPaidDebug
Java sources: [app/src/androidTestPaidDebug/java]
Manifest file: app/src/androidTestPaidDebug/AndroidManifest.xml
Android resources: [app/src/androidTestPaidDebug/res]
Assets: [app/src/androidTestPaidDebug/assets]
AIDL sources: [app/src/androidTestPaidDebug/aidl]
RenderScript sources: [app/src/androidTestPaidDebug/rs]
JNI sources: [app/src/androidTestPaidDebug/jni]
JNI libraries: [app/src/androidTestPaidDebug/jniLibs]
Java-style resources: [app/src/androidTestPaidDebug/resources]

androidTestTrial
----------------
Compile configuration: androidTestTrialCompile
build.gradle name: android.sourceSets.androidTestTrial
Java sources: [app/src/androidTestTrial/java]
Manifest file: app/src/androidTestTrial/AndroidManifest.xml
Android resources: [app/src/androidTestTrial/res]
Assets: [app/src/androidTestTrial/assets]
AIDL sources: [app/src/androidTestTrial/aidl]
RenderScript sources: [app/src/androidTestTrial/rs]
JNI sources: [app/src/androidTestTrial/jni]
JNI libraries: [app/src/androidTestTrial/jniLibs]
Java-style resources: [app/src/androidTestTrial/resources]

androidTestTrialDebug
---------------------
Compile configuration: androidTestTrialDebugCompile
build.gradle name: android.sourceSets.androidTestTrialDebug
Java sources: [app/src/androidTestTrialDebug/java]
Manifest file: app/src/androidTestTrialDebug/AndroidManifest.xml
Android resources: [app/src/androidTestTrialDebug/res]
Assets: [app/src/androidTestTrialDebug/assets]
AIDL sources: [app/src/androidTestTrialDebug/aidl]
RenderScript sources: [app/src/androidTestTrialDebug/rs]
JNI sources: [app/src/androidTestTrialDebug/jni]
JNI libraries: [app/src/androidTestTrialDebug/jniLibs]
Java-style resources: [app/src/androidTestTrialDebug/resources]

debug
-----
Compile configuration: debugCompile
build.gradle name: android.sourceSets.debug
Java sources: [app/src/debug/java]
Manifest file: app/src/debug/AndroidManifest.xml
Android resources: [app/src/debug/res]
Assets: [app/src/debug/assets]
AIDL sources: [app/src/debug/aidl]
RenderScript sources: [app/src/debug/rs]
JNI sources: [app/src/debug/jni]
JNI libraries: [app/src/debug/jniLibs]
Java-style resources: [app/src/debug/resources]

main
----
Compile configuration: compile
build.gradle name: android.sourceSets.main
Java sources: [app/src/main/java]
Manifest file: app/src/main/AndroidManifest.xml
Android resources: [app/src/main/res]
Assets: [app/src/main/assets]
AIDL sources: [app/src/main/aidl]
RenderScript sources: [app/src/main/rs]
JNI sources: [app/src/main/jni]
JNI libraries: [app/src/main/jniLibs]
Java-style resources: [app/src/main/resources]

paid
----
Compile configuration: paidCompile
build.gradle name: android.sourceSets.paid
Java sources: [app/src/paid/java]
Manifest file: app/src/paid/AndroidManifest.xml
Android resources: [app/src/paid/res]
Assets: [app/src/paid/assets]
AIDL sources: [app/src/paid/aidl]
RenderScript sources: [app/src/paid/rs]
JNI sources: [app/src/paid/jni]
JNI libraries: [app/src/paid/jniLibs]
Java-style resources: [app/src/paid/resources]

paidDebug
---------
Compile configuration: paidDebugCompile
build.gradle name: android.sourceSets.paidDebug
Java sources: [app/src/paidDebug/java]
Manifest file: app/src/paidDebug/AndroidManifest.xml
Android resources: [app/src/paidDebug/res]
Assets: [app/src/paidDebug/assets]
AIDL sources: [app/src/paidDebug/aidl]
RenderScript sources: [app/src/paidDebug/rs]
JNI sources: [app/src/paidDebug/jni]
JNI libraries: [app/src/paidDebug/jniLibs]
Java-style resources: [app/src/paidDebug/resources]

paidPrerelease
--------------
Compile configuration: paidPrereleaseCompile
build.gradle name: android.sourceSets.paidPrerelease
Java sources: [app/src/paidPrerelease/java]
Manifest file: app/src/paidPrerelease/AndroidManifest.xml
Android resources: [app/src/paidPrerelease/res]
Assets: [app/src/paidPrerelease/assets]
AIDL sources: [app/src/paidPrerelease/aidl]
RenderScript sources: [app/src/paidPrerelease/rs]
JNI sources: [app/src/paidPrerelease/jni]
JNI libraries: [app/src/paidPrerelease/jniLibs]
Java-style resources: [app/src/paidPrerelease/resources]

paidRelease
-----------
Compile configuration: paidReleaseCompile
build.gradle name: android.sourceSets.paidRelease
Java sources: [app/src/paidRelease/java]
Manifest file: app/src/paidRelease/AndroidManifest.xml
Android resources: [app/src/paidRelease/res]
Assets: [app/src/paidRelease/assets]
AIDL sources: [app/src/paidRelease/aidl]
RenderScript sources: [app/src/paidRelease/rs]
JNI sources: [app/src/paidRelease/jni]
JNI libraries: [app/src/paidRelease/jniLibs]
Java-style resources: [app/src/paidRelease/resources]

prerelease
----------
Compile configuration: prereleaseCompile
build.gradle name: android.sourceSets.prerelease
Java sources: [app/src/prerelease/java]
Manifest file: app/src/prerelease/AndroidManifest.xml
Android resources: [app/src/prerelease/res]
Assets: [app/src/prerelease/assets]
AIDL sources: [app/src/prerelease/aidl]
RenderScript sources: [app/src/prerelease/rs]
JNI sources: [app/src/prerelease/jni]
JNI libraries: [app/src/prerelease/jniLibs]
Java-style resources: [app/src/prerelease/resources]

release
-------
Compile configuration: releaseCompile
build.gradle name: android.sourceSets.release
Java sources: [app/src/release/java]
Manifest file: app/src/release/AndroidManifest.xml
Android resources: [app/src/release/res]
Assets: [app/src/release/assets]
AIDL sources: [app/src/release/aidl]
RenderScript sources: [app/src/release/rs]
JNI sources: [app/src/release/jni]
JNI libraries: [app/src/release/jniLibs]
Java-style resources: [app/src/release/resources]

test
----
Compile configuration: testCompile
build.gradle name: android.sourceSets.test
Java sources: [app/src/test/java]
Java-style resources: [app/src/test/resources]

testDebug
---------
Compile configuration: testDebugCompile
build.gradle name: android.sourceSets.testDebug
Java sources: [app/src/testDebug/java]
Java-style resources: [app/src/testDebug/resources]

testPaid
--------
Compile configuration: testPaidCompile
build.gradle name: android.sourceSets.testPaid
Java sources: [app/src/testPaid/java]
Java-style resources: [app/src/testPaid/resources]

testPaidDebug
-------------
Compile configuration: testPaidDebugCompile
build.gradle name: android.sourceSets.testPaidDebug
Java sources: [app/src/testPaidDebug/java]
Java-style resources: [app/src/testPaidDebug/resources]

testPaidPrerelease
------------------
Compile configuration: testPaidPrereleaseCompile
build.gradle name: android.sourceSets.testPaidPrerelease
Java sources: [app/src/testPaidPrerelease/java]
Java-style resources: [app/src/testPaidPrerelease/resources]

testPaidRelease
---------------
Compile configuration: testPaidReleaseCompile
build.gradle name: android.sourceSets.testPaidRelease
Java sources: [app/src/testPaidRelease/java]
Java-style resources: [app/src/testPaidRelease/resources]

testPrerelease
--------------
Compile configuration: testPrereleaseCompile
build.gradle name: android.sourceSets.testPrerelease
Java sources: [app/src/testPrerelease/java]
Java-style resources: [app/src/testPrerelease/resources]

testRelease
-----------
Compile configuration: testReleaseCompile
build.gradle name: android.sourceSets.testRelease
Java sources: [app/src/testRelease/java]
Java-style resources: [app/src/testRelease/resources]

testTrial
---------
Compile configuration: testTrialCompile
build.gradle name: android.sourceSets.testTrial
Java sources: [app/src/testTrial/java]
Java-style resources: [app/src/testTrial/resources]

testTrialDebug
--------------
Compile configuration: testTrialDebugCompile
build.gradle name: android.sourceSets.testTrialDebug
Java sources: [app/src/testTrialDebug/java]
Java-style resources: [app/src/testTrialDebug/resources]

testTrialPrerelease
-------------------
Compile configuration: testTrialPrereleaseCompile
build.gradle name: android.sourceSets.testTrialPrerelease
Java sources: [app/src/testTrialPrerelease/java]
Java-style resources: [app/src/testTrialPrerelease/resources]

testTrialRelease
----------------
Compile configuration: testTrialReleaseCompile
build.gradle name: android.sourceSets.testTrialRelease
Java sources: [app/src/testTrialRelease/java]
Java-style resources: [app/src/testTrialRelease/resources]

trial
-----
Compile configuration: trialCompile
build.gradle name: android.sourceSets.trial
Java sources: [app/src/trial/java]
Manifest file: app/src/trial/AndroidManifest.xml
Android resources: [app/src/trial/res]
Assets: [app/src/trial/assets]
AIDL sources: [app/src/trial/aidl]
RenderScript sources: [app/src/trial/rs]
JNI sources: [app/src/trial/jni]
JNI libraries: [app/src/trial/jniLibs]
Java-style resources: [app/src/trial/resources]

trialDebug
----------
Compile configuration: trialDebugCompile
build.gradle name: android.sourceSets.trialDebug
Java sources: [app/src/trialDebug/java]
Manifest file: app/src/trialDebug/AndroidManifest.xml
Android resources: [app/src/trialDebug/res]
Assets: [app/src/trialDebug/assets]
AIDL sources: [app/src/trialDebug/aidl]
RenderScript sources: [app/src/trialDebug/rs]
JNI sources: [app/src/trialDebug/jni]
JNI libraries: [app/src/trialDebug/jniLibs]
Java-style resources: [app/src/trialDebug/resources]

trialPrerelease
---------------
Compile configuration: trialPrereleaseCompile
build.gradle name: android.sourceSets.trialPrerelease
Java sources: [app/src/trialPrerelease/java]
Manifest file: app/src/trialPrerelease/AndroidManifest.xml
Android resources: [app/src/trialPrerelease/res]
Assets: [app/src/trialPrerelease/assets]
AIDL sources: [app/src/trialPrerelease/aidl]
RenderScript sources: [app/src/trialPrerelease/rs]
JNI sources: [app/src/trialPrerelease/jni]
JNI libraries: [app/src/trialPrerelease/jniLibs]
Java-style resources: [app/src/trialPrerelease/resources]

trialRelease
------------
Compile configuration: trialReleaseCompile
build.gradle name: android.sourceSets.trialRelease
Java sources: [app/src/trialRelease/java]
Manifest file: app/src/trialRelease/AndroidManifest.xml
Android resources: [app/src/trialRelease/res]
Assets: [app/src/trialRelease/assets]
AIDL sources: [app/src/trialRelease/aidl]
RenderScript sources: [app/src/trialRelease/rs]
JNI sources: [app/src/trialRelease/jni]
JNI libraries: [app/src/trialRelease/jniLibs]
Java-style resources: [app/src/trialRelease/resources]


BUILD SUCCESSFUL in 2s
1 actionable task: 1 executed
15:55:24: Task execution finished 'sourceSets'.

We are only interested in a differentiation between the flavors. So in our case, sections named as trial and paid are what we are looking for.

paid
----
Compile configuration: paidCompile
build.gradle name: android.sourceSets.paid
Java sources: [app/src/paid/java]
Manifest file: app/src/paid/AndroidManifest.xml
Android resources: [app/src/paid/res]
Assets: [app/src/paid/assets]
AIDL sources: [app/src/paid/aidl]
RenderScript sources: [app/src/paid/rs]
JNI sources: [app/src/paid/jni]
JNI libraries: [app/src/paid/jniLibs]
Java-style resources: [app/src/paid/resources]

trial
-----
Compile configuration: trialCompile
build.gradle name: android.sourceSets.trial
Java sources: [app/src/trial/java]
Manifest file: app/src/trial/AndroidManifest.xml
Android resources: [app/src/trial/res]
Assets: [app/src/trial/assets]
AIDL sources: [app/src/trial/aidl]
RenderScript sources: [app/src/trial/rs]
JNI sources: [app/src/trial/jni]
JNI libraries: [app/src/trial/jniLibs]
Java-style resources: [app/src/trial/resources]

What Gradle is telling us is, if we create one of the folders specified in the text above, Gradle will use that folder to determine which file it should use. With this method, it is possible to define different manifests, assets, classes etc for each of your build variants.

There is an easy way to create these folders without dealing with manual naming. Change your Project pane’s navigator visibility to Project.

Navigate to app->src and right click.

Choose the type of folder you want to differentiate. For example, you can choose Java Folder if you want to use a different MainActivity class in your flavor. When you click one of the available options, you will be asked for which variant to use. Do this once for trial and once for paid.

If you chose Java Folder, your project would end up like this.

Now, you can recreate the same package and folder structure in these newly created java folders to be able to define another MainActivity.java file for each of your flavors.

Fully Exclude TestFairy SDK in Release

In order for ProGuard to fully crop the TestFairy SDK from the final binary, you may use a wrapper class that differentiates in each of your flavors.

  • Create a new Java folder for your release variant.
  • Create a Java class somewhere in the main variant.
  • Create the same package structure and Java class in the release variant as well.
  • Put the code below into the Java class under main/java.
public class TestFairyWrapper {
  public static void begin(Activity activity, String appToken) {
    TestFairy.begin(activity, appToken);
  }
}
  • Put the code below into the Java class under release/java.
public class TestFairyWrapper {
  public static void begin(Activity activity, String appToken) {
    // Do nothing
  }
}

Call TestFairyWrapper.begin() in your main activity.