When do I need to care about multiple processes?

In some occasions, we might need to access our Jetpack DataStore from different processes. You might encounter that this is necessary, when you write something to your Preferences DataStore but a service or activity in another part of your app does not pick up the change. This is likely because the read and write happen in different processes and consistency is not guaranteed there.

How it was before

The Preferences DataStore does not support multiple processes, but we can easily adapt an existing one to instead be accessed by multiple processes, while keeping a similar flexibility.

Let’s say, you’ve wrapped your Preferences DataStore in a class like this:

private val USER_PREFERENCES_NAME = "user_preferences"
private val Context.dataStore by preferencesDataStore(
        name = USER_PREFERENCES_NAME
)

@Singleton
class DataStoreManager @Inject constructor(@ApplicationContext context: Context) {
    companion object {
        val TOKEN = stringPreferencesKey("token")
        val LOGIN_COUNT = intPreferencesKey("loginCount")
    }
    
    private val dataStore = context.dataStore

    suspend fun saveToken(token: String) {
        dataStore.edit { preferences ->
            preferences[TOKEN] = token
        }
    }

    val tokenFlow = dataStore.data.map { preferences ->
        preferences[TOKEN] ?: ""
    }
    
    // and so on
}

In this, we’re using the companion object to store the keys that the DataStore uses to access our data.

How to fix it

In order to adapt the DataStore for multiple processes, we need to initialize it differently and take care of a small amount of data handling. We’ll utilize the MultiProcessDataStoreFactory for this.

First, let’s use the keys from the companion object to model our new data class. It will represent what the DataStore can save. We’ll also mark it as @Serializable.

@Serializable
data class AppSettings(
    val token: String?,
    val loginCount: Int
)

Dependencies

You’ll notice that there is no import available for the annotation. To fix this, we’ll need to update the build.gradle files in the project as well as the module.

For the project build.gradle:

plugins {
    // ...
    id "org.jetbrains.kotlin.android" version "1.9.0" apply false
    id "org.jetbrains.kotlin.plugin.serialization" version "1.9.0"
}

And some more in the module build.gradle:

plugins {
    // ... all other project plugins
    id "kotlin-parcelize"
    id "kotlinx-serialization"
    id "org.jetbrains.kotlin.plugin.serialization"
}

android {
    // ... all other configuration
    
    dependencies {
        // .. all your other imports
        implementation "androidx.datastore:datastore:1.1.1"
        implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.4.1"
        implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
    }
}

If any peculiar errors show up like Cannot access 'Serializable': it is internal in 'kotlin.io', be sure that the project’s plugin versions match and that you’ve changed the correct files.

Adding the Serializer

The MultiProcessDataStoreFactory requires us to specify a Serializer. To keep it simple, we’re going to save our preferences in JSON. This will handle read/write operations and provide default values to the DataStore.

@Singleton
class SettingsSerializer @Inject constructor() : Serializer<AppSettings> {
    override val defaultValue = AppSettings(
        token = "",
        loginCount = 0
    )
    
    override suspend fun readFrom(input: InputStream): AppSettings =
        try {
            Json.decodeFromString(input.readBytes().decodeToString())
        } catch (serialization: SerializationException) {
            throw CorruptionException("Unable to read Settings", serialization)
        }

    override suspend fun writeTo(t: AppSettings, output: OutputStream) {
        withContext(Dispatchers.IO) {
            output.write(
                Json.encodeToString(t)
                    .encodeToByteArray()
            )
        }
    }
}

Tying it together

As we also need to specify to which file our preferences get written to, we’ll add a function for this. This will use the default DataStore location for your application.

fun Context.appSettingsDataStoreFile(name: String): File =
    this.dataStoreFile("$name.appsettings.json")

And change the following in our DataStoreManager:

  • remove the preferencesKeys from the companion object
  • initialize the dataStore with MultiProcessDataStoreFactory.create
  • change save calls to use dataStore.updateData
  • change Flows to use the AppSettings class
private val DATASTORE_NAME = "user_preferences"

@Singleton
class DataStoreManager @Inject constructor(@ApplicationContext context: Context) {
    companion object {
        val TOKEN = stringPreferencesKey("token")
        val LOGIN_COUNT = intPreferencesKey("loginCount")
    }
    
    private val dataStore: DataStore<AppSettings> = MultiProcessDataStoreFactory.create(
        serializer = SettingsSerializer(),
        produceFile = { context.appSettingsDataStoreFile(DATASTORE_NAME) }
    )

    suspend fun saveToken(token: String) {
        dataStore.updateData {
            it.copy(token = token)
        }
    }

    val tokenFlow = dataStore.data.map { preferences ->
        preferences.token
    }
    
    // and so on
}

And as a bonus, the code that already accesses your DataStore should not need any additional adaptations, so it becomes a plug-in change.