The dark side of persistence & code shrinking in Android

Blog Infos
Author
Published
Topics
, ,
Author
Published
Posted by: Rotem Matityahu
Intro

This is a story about my recent battle with persistence in Android combined with an awesome tool called code shrinker.

When dealing with object persistence in Android, one has several implementation options:
A simple file, a database, SharedPreferences, or with the new and shiny Jetpack DataStore.

If you choose to store objects in SharedPreferences, you’re probably going to use a JSON representation.
When Android’s code shrinker joins this party, things can get pretty messy.

In this article, I’m not going to cover the capabilities of the Android code shrinker (R8/Progaurd), as there are plenty of articles about it.
Instead, I will showcase a problem I faced, my thinking process, and how I managed to solve it.

Let’s start with an example, shall we?

I’m developing an app that has some user custom settings.
Since there is a single settings object, for simplicity, I decided to store the settings object as a JSON using SharedPreferences.

class Settings(
val volume: Int,
val userName: String,
val fontSize: Float,
val privacyEnabled: Boolean
)
view raw Settings.kt hosted with ❤ by GitHub

After the app was released, I realized that the font size should be taken from the system settings (because who among us wants to mess with the users’ font settings?). Naively enough, I opened Settings class, removed fontSize from it, and deployed the version to production.

A couple of days later, I noticed an issue in production — the Settingsobject couldn’t be parsed.

What the heck?

I looked for the Settings key in the SharedPreferences file (in /data/data/<packageName>/shared_prefs/<fileName>) and this is what I saw:

{
"a":55,
"b":"rotem matityahu",
"c":30.0,
"d":true
}

First I was relieved because there was some data in the file; I thought it was deleted somehow.

The shrinker obfuscates the members’ names to short and meaningless names, in the same order as the class declares them. This process happens in each build.
In the first app version, the obfuscated Settings structure looks like this:
{“a”: Int, “b”: String, “c”: Float, “d”: Boolean}
Now let’s take a look at 2 scenarios:

  • In the updated version the first field was deleted: The shrinker will assign the fields in the same order they’re written in the object and the Settings structure will look like this: {“a”: String, “b”: Float, “c”: Boolean}.
  • In the updated version the first and second fields were swapped:The shrinker will change the structure to be : {“a”: String, “b”: Int, “c”: Float, “d”: Boolean}.

In both scenarios, there’s a fields’ types mismatch between the first and the updated versions. Therefore, the persisted object in the first version’s scheme will be failed to be parsed in the updated version’s scheme.

In my case, it was the fontSize removal.
Got it! To fix the issue I just need to add fontSize again to the Settingsobject (in the same order as it was before) and the parsing will succeed because all the original members exist in the Settings class.

But did that really solve my issue? Not so fast.
I added the missing member, so why is the Settings object still not being parsed?

Then it finally hit me — I didn’t use the member; even invoking the member’s getter would have been enough.
The code shrinker will discard members that aren’t used (and on the way almost made me go to a shrink)This is what it does! It is equivalent to removing the members by yourself.

Job Offers

Job Offers


    Delivery Lead / Scrum Master (m/w/d)

    Deutsche Post IT Services (Berlin) GmbH
    Berlin
    • Full Time
    apply now

    Senior Android Engineer – Big Release Team

    Zalando SE
    Berlin
    • Full Time
    apply now

    Android Developer

    Yoti Ltd
    Anywhere
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

Behind the Curtains

All smartphones have cameras, and we know we can use specific APIs to get amazing shots. But are they the best cameras? Probably not! What if we wanted to drive an external camera, much more powerful than a smartphone? How would we connect to it, and how would we trigger a shot? This and much more…
READ MORE

Jobs

Getting it right

As responsible developers, we always test our code during development, but we do it using a debug build (because who has time to wait for Proguard in every build?). Eventually, these types of issues fall between the cracks.

Ok, so how can we prevent and resolve persistent data migration issues in Android, similar to the one I’ve encountered?

Prevention

First and foremost, think carefully about which data persistence API is better for your use case when dealing with objects:

  1. Collections of objects: Prefer using a database. There’s a migration process to implement in order to add or remove members from DB objects.
  2. Single objects: Forget about SharedPreferences and go take a look at Proto DataStore. It offers compile-time safety and supports migration from version to version, using a predefined scheme.

If you still insist on using SharedPreferences and JSON, always provide a name to serialized members.
Depending on the JSON parsing library you’re using, there are several options. With Gson, for example, you can use @SerializeName and in Moshi you have the @Json annotation (most serialization libraries will have a similar feature).
This way, you are decoupling the members’ names and how they are being saved, which protects you from any future change in the stored object structure.
Take notice though, this solution might expose your business logic through the member names.

For a more generic solution, you can create a custom annotation, annotate the persisted object and add a Progaurd rule: keepnames.
Usage: -keepnames @com.your.annotation.package.PersistedObject public class * { *; }
By using this rule, the shrinker will keep the members’ names and won’t discard unused members. When storing an object this way, its members’ names will be saved as they are (without any obfuscation).
For that reason, beware of changing the members’ names after the object has been saved since the parse will fail.

Recovery

There’s actually just one solution, and you probably knew it was coming: Migration. Parse the Object in the old format, map it to the new format and save it again.
Jetpack DataStore offers a simple and straightforward migration API from SharedPreferences.

Digesting it

As developers, we’re not always aware of what is happening to our code after the shrinker does its thing; this can lead to bugs when we mix it with persistence.
Always keep in mind that your object is being shrunk and obfuscated by the shrinker.
I know it’s kind of obvious, but sometimes we forget it.

My 2 cents

Preferring SharedPreferences was wrong in the first place.
I strongly believe that Jetpack Proto DataStore is the best solution for this use case and the future of persisting single objects. Besides being a superior communication protocol, it defeats JSON yet in another battle — the battle of persistence (2–0).

Thank you for reading and I hope you enjoyed it.

 

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Nowadays authentication has become common in almost all apps. And many of us know…
READ MORE
blog
Collections are a set of interfaces and classes that implement highly optimised data structures.…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu