KMM Preferences Datastore

In this article, well, take a look at how to DataStore and how to use it in a Kotlin Multiplatform project.

Recently Google announced a couple of Jetpack Multiplatform libraries. One is DataStore, a storage solution that allows you to store key-value pairs. This has been rewritten & is now available on Kotlin Multiplatform, targeting Android & iOS. 

Something to take note of is that these libraries are experimental and should not be used on production apps!

What you will learn:

  1. Setting up Datastore.
  2. Dependency Injection.
  3. Creating a repository
  4. I am writing a test.

TvManiac Project

A lot is changing at the moment on this project as I am cleaning up and creating a better structure for this project. The changes referenced here can be found in this commit.

Flow redux settings by c0de-wizard · Pull Request #48 · c0de-wizard/tv-maniac
Add this suggestion to a batch that can be applied as a single commit. This suggestion is invalid because there are no changes…github.com.

Let’s get started!

For our project, we need to add the preference core dependency.

dependencies {
implementation("androidx.datastore:datastore-preferences-core:1.1.0-dev01")
}

Now we need to create a function in commonMain to help us create an instance of our DataStore object. We can use expect/actual pattern and have each platform have its implementation, but I will create a function and set it up when setting up injection. It’s a straightforward function that takes in two parameters: Coroutine scope and the filePath.

fun createDataStore(
coroutineScope: CoroutineScope,
producePath: () -> String
): DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = coroutineScope,
produceFile = { producePath().toPath() },
)

internal const val dataStoreFileName = "tvmainac.preferences_pb"

And that’s it. ? We can now move over to the injection of the DataStore. As I mentioned in my previous article, we have a “hybrid injection setup” where we use Hilt on the Android side and Koin on iOS; however, I plan on migrating to Koin in the future. ?

Dependency Injection:

Android:

This should be self-explanatory:

@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context,
@DefaultCoroutineScope defaultScope: CoroutineScope
): DataStore<Preferences> = createDataStore(
coroutineScope = defaultScope,
producePath = { context.filesDir.resolve(dataStoreFileName).absolutePath }
)

IOS Koin Module:

For iOS, it’s almost similar. We need to create a function that creates the dataStoretore object and then add that to koin module

fun dataStore(scope: CoroutineScope): DataStore<Preferences> = createDataStore(
coroutineScope = scope,
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(documentDirectory).path + "/$dataStoreFileName"
}
)

actual fun settingsModule(): Module = module {
single { dataStore(get()) }
}

Boom. We can now create a repository and use it to get the theme and set the theme. The implementation looks like so.

class SettingsRepositoryImpl(
private val dataStore: DataStore<Preferences>,
private val coroutineScope: CoroutineScope
) : SettingsRepository {

override fun saveTheme(theme: String) {
coroutineScope.launch {
dataStore.edit { settings ->
settings[KEY_THEME] = theme
}
}
}

override fun observeTheme(): Flow<Theme> = dataStore.data.map { theme ->
when (theme[KEY_THEME]) {
"light" -> Theme.LIGHT
"dark" -> Theme.DARK
else -> Theme.SYSTEM
}
}

companion object {
val KEY_THEME = stringPreferencesKey("app_theme")
}

Testing

The test is pretty simple.

Creating the DataStore & repository objects.

private var preferencesScope: CoroutineScope = CoroutineScope(testCoroutineDispatcher + Job())   
private val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = preferencesScope,
produceFile = { "test.preferences_pb".toPath() },
)
private val repository = SettingsRepositoryImpl(dataStore, testCoroutineScope)

Clear/remove the key after running a test.

The other thing we need to do is clear the saved item after running a test. We could do this two ways, delete the file in the application dir or remove the preferences using the key. We will use the latter. We do that by creating a function and using @AfterTest annotation. We also need to cancel the context associated with the data store.

@AfterTest
fun clearDataStore() = runBlockingTest {
dataStore.edit {
it.remove(KEY_THEME)
}
preferencesScope.cancel()
}

Since DataStore uses coroutines, we will use Turbine for tests. This isn’t complicated. Here, we test that the theme gets updated.

@Test
fun when_theme_is_changed_correct_value_is_set() = runBlockingTest {
repository.observeTheme().test {
repository.saveTheme("dark")
awaitItem() shouldBe Theme.SYSTEM //Default theme
awaitItem() shouldBe Theme.DARK
}
}

One thing that I will improve in the future is deleting the generated test file. Right now, we ignore all  .preferences_pb files created during testing.

This is how our test class looks like after stitching everything together.

private var preferencesScope: CoroutineScope = CoroutineScope(testCoroutineDispatcher + Job())
private val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
scope = preferencesScope,
produceFile = { "test.preferences_pb".toPath() },
)
private val repository = SettingsRepositoryImpl(dataStore, testCoroutineScope)


@AfterTest
fun clearDataStore() = runBlockingTest {
dataStore.edit {
it.remove(KEY_THEME)
}
preferencesScope.cancel()
}

@Test
fun default_theme_is_emitted() = runBlockingTest {
repository.observeTheme().test {
awaitItem() shouldBe Theme.SYSTEM
}
}

@Test
fun when_theme_is_changed_correct_value_is_set() = runBlockingTest {
repository.observeTheme().test {
repository.saveTheme(Theme.DARK)
awaitItem() shouldBe Theme.SYSTEM //Default theme
awaitItem() shouldBe Theme.DARK
}
}
}

Final Thoughts

We’ve covered steps for using DataStore in a Kotlin Multiplatform project. I must say, it was super simple to get this up and running. You can use this in your ViewModel. For my project, I’m using a FlowRedux StateMachine, which I will discuss in the next post.

Any comments, good or bad, are always welcome.

Resources


Thanks to Marton Braun for reviewing this article.

(Visited 1,716 times, 1 visits today)