Distributing your apps by APK

Photo by Mark Boss on Unsplash

Distributing your apps by APK

Some pitfalls with versionCode and keystores

In my experience with Line of Bussiness apps, apps are not distributed by the Google Play store, but by an Enterprise Mobility Management platform like SOTI MobiControl.

This means that you most likely will distribute your app as an APK and not as an "App Bundle".

In this post, I will discuss two pitfalls that you might encounter:

  • versionCode: the version number on which Android decides which version of the APK is newer.

  • keystore: each app is signed by a cryptographic key and that key has to be the same to allow app upgrades.

versionCode

The versionCode is what Android uses to determine the version of an app.

Let's consider this part of the pubspec.yaml file that is present in any Flutter app.

 version: 1.0.0+1

In the sample here above, 1.0.0 is the versionName and 1 is the versionCode. The versionName is a String and is only used for display purposes. What counts for Android is the versionCode, which is an integer. A higher number means a newer version and a lower number means an older version.

Normally it is not allowed to downgrade an app on Android. The only solution to that is to deinstall the app first which, depending on the setup, can happen automatically. A deinstall causes all local data and local configuration to get lost. This can be quite dramatic in Enterprise environments if the employees did not upload their work first.

So in an Enterprise Environment, when you deploy a new version, the versionCode of the new app must be higher than any released older versions.

This might sound like a trivial step, you just have to change the +1 in the sample above to +2, but there is a catch.

It is not 100% guaranteed that the versionCode that you specify in the pubspec.yaml file is the number that ends up in the generated APK.

Assume this build of a Flutter app, which creates a 'fat APK'.

% flutter build apk
Running Gradle task 'assembleRelease'...                           72.5s
✓  Built build/app/outputs/flutter-apk/app-release.apk (22.1MB).

Let's examine the versionCode of the generated app-release.apk.

% aapt dump badging ./build/app/outputs/flutter-apk/app-release.apk           
package: name='com.example.demo' versionCode='1' versionName='1.0.0' compileSdkVersion='33' compileSdkVersionCodename='13'

Here the versionCode = 1, exactly what we expected with version: 1.0.0+1 in the pubspec.yaml file.

Now consider this build of a Flutter app, which creates an APK for each platform (ABI). You might want to do this to shrink the size of the APK and because you think it might not hurt if you only target ARM64 devices.

% flutter build apk --split-per-abi
Running Gradle task 'assembleRelease'...                           15.3s
✓  Built build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk (7.7MB).
✓  Built build/app/outputs/flutter-apk/app-arm64-v8a-release.apk (8.0MB).
✓  Built build/app/outputs/flutter-apk/app-x86_64-release.apk (8.1MB).

Let's examine the versionCode of app-arm64-v8a-release.apk.

% aapt dump badging ./build/app/outputs/flutter-apk/app-arm64-v8a-release.apk
package: name='com.dalosy.warehouse_app' versionCode='2001' versionName='1.0.0' compileSdkVersion='33' compileSdkVersionCodename='13'

Here the versionCode = 2001, this is not what we expected 🤯.

This is of course by design, see the following issue: flutter build apk --split-per-abi generates apk with wrong version code.

In this issue the following is referenced: https://developer.android.com/build/configure-apk-splits#configure-APK-versions

Here we can read the following:

Because the Google Play Store doesn't allow multiple APKs for the same app that all have the same version information, you need to ensure that each APK has a unique versionCode before you upload to the Play Store.

In the same article, a solution is discussed where the versionCode on each platform is incremented with a multiple of 1000 and so overcomes the Play Stores' limitation.

This is exactly how it is implemented in the toolchain of Flutter. Because of the open nature of Flutter, you can find the code that does this yourself.

In the file flutter.gradle you can find on row 897 the following code that makes this happen:

if (shouldSplitPerAbi()) {
    variant.outputs.each { output ->
        // Assigns the new version code to versionCodeOverride, which changes the version code
        // for only the output APK, not for the variant itself. Skipping this step simply
        // causes Gradle to use the value of variant.versionCode for the APK.
        // For more, see https://developer.android.com/studio/build/configure-apk-splits
        def abiVersionCode = ABI_VERSION.get(output.getFilter(OutputFile.ABI))
        if (abiVersionCode != null) {
            output.versionCodeOverride =
                abiVersionCode * 1000 + variant.versionCode
        }
    }
}

So, what does that mean, for us, the Enterprise Line of Business app programmers?

  1. Keep using the same kind of APK for each of your existing projects. Do not switch from an "APK per abi" to a fat APK as this might result in unexpected app uninstalls and data loss for your customers.

  2. Consider using fat APKs for all your projects, even though the APK has an increased size. Only this type of APK has the expected original versionCode.

  3. Using "fat APKs" also prevents unexpected reinstalls while installing a debug version on a device that has a release build on it. Debug builds always use the same original versionCode as fat APKs.

Keystore

Each Android app is signed with a cryptographic key.

On large projects, with a lot of developers, or on high-profile apps, you do not want to store the cryptographic key with your source files. In that case, it is better to have the app signed by a CI/CD pipeline.

But if you are developing an app that is not distributed by the Play Store and is not a high-profile app, setting up a CI/CD pipeline is overkill and complicates the workflow for the developer without bringing reasonable benefits.

And if the release APK is signed with a different key than the debug APK, it will not be possible to install a debug version over a release version. The installed version is then uninstalled before the debug version is installed. This causes the loss of data that you might want to keep for your debugging session.

Therefore I choose the approach as explained here in the Flutter documentation.

  1. Create a keystore with keytool and place that in ./android/keystore.

  2. Create a key.properties file in ./android .

  3. Change the build.gradle file so the local keystore is used.

This solution will not work cross-platform, due to the different path conventions between Windows, macOS, and Linux.

Therefore I changed the build.gradle a bit to make it work cross-platform too.

./flutter_app/android/key.properties
./flutter_app/android/keystore/upload-keystore.jks
./flutter_app/android/app/src/build.gradle

Sample key.properties:

storePassword=comedown-twiddle-hoax
keyPassword=comedown-twiddle-hoax
keyAlias=upload
storeFileWindows=..\\keystore\\upload-keystore.jks
storeFileLinux=../keystore/upload-keystore.jks
storeFileMac=../keystore/upload-keystore.jks

Here you see the different paths for Windows, Linux and macOS.

Sample build.gradle:

import org.apache.tools.ant.taskdefs.condition.Os
....
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} else {
    throw new GradleException("key.properties file not found, please define keystore file and password in key.properties")
}

def propertiesStoreFilename
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
    propertiesStoreFilename = file(keystoreProperties['storeFileWindows'])
} else if (Os.isFamily(Os.FAMILY_MAC)) {
    propertiesStoreFilename = file(keystoreProperties['storeFileMac'])
} else if (Os.isFamily(Os.FAMILY_UNIX)) {
    propertiesStoreFilename = file(keystoreProperties['storeFileLinux'])
} else {
    throw new GradleException("Unsupported OS Family: ${Os.os}")
}

def propertiesStorePassword = keystoreProperties['storePassword']
def propertiesKeyAlias = keystoreProperties['keyAlias']
def propertiesKeyPassword = keystoreProperties['keyPassword']
....

android {
....
    signingConfigs {
        debug {
            storeFile file(propertiesStoreFilename)
            storePassword propertiesStorePassword
            keyAlias propertiesKeyAlias
            keyPassword propertiesKeyPassword
        }
        release {
            storeFile file(propertiesStoreFilename)
            storePassword propertiesStorePassword
            keyAlias propertiesKeyAlias
            keyPassword propertiesKeyPassword
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true
        }
        debug {
            signingConfig signingConfigs.debug
        }
    }
}

Now your APK will be signed with a local keystore, independent from the platform you are developing on.

I hope you had a fun read, let me know what you think! 💙