Jetpack DataStore Evaluation

Esmund Koh
NE Digital
Published in
4 min readOct 11, 2021

Journey to find an alternative secure shared preference solution

In a recent bid to securely store sensitive user data such as emails and passwords, my teammates and I were exploring some options for Android. After some exploring, Encrypted Shared Preferences which is part of AndroidX Security library seems to fit the bill perfectly. That is until Jetpack DataStore came around. Since DataStore supports Kotlin coroutines and shared preference does not, it causes some inconsistency that might propagate to the rest of the codebase. With that in mind, we decided to evaluate the options and just stick to one library.

Preliminary research on DataStore looks like the perfect replacement for shared preference — with coroutine/flow support, type safety with protobuf and transactional apis to handle increments to name a few. Hence we decided to explore deeper. More on this in android dev blog here

Testing DataStore

To evaluate DataStore, I started building a sample DataStore into our code to see if it indeed would suit our needs and to get a sense of the required effort in implementation.

Since, the project I’m testing on is using Hilt, the delegate approach from the documentations and codelab to initiate DataStore wouldn’t be ideal. Instead, we can create it like so:

PreferenceDataStoreFactory.create(
scope = CoroutineScope(dispatcherProvider.io + SupervisorJob()),
migrations = listOf(SharedPreferencesMigration(context, PreferenceManager.getDefaultSharedPreferencesName(context)))
) {
context.preferencesDataStoreFile("prefDataStore")
}

The migrations param allow the library to automagically migrate the provided shared preference file and its’ keys (default to all keys) to DataStore. This is triggered upon the first access of the DataStore. Do not make the assumption that the migration happens on app launch similar to Room migrations and delete any shared preference key before that!

Now that we have a DataStore object, we can start writing & reading data. It will look a little like so:

Writing string

suspend fun setString(key: String, value: String) {
val prefKey = stringPreferencesKey(key)
prefDataStore.edit {
it[prefKey] = value
}
}

The function is defined as a suspending function as DataStore features coroutine support and editing of the data is done off the UI thread (usually on the IO dispatcher)

stringPreferencesKey is defined in the DataStore library and is required when fetching or writing values. It helps define the type of flow and value that is returned as the result.

prefDataStore.edit accepts a code block where it executes as a singular, atomic transaction where reading, incrementing and writing to the same value is safe.

The previous value of the key prior to edit is returned by prefDataStore.edit as well although in the project’s use case it is not needed and ignored.

Reading string

fun getString(key: String): Flow<String?> {
val prefKey = stringPreferencesKey(key)
return prefDataStore.data.map {
it[prefKey]
}
}

Reading the value is fairly straightforward; DataStore returns a Flow of the defined type where the caller can either collect the stream of values (if the underlying value changes) or call first() to get the value and close the stream. More info on Kotlin Flow here.

Also note that since Flow can only be collected from a coroutine, there is no need for the “get” function to be suspending unlike the “set” function.

Sounds good so far right? Yes and no; there is only one issue, DataStore does not have an encrypted version nor provide any form of encryption support. That leaves us little choice but to implement one ourselves.

The basic idea is illustrated in the diagram below

How text can be encrypted/decrypted while using DataStore

Since the sensitive data in the project are in plain text and given that we can serialise any typed object to string as needed, I modified the DataStore data source to encrypt and decrypt only strings. A sample implementation is as shown below:

suspend fun setEncryptedString(key: String, value: String) {
val prefKey = stringPreferencesKey(key)
val encryptedText = stringEncryptor.encryptString(value)
prefDataStore.edit {
it[prefKey] = encryptedText
}
}
fun getDecryptedString(key: String, defaultValue: String = ""): Flow<String> {
val prefKey = stringPreferencesKey(key)
return prefDataStore.data.map {
it[prefKey]?.run {
stringDecryptor.decryptString(this)
} ?: defaultValue
}
}

The StringEncryptor and StringDecryptor object here takes care of encrypting and decrypting any string respectively using any of the popular encryption algorithms such like AES. As cryptography and encryption is a huge topic and out of the scope of this article, I will leave the implementation details to the readers.

For best practices, I recommend following the OWASP Mobile Security guide for configuring the keys, keystore and algorithms.

And that’s it! Now we can start using DataStore instead of shared preferences without resorting to saving sensitive information as plaintext. Thank you for reading!

--

--