Script for auto-build Android applications

Mariya Zharyonova
14 min readAug 14, 2023

--

This article will cover the creation of an automated app build using a signature key for Google Play. Automated build will help reduce the time spent manually adding signature key data and choosing what to generate (APK or bundle), especially advantageous when you need to build multiple product flavor at the same time. In the end we only need to run the script from the command line and wait for it to execute. The APK or bundle will be built and added to the created build folder.

Creating application signature keys

To publish and later update the application in Google Play, you need to create a signature key. Let’s do it. To do this, select build in the top toolbar of Android Studio and choose Generate Signed Bundle/APK in the pop-up menu:

In the window that opens, select Android App Bundle. This is the file with the .aab extension, which must be uploaded to the Google Play Console for publishing. Click on the “next” button and go to the window for entering data about the already created signature key. So far we don’t have such a key, so we go to create a new one “Create new…” and enter the necessary data in the opened window (the password from the file where the key is stored and from the key itself must be the same). I suggest saving the keys in the project folder, so that you can add them to a remote Git repository later and not lose them:

For convenience, name the key file (the very first line when filling in the key data) the same as the key itself (key alias). Create as many keys as you will have applications on this code base. To create several keys, just go to the form of creating a key several times by clicking “Create new…” and repeat all the same steps. Once the key is created, you don’t need to go further to generate the signed bundle. Our task was to create the signature key. The window can be closed.

Creating gradle.build.kts without product flavors

Let’s start modifying our build.gradle.kts(build.gradle) application level, specifically the android block inside this file. Check if the SDK values, version number, code version are correct. Check if sourceCompatibility and targetCompatibility Java versions in compileOptions are correct — they should be the same and the same as the version for the generated Java jvmTarget bytecode in the kotlinOptions block. All other blocks (buildFeatures, composeOptions if you are using Compose and dependencies) must be already filled in, because without them you would not be able to build the application.

Adding key data to build.gradle.kts

Now we need to write a block in the android block that is responsible for automatically adding a signature when creating a signed .aab/APK, so that we don’t have to enter the key data manually. This block is called signingConfig. In it you need to specify all the same data that are filled in the window when creating a signed .aab/APK: the path where the file with the key is located, passwords to the file and to the key itself, and its alias (name):

android {
....

defaultConfig {
....
}

signingConfigs {

create("key1") {
keyAlias = "key1"
keyPassword = "PyYkAdn6hfnz"
storeFile = "../jks/key1.jks"
storePassword = "PyYkAdn6hfnz"
}
}
....
}

This step can still be optimized by using the java.util.Properties class. This class represents a persistent set of properties. A property can be saved in a stream or loaded from a stream. To use this class, we will need to create a text file with the key parameters and save it in the project folder. I save in the key folder:

storePassword=PyYkAdn6hfnz
keyPassword=PyYkAdn6hfnz
keyAlias=key1
storeFile=../jks/key1.jks

Now let’s describe how to create a Properties object and read a text file using an input stream:

import java.util.Properties
import java.io.FileInputStream

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}

val keystorePropertiesFile = rootProject.file("jks/key1store.properties.txt")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))

android {
....
}

Let’s rewrite the signingConfig block:

val keystorePropertiesFile = rootProject.file("jks/key1store.properties.txt")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))

android {
....

defaultConfig {
....
}

signingConfigs {

create("key1") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
....
}

Creating build types

Let’s move on to the buildType block. At this stage, you need to determine what types of builds you need: debug, release and some others, for example, a separate build for a specific application store, for which you need to specify additional parameters, or separate paid and free versions of the application. The variant with a different store we will just consider in the article, so that we have a complete understanding of what to do and how to do it, and then there were no problems with the creation of a new build type. In each release version of the build we need to specify its parameters: whether to create a signed .aab/APK, if yes — information about the key, whether to do code shrinking, resource shrinking, obfuscation, optimization, proguardFiles (more information about these 5 methods here) and additional parameters (properties) passed to the buildConfigField function, which in turn creates properties in the BuildConfig class. With this we can create separate properties for different build types. In build type for debug, there should be no optimization of code and resources, which were described above.

In our case, we will create three types of builds: debug, release and a separate build for the Russian RuStore app store. We needed to make a separate build for it because of the implementation of the transition to the store to update the application. This build will differ from the release build by an additional added parameter in BuildConfig. We will add a link to the RuStore, which the user will go to when pressing the “Update” button in the pop-up notification:

android {

....

signingConfigs {

create("key1") {
keyAlias = keystoreProperties["keyAlias1"] as String
keyPassword = keystoreProperties["keyPassword1"] as String
storeFile = file(keystoreProperties["storeFile1"] as String)
storePassword = keystoreProperties["storePassword1"] as String
}
}

buildTypes {

debug {
signingConfig = null
}

release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("key1")
}

create("rustore") {
initWith(getByName("release"))
buildConfigField("String", "APP_SOURCE_BASE_URI", "\"https://apps.rustore.ru/app/\"")
}
}

compileOptions {
....
}

....
}

For convenience, we will not configure code and resource optimization, and for proguardFiles we will specify the path to the proguards-rules.pro file created initially when creating the project. Optimization of code, resources and description of rules in proguards-rules.pro is the topic of a separate article and we will not consider it here.
To be able to use the BuildConfig class we need to write it in the buildFeatures block:

android {

....

compileOptions {
....
}

buildFeatures {
....
buildConfig = true
}

....
}

This completes the build.gradle.kts(build.gradle) application level addition.

Creating gradle.build.kts with product flavors

You need to perform all the items from the section “Creating gradle.build.kts without product flavors”, but with some modifications. If you choose to use these keys by creating an object of the Properties class, then specify the data of all three keys in the text file:

storePassword1=PyYkAdn6hfnz
keyPassword1=PyYkAdn6hfnz
keyAlias1=key1
storeFile1=../jks/key1.jks

storePassword2=0kwuLphc677B
keyPassword2=0kwuLphc677B
keyAlias2=key2
storeFile2=../jks/key2.jks

storePassword3=2w91D8SC7SJk
keyPassword3=2w91D8SC7SJk
keyAlias3=key3
storeFile3=../jks/key3.jks

Then in the signingConfig block we describe the parameters for all created keys, using a different name for each:

android {
....
signingConfigs {

create("key1") {
keyAlias = keystoreProperties["keyAlias1"] as String
keyPassword = keystoreProperties["keyPassword1"] as String
storeFile = file(keystoreProperties["storeFile1"] as String)
storePassword = keystoreProperties["storePassword1"] as String
}

create("key2") {
keyAlias = keystoreProperties["keyAlias2"] as String
keyPassword = keystoreProperties["keyPassword2"] as String
storeFile = file(keystoreProperties["storeFile2"] as String)
storePassword = keystoreProperties["storePassword2"] as String
}

create("key3") {
keyAlias = keystoreProperties["keyAlias3"] as String
keyPassword = keystoreProperties["keyPassword3"] as String
storeFile = file(keystoreProperties["storeFile3"] as String)
storePassword = keystoreProperties["storePassword3"] as String
}
}
....
}

And we don’t need to specify the key data in the buildType block in the release build type, we will specify them in the productFlavors block.

Before creating product flavors, you must add a value for the dimensions parameter(these are the first letters before applicationId: “app. ..applicationId”):

Before creating product flavors, you must add a value for the dimensions parameter(these are the first letters before applicationId: “app. ..applicationId”). If each product flavor has a different value for the dimensions parameter, then add the value “default” to the common array, and then for each product flavor you must add your own value:

android {
....

signingConfigs {
....
}

flavorDimensions.add("default")

....
}

Let’s create three product flavors to demonstrate the process of building multiple applications on the same code base. The productFlavor block is used for this purpose. This block specifies the parameters for each of the applications:

  • name — name of product flavor
  • applicationId (ex. «com.example. ….»)
  • applicationIdSuffix (ex. «.first») — matches the name
  • versionName
  • versionCode
  • additional fields such as buildConfigField and signingConfig (the signature from the signingConfigs block corresponding to the created product flavor is specified)

If the applicationId field is the same as the main fields, they can be omitted. In fact, all these parameters are a repetition of the defaultConfig block. We just specify, if necessary, those parameters that are different in each of the created applications. For example, let’s fill all these fields regardless of whether the first one matches the main defaultConfig block or not:

android {

flavorDimensions.add("default")

productFlavors {
create("first") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".first"
versionName = "1"
versionCode = 1
signingConfig = signingConfigs.getByName("key1")
}

create("second") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".second"
versionName = "1"
versionCode = 1
signingConfig = signingConfigs.getByName("key2")
}

create("third") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".third"
versionName = "1"
versionCode = 1
signingConfig = signingConfigs.getByName("key3")
}
}
}

This example shows that you can set values separately for each product flavor, but you can also use variables:

android {
....

flavorDimensions.add("default")

val _versionName = "1"
val _versionCode = 1

productFlavors {
create("first") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".first"
versionName = _versionName
versionCode = _versionCode
signingConfig = signingConfigs.getByName("key1")
}

create("second") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".second"
versionName = _versionName
versionCode = _versionCode
signingConfig = signingConfigs.getByName("key2")
}

create("third") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".third"
versionName = _versionName
versionCode = _versionCode
signingConfig = signingConfigs.getByName("key3")
}
}
....
}

Sync gradle, open the build variants tab and check that all product flavors are displayed:

Creating and running the script for auto-build application(s)

Here our task is to create a script that we will run from the command line to automatically build all created product flavors and their build types. This script is a sequential execution of Gradle tasks, which will build bundle/APKs and separate them into bundels and apks folders.

A Gradle task is a set of sequential Action objects (functions). When the task is executed, each of these actions is executed in turn, calling the Action.execute(T) method. Thus we get a sequence of actions that can be combined into an atomic operation, i.e. all actions from our point of view will be executed as one. Such atomic operation is a gradle task.

In our case we will build only release builds, for all other builds the algorithm is the same. But we must somehow define the names of the gradle tasks to be run. Here everything is simple — first in the name goes the type to be built (bundle for .aab and assemble for APK), then the name of product flavor if there is such and build type. The name of the task is written in camel case.

Creating the script without product flavor

Our script will have the extension .sh (shell). We will consider creating a script for Linux/MacOs. For convenience, place the script file in the project folder.

To execute gardle tasks we will use gradle wrapper (short just Wrapper). Therefore, the script that we will run to execute our build has the extension .gradlew. Wrapper is a script that calls our version of Gradle and installs it if necessary, which allows the programmer to quickly start working with the build without first installing the correct version of Gradle. Wrapper also standardizes the project for our version of Gradle, which makes builds more reliable.

In Linux/MacOs operating systems there is no concept of executable files (extension .exe). We will have to specify that the file is executable. Therefore, the first command will be to specify that the gradlew script file is executable:

chmod +x ./gradlew

Let’s consider the written command. The chmod command is intended for changing access rights to files and directories. The “+” sign means that we want to give permission, and the letter “x” means “execute”. This is how we define that the gradlew file can be executed. Now we can use this wrapper to execute gradle tastks.
With the next command cd(change directory), we will go to the app folder to access the application level build.gradle.kts:

chmod +x ./gradlew
cd ./app

For the convenience of tracking the stages of the build, I suggest to output the stage of the build to the terminal. To do this, we will use the command echo (output a line of text to the terminal):

chmod +x ./gradlew
cd ./app

echo "AAB build"

Let’s write down the command for the release bundle build:

chmod +x ./gradlew
cd ./app

echo "AAB build"
../gradlew bundleRelease

Let’s parse the recorded command. After the command cd ./app we are in the app folder, and the wrapper script is in the root of the project folder. In order to access this script we need to go to the directory above (“../”), then access the wrapper script to execute it (“../gradlew”) and record the task that gradle should execute (bundleRelease). Recall that the name of the task is written in the camel case.

Next, for convenience, we will create a build folder in the root of the project with two folders in it — budles and apks, in which we will put the created bundle and APK. To do this, write three commands:

chmod +x ./gradlew
cd ./app

echo "AAB build"
../gradlew bundleRelease

mkdir -p ../build/bundles
find ./build/outputs/bundle -name "*.aab" | xargs -I % cp % ../build/bundles
rm -rf ./build
  • mkdir — to make directory
  • ../build/bundles — create a build folder in the root of the project (if there is no such folder) and a bundles folder in it (if there is no such folder)
  • find — to find all files in the specified folder
  • ./build/outputs/bundle — folder where the build with the .aab type is saved by default
  • -name — find command parameter, meaning the name of the files we are looking for
  • *.aab — look for all files whose name ends in .aab
  • | — is a bash wildcard that redirects the output of the command on the left to the input of the command on the right
  • xargs — this command reads the data it receives for input (in our case from the command to the left of the wildcard), analyzes it and executes the specified command one or more times with the analyzed data
  • -I — the insert parameter of the xargs command, indicating that it is necessary to insert the data that came to the input of the xargs command instead of the filler %
  • % — placeholder for the xargs -I command
  • cp — to copy from this directory to another
  • ../build/bundles — the directory to which we will copy the found files
  • rm — to remove files or a directory
  • -rf — rm command parameter specifying to forcibly delete the directory with all its contents, including files and subdirectories
  • ./build — the directory that we want to completely delete for uselessness — we moved all the built bundles to ../build/bundles (remember that this directory is created in the app folder)

Now let’s do all the same steps after moving to the app directory to create a build for build type “rustore”. We will build APK for it, so gradle task will be recorded:

../gradlew assembleRustore

When selecting all files to move to the build folder, we will look for all files with the .apk extension. In the end we will have a script:

chmod +x ./gradlew
cd ./app

echo "AAB build"
../gradlew bundleRelease

mkdir -p ../build/bundles
find ./build/outputs/bundle -name "*.aab" | xargs -I % cp % ../build/bundles
rm -rf ./build

echo "APK build"
../gradlew assembleRustore

mkdir -p ../build/apk
find ./build/outputs -name "*.apk" | xargs -I % cp % ../build/apk
rm -rf ./build

Creating the script with product flavors

Let’s rewrite the .sh script we wrote for gradle without product flavor, for multiple product flavor. The only difference will be in the gradle tasks entry. The name of the product flavor will be added:

chmod +x ./gradlew
cd ./app

echo "AAB build"
../gradlew bundleFirstRelease
../gradlew bundleSecondRelease
../gradlew bundleThirdRelease

mkdir -p ../build/bundles
find ./build/outputs/bundle -name "*.aab" | xargs -I % cp % ../build/bundles
rm -rf ./build

echo "APK build"
../gradlew assembleFirstRustore
../gradlew assembleSecondRustore
../gradlew assembleThirdRustore

mkdir -p ../build/apk
find ./build/outputs -name "*.apk" | xargs -I % cp % ../build/apk
rm -rf ./build

Running the script

To run the script, we need to specify that our script is executable, just like for gradlew. Let’s check what modes it has by default:

Now let’s execute the already known chmod command and check what has changed in the script modes:

Great! The letter “x” appears, so the execution mode is set correctly. Now we need to run it. First we synchronize gradle to make sure that what we have written is correct. Now I recommend that you build a debug build once to make sure that everything works as it should. Now run the script just by accessing it:

Auto-build script execution without product flavor
Auto-build script execution with product flavors

The project folder:

The folder with signature keys:

The resulting build.gradle.kts file:

import java.util.Properties
import java.io.FileInputStream

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}


val keystorePropertiesFile = rootProject.file("jks/keystore.properties.txt")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))

android {
namespace = "com.example.application_for_article"
compileSdk = 33

defaultConfig {
applicationId = "com.example.application_for_article"
minSdk = 24
targetSdk = 33
versionCode = 1
versionName = "1"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}

signingConfigs {

create("key1") {
keyAlias = keystoreProperties["keyAlias1"] as String
keyPassword = keystoreProperties["keyPassword1"] as String
storeFile = file(keystoreProperties["storeFile1"] as String)
storePassword = keystoreProperties["storePassword1"] as String
}

create("key2") {
keyAlias = keystoreProperties["keyAlias2"] as String
keyPassword = keystoreProperties["keyPassword2"] as String
storeFile = file(keystoreProperties["storeFile2"] as String)
storePassword = keystoreProperties["storePassword2"] as String
}

create("key3") {
keyAlias = keystoreProperties["keyAlias3"] as String
keyPassword = keystoreProperties["keyPassword3"] as String
storeFile = file(keystoreProperties["storeFile3"] as String)
storePassword = keystoreProperties["storePassword3"] as String
}
}

val _versionName = "1"
val _versionCode = 1

flavorDimensions.add("default")

productFlavors {
create("first") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".first"
versionName = _versionName
versionCode = _versionCode
signingConfig = signingConfigs.getByName("key1")
}

create("second") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".second"
versionName = _versionName
versionCode = 1
signingConfig = signingConfigs.getByName("key2")
}

create("third") {
applicationId = "com.example.application_for_article"
applicationIdSuffix = ".third"
versionName = _versionName
versionCode = _versionCode
signingConfig = signingConfigs.getByName("key3")
}
}

buildTypes {

debug {
signingConfig = null
}

release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("key1")
}

create("rustore") {
initWith(getByName("release"))
buildConfigField("String", "APP_SOURCE_BASE_URI", "\"https://apps.rustore.ru/app/\"")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.3"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies {

....
}

If you found article useful then please start following me. You can also find me on Medium or LinkedIn

--

--