Post

Jetpack Preferences DataStore in Kotlin Multiplatform (KMP)

I decided to delve into Kotlin Multiplatform (KMP), and I quickly became hooked. That was a year ago, and I was initially hesitant due to the numerous build.gradle configurations that made my head spin. However, one year later, things are looking promising for the ecosystem, especially now that it’s stable.

I love the technology and how it provides a way to craft a multi-platform target solution.

I must admit that, among all the awesome libraries JetBrains has created, Ktor for the backend was my favorite (sorry Laravel, you’ll remain as the project with the best developer documentation I’ve ever encountered).

As Android developers, we are presented with a new way to store user preferences, called DataStore. Fortunately for us, it comes with KMP support (currently iOS, Android, Desktop, WASM not yet).

I won’t bore you with the details of what DataStore is.

tl:dr; type safe, async, cross platform, easily configurable, performant key-value storage.

Project and library setup

To create your KMP project, it’s best to use the KMP Wizard.

Once you import the project in Android Studio/IntelliJ Idea or Fleet, you will need to add the DataStore Preferences dependency.

1
2
3
4
5
[versions]
androidx-data-store = "1.1.0-alpha07" #at the time of this article

[libraries]
androidx-data-store-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "androidx-data-store" }

Inside your commonMain, add the library dependency.

1
2
3
4
5
6
  commonMain {
    dependencies {
      ...
      implementation(libs.androidx.data.store.core)
    }
  }

Library setup

In KMP, we have expect and actual for providing platform-specific implementations.

Inside our commonMain, we’ll create a new file DataStorePreferences.kt, where we’ll provide an expected implementation for other platforms to implement.

commonMain -> DataStorePreferences.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
expect fun dataStorePreferences(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences>

internal const val SETTINGS_PREFERENCES = "settings_preferences.preferences_pb"

internal fun createDataStoreWithDefaults(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    migrations: List<DataMigration<Preferences>> = emptyList(),
    path: () -> String,
) = PreferenceDataStoreFactory
    .createWithPath(
        corruptionHandler = corruptionHandler,
        scope = coroutineScope,
        migrations = migrations,
        produceFile = {
            path().toPath()
        }
    )

The code is self-explanatory.

I used a createDataStoreWithDefaults helper function to avoid importing the okio path, this function is used by the library to create the data store file.

androidMain -> DataStorePreferences.android.kt

1
2
3
4
5
6
7
8
9
10
11
12
actual fun dataStorePreferences(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences> = createDataStoreWithDefaults(
    corruptionHandler = corruptionHandler,
    migrations = migrations,
    coroutineScope = coroutineScope,
    path = {
        File(applicationContext.filesDir, "datastore/$SETTINGS_PREFERENCES").path
    }
)

Now, you might wonder where we got the applicationContext as it’s not a parameter.

Well, there’s a trick you can use that Firebase has been using since a long time ago, but now we have the AndroidX Startup library to make it even easier.

1
2
3
4
[versions]
androidx-startup = "1.1.1"
[libraries]
androidx-startup = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" }

Inside the build.gradle file, add the Android library.

1
2
3
4
5
androidMain {
  dependencies {
    api(libs.androidx.startup)
  }
}

Then create the initializer.

androidMain -> ContextProvider.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
internal lateinit var applicationContext: Context
    private set

data object ContextProviderInitializer

class ContextProvider: Initializer<ContextProviderInitializer> {
    override fun create(context: Context): ContextProviderInitializer {
        applicationContext = context.applicationContext
        return ContextProviderInitializer
    }

    override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
}

Don’t worry about memory leaks; we’re holding the Application context, which is safe throughout your app’s lifecycle.

The last step is inside your AndroidManifest.xml; make sure to add the provider before the closing tag </application>.

1
2
3
4
5
6
7
8
9
10
        <provider
            android:name="androidx.startup.InitializationProvider"
            android:authorities="${applicationId}.androidx-startup"
            android:exported="false"
            tools:node="merge">

            <meta-data
                android:name="context.ContextProvider"
                android:value="androidx.startup" />
        </provider>

iosMain -> DataStorePreferences.ios.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@OptIn(ExperimentalForeignApi::class)
actual fun dataStorePreferences(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences> = createDataStoreWithDefaults(
    corruptionHandler = corruptionHandler,
    migrations = migrations,
    coroutineScope = coroutineScope,
    path = {
        val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        (requireNotNull(documentDirectory).path + "/$SETTINGS_PREFERENCES")
    }
)

desktopMain -> DataStorePreferences.jvm.kt

1
2
3
4
5
6
7
8
9
10
11
actual fun dataStorePreferences(
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>?,
    coroutineScope: CoroutineScope,
    migrations: List<DataMigration<Preferences>>,
): DataStore<Preferences> =
    createDataStoreWithDefaults(
        corruptionHandler = corruptionHandler,
        migrations = migrations,
        coroutineScope = coroutineScope,
        path = { SETTINGS_PREFERENCES }
    )

Keep in mind that the actual path for Desktop is just for demonstration purposes. Make sure to discuss with your team what’s best.

Sample implementation

There are a few more things left to do, so bear with me for a moment. We are not done just yet.

We will create a simple class for demonstration purposes, nothing fancy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface AppPreferences {
    suspend fun isDarkModeEnabled(): Boolean
    suspend fun changeDarkMode(isEnabled: Boolean): Preferences    
}

internal class AppPreferencesImpl(
    private val dataStore: DataStore<Preferences>
) : AppPreferences {

    private companion object {
        private const val PREFS_TAG_KEY = "AppPreferences"
        private const val IS_DARK_MODE_ENABLED = "prefsBoolean"
    }

    private val darkModeKey = booleanPreferencesKey("$PREFS_TAG_KEY$IS_DARK_MODE_ENABLED")

    override suspend fun isDarkModeEnabled() = dataStore.data.map { preferences ->
        preferences[darkModeKey] ?: false
    }.first()

    override suspend fun changeDarkMode(isEnabled : Boolean) = dataStore.edit { preferences ->
        preferences[darkModeKey] = isEnabled
    }
}

Since DataStore needs to be instantiated only once, we need to make sure we do that in a somewhat fashionable way.

Let’s create a CoroutinesComponent and CoreComponent which will help us in this journey.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface CoroutinesComponent {
    val mainImmediateDispatcher: CoroutineDispatcher
    val applicationScope: CoroutineScope
}

internal class CoroutinesComponentImpl private constructor() : CoroutinesComponent {

    companion object {
        fun create(): CoroutinesComponent = CoroutinesComponentImpl()
    }

    override val mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate
    override val applicationScope: CoroutineScope
        get() = CoroutineScope(
            SupervisorJob() + mainImmediateDispatcher,
        )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface CoreComponent : CoroutinesComponent {
    val appPreferences: AppPreferences
}

internal class CoreComponentImpl internal constructor() : CoreComponent,
    CoroutinesComponent by CoroutinesComponentImpl.create() {

    private val dataStore: DataStore<Preferences> = dataStorePreferences(
        corruptionHandler = null,
        coroutineScope = applicationScope + Dispatchers.IO,
        migrations = emptyList()
    )

    override val appPreferences : AppPreferences = AppPreferencesImpl(dataStore)
}

What we did was provide an application scope so that the data store would use. Now let’s create a way to initialize all these components.

1
2
3
4
5
6
7
8
9
10
11
12
object ApplicationComponent {
    private var _coreComponent: CoreComponent? = null
    val coreComponent
        get() = _coreComponent
            ?: throw IllegalStateException("Make sure to call ApplicationComponent.init()")

    fun init() {
        _coreComponent = CoreComponentImpl()
    }
}

val coreComponent get() = ApplicationComponent.coreComponent

Initialization

On Android we’ll have the init happening inside our :Application

1
2
3
4
5
6
class MyAwesomeKMPApp : Application() {
    override fun onCreate() {
        super.onCreate()
        ApplicationComponent.init()
    }
}

On iOS we’ll have to create an init function, inside the MainViewController.kt let’s create it.

1
2
3
fun initialize() {
    ApplicationComponent.init()
}

then in the iOSApp.swift’s App init block, initialize it.

1
2
3
4
5
6
7
8
9
10
11
@main
struct iOSApp: App {

	init() { MainViewControllerKt.initialize() }

	var body: some Scene {
		WindowGroup {
			ContentView()
		}
	}
}

On Desktop it’s pretty straight forward.

1
2
3
4
5
6
7
8
9
fun main() = application {
    ApplicationComponent.init()

    Window(
        onCloseRequest = ::exitApplication,
    ) {
        App()
    }
}

and now with the helper property, you can just use coreComponent.appPreferences whenever you need it.

That’s all folks

This articale is also partially presenting how to set up manual DI, if enough interest is gathered, I will create an article about it in the new year, as this will probably be my last article for this one.

Happy early New Year, everyone.

This post is licensed under CC BY 4.0 by the author.