Introduction
First of all, let’s explain why I need the CookiesStorage. To identify the user, server sets JWT token into the cookies. And all requests from the mobile app should be sent with this cookie. When user logouts from the application, the token cookie is deleted since the session is finished.
Now let’s explain, how the previous CookiesStorage was implemented. Since the project has KMM modules (currently supported only Android part), for the networking I use the Ktor library. It allows us to install the HttpCookies plugin.
private val httpClient = HttpClient { | |
install(HttpCookies) { | |
storage = cookiesStorage | |
} | |
} |
What relates to CookiesStorage, it is the subclass of Ktor’s CookiesStorage (io.ktor.client.plugins.cookies.CookiesStorage). Here’s the way how I implemented it.
As you can see, the logic is pretty simple. When the server sets the cookie, system checks if this cookie is supported by the app (exists in CookieType), we save it in the local storage (in case of the Android value is saved into SharedPreferences).
actual class PreferencesRepository actual constructor(actual val di: DI) { | |
private val context: Context by di.instance() | |
val preferences by lazy { | |
context.getSharedPreferences("fitnestapp", Context.MODE_PRIVATE) | |
} | |
actual fun <T> saveValue(key: String, value: T?) { | |
println("Saved with Android: key=$key, value=$value") | |
val editor = preferences.edit() | |
when (value) { | |
is String -> editor.putString(key, value) | |
} | |
editor.apply() | |
} | |
actual inline fun <reified T> getValue(key: String, defaultValue: T?): T? { | |
return when (T::class) { | |
String::class -> { | |
preferences.getString(key, defaultValue as String?) as T? | |
} | |
else -> null | |
} | |
} | |
} |
And here we face the main problem. Since SharedPreferences are supported only in Android, so, we have to use the expect-actual mechanism from KMM and write implementations in iOS and Android.
But recently Android team announced the KMM version of DataStore. For my case it is a silver bullet, since it allows me to:
- Migrate from the SharedPreferences to DataStore (its advantages you can read in this brilliant article)
- Have the same multi platform code instead of expect-actual classes
Currently the biggest minus is that the library is in dev stage, but when did it stop the developers :). So, now let me explain, how did I migrate to the DataStore.
Point one.
We need to add the dependencies — androidx.datastore:datastore-preferences-core:1.1.0-dev01 (into shared part) and androidx.datastore:datastore-preferences:1.1.0-alpha01 (since it contains the SharedPreferencesMigration — will discuss it later). Note, that the versions are different.
Point two.
I created a separate repository for the DataStore. Here’s it’s listing
class DataStoreRepository internal constructor( | |
private val dataStore: DataStore<Preferences> | |
) : com.fitnest.domain.repository.DataStoreRepository { | |
override suspend fun saveString(key: String, value: String) { | |
dataStore.edit { it[stringPreferencesKey(key)] = value } | |
} | |
override suspend fun getString(key: String) = dataStore.data.map { | |
it[stringPreferencesKey(key)] | |
}.firstOrNull() | |
} |
What do we have here?
- First of all — now the repository is located in common module, instead of the Android and iOS parts. The class DataStoreRepository implements DataStoreRepository from the domain layer to achieve the dependency inversion principle and make the class easy to test.
- Second — instead of di container (which we need to get the SharedPreferences), now we pass the DataStore<Preferences> directly. So, conceptually, nothing is changed.
- Third — get and save values methods became suspend, since DataStore editing (in case of the reading values) is suspend. What about reading the values — I decided not to return Flow, because in the current implementation I execute the method on each request, so, I don’t need the observing mechanism. It’s enough to use firstOrNull, but since it’s also suspend — getString method became suspend.
Getting the value from the PreferencesDataStore is very similar to working with shared preferences, but instead of methods getString, getInt and so on, now we use PreferencesKeys (full list of them you can find here).
And CookiesStorage almost didn’t change. The only difference that instead of PreferencesRepository now I pass the DataStoreRepository. But probably it is the same entities.
Job Offers
Almost the last …
And here we face a small problem — how to migrate the data, which is already stored in the SharedPreferences to DataStore. Luckily, it is easy. When we create the instance of the DataStore, we can pass the list of the migrations (remember, that you should have only one instance of DataStore in the same process to avoid the accessing the file simultaneously and catching the exception).
First — this is how DataStore creation looks like:
internal fun getDataStore(path: String, migrations: List<DataMigration<Preferences>>) = | |
PreferenceDataStoreFactory.createWithPath(migrations = migrations) { path.toPath() } | |
internal const val dataStoreFileName = "fitnest.preferences_pb" |
Second — here’s the way how I create the migrations list:
actual fun createMigrations(di: DI): List<DataMigration<Preferences>> { | |
val context by di.instance<Context>() | |
return listOf(SharedPreferencesMigration(context, "fitnestapp")) | |
} |
Inside of the SharedPreferencesMigration we need to pass the SharedPreferences object or context and preferences name — to allow the function to get the SharedPreferences object under the hood. Also we can specify the keys we want to migrate as the third param.
Under the hood, the migration function looks simple, we just read all values from SharedPreferences, then filter only the values we need and save them into Preferences.
Conclusion.
- Appearance of this DataStore in KMM is one more point to look into the side of the KMM for the Android Developers, since it allows us to use more familiar classes and methods in the all platforms. But keep in mind that the library is in dev version.
- It allows us to use more common code instead of the expect/actual.
- With the help of the Flow, we can observe the changes of the storage.
- For more complex situations (than keeping key-value) you can use ProtoDataStore (links are here and here).
- DataStore is a more powerful alternative to the Settings library.
- Has the migrations mechanism.
You can find the source code at the Github.
Thank you for reading! Feel free to ask questions and leave the feedback in comments or Linkedin.