Code Obfuscation Errors On Android: A Cautionary Tale

How the GoPay team solved an issue which caused thousands of crashes on our Android app.

Code Obfuscation Errors On Android: A Cautionary Tale

By Karan Trehan

This story has 5 main characters: Gson, Dexguard, SharedPreferences, a misconfigured model, and thousands of crashes on our Android app. A few of our readers know exactly where this is going. For the rest of the lucky ones who don’t, please follow along to ensure you don’t make this easily avoidable mistake on your Android apps.

The backstory

We, at GoPay, developed a new feature for our Android app. The app was tested by the developers and the QA team extensively. Post the approval of build sanity, the app was rolled out to users on Google Play and everything was fine. We then made some changes to the feature and released a new upgrade. The upgrade too was tested by the developers and the QA team extensively. No issues were reported. But when the new upgrade was rolled out on Google Play, we started seeing thousands of crashes. The crash log looked like below:

Fatal Exception: java.lang.NullPointerException
Attempt to invoke virtual method 'int java.lang.Object.hashCode()' on a null object reference

Seeing the large number of crashes we immediately halted the rollout, rolled back and started to look for the root cause.

Flow

By looking at the crash log on Firebase Crashlytics, we debugged that a model’s variable which was expected to be non-null was being set as null.

Model

Each user has a status. For some statuses there are also sub-statuses. Hence subStatus is nullable.

data class UserStatus(
    val userStatus: String,
    val userSubStatus: String? = null
)

Code

We were saving this model in SharedPreferences and reading from it. As SharedPreference does not support objects to be written and read from it directly, we were converting the model to a json string (serializing) and saving it. While retrieving the model, we were converting it back from a json string (de-serializing) to our model object. To do this serialization and de-serialization, we were using Gson.

override fun lastKnownStatus(): UserStatus? {
    val lastKnownStatusString = preferences.getString(
        KEY_LAST_KNOWN_STATUS,
        EMPTY_STRING
    )
    if (lastKnownStatusString.isNullOrEmpty().not()) {
        return gson.parseDataFromString(
            data = lastKnownStatusString,
            modelClass = UserStatus::class.java,
            onFailure = { Timber.e(it) }
        )
    }
    return null
}

Observations

The following observations were made by us:

  1. The crash was happening only on the latest build pushed.
  2. The crash was on a model we did not update in the latest build.
  3. The crash was only for production users. It was not happening for any of our debug builds or QA builds.

The only difference between the QA and production apps was code obfuscation. We use Dexguard to obfuscate our code.

We started debugging the issue and initially concentrated on the code changes in the latest version. We wanted to check if any of the code changes were updating the model incorrectly. No culprits were found.

We then looked at the code flow and all the moving parts involved. We looked closely at the model, the function and the place where they were getting called.

We had missed adding SerializedName annotations to our model class variables.

Explanation

Our model contains 2 variables. One is nullable and the other is non-null: val userStatus: String & val userSubStatus: String? . With each build, Dexguard was free to obfuscate/replace the variable names with any word/character.

Hence in build v19 Dexguard converted the model to

data class AA(
    //userStatus
    val aa: String,
    //userSubStatus
    val ab: String? = null
)

Whereas in v20 the model was obfuscated to

data class AA(
    //userStatus
    val ab: String,
    //userSubStatus
    val aa: String? = null
)

Hence, when the user would upgrade to v20, the SharedPreference would already contain the model. On de-serialising the saved value, a null variable ab (userSubStatus) would be assigned to a non-null variable ab (userStatus) leading to crashes.

Resolution

We added the SerializedName annotations to our model variables. The updated model looked like this:

data class UserStatus(
    @SerializedName("userStatus")
    val userStatus: String,
    @SerializedName("userSubStatus")
    val userSubStatus: String? = null
)

Adding the SerializedNames helped gson serialise and deserialise the model correctly even after obfuscation.

Also to ensure that the saved misconfigured models do not hinder our flows henceforth, we cleared the previously saved values in our SharedPreferences. An RCA document was shared with our engineering team so that this mistake can be easily avoided in the future. It also now an SOP to test any new version of the app by upgrading from the existing build to ensure cross-version issues don’t surprise us.

Click here to read more stories about how we do what we do.

And we’re hiring! Check out the link below: