Skip to content

ArthurKun21/generic-datastore

Repository files navigation

Generic Datastore Library

Release GitHub Packages build Tests

A Kotlin Multiplatform library that provides a thin, convenient wrapper around AndroidX DataStore Preferences and Proto DataStore.

Inspired by flow-preferences for SharedPreferences.

Modules

Module Description
generic-datastore Core library with Preferences DataStore and Proto DataStore wrappers
generic-datastore-compose Jetpack Compose extensions (remember(), rememberPreferences(), rememberBatchRead(), LocalPreferencesDatastore)

KMP Targets

Both modules target Android, Desktop (JVM), and iOS (iosX64, iosArm64, iosSimulatorArm64).

Installation

JitPack

Add the JitPack repository to your settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        maven("https://jitpack.io")
    }
}

Add the dependencies:

dependencies {
    implementation("com.github.arthurkun:generic-datastore:<version>")

    // Optional: Compose extensions
    implementation("com.github.arthurkun:generic-datastore-compose:<version>")
}

GitHub Packages

Add the GitHub Packages repository to your settings.gradle.kts. Authentication requires a GitHub personal access token (classic) with the read:packages scope:

dependencyResolutionManagement {
    repositories {
        maven {
            url = uri("https://maven.pkg.github.com/ArthurKun21/generic-datastore")
            credentials {
                username = providers.gradleProperty("gpr.user").orNull
                    ?: System.getenv("GITHUB_ACTOR")
                password = providers.gradleProperty("gpr.key").orNull
                    ?: System.getenv("GITHUB_TOKEN")
            }
        }
    }
}

Set the credentials in your ~/.gradle/gradle.properties (or use the environment variables GITHUB_ACTOR and GITHUB_TOKEN):

gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_GITHUB_TOKEN

Add the dependencies:

dependencies {
    implementation("com.github.ArthurKun21:generic-datastore:<version>")

    // Optional: Compose extensions
    implementation("com.github.ArthurKun21:generic-datastore-compose:<version>")
}

Preferences DataStore

Setup Preference DataStore

Use the createPreferencesDatastore factory function to create a GenericPreferencesDatastore:

val datastore = createPreferencesDatastore(
    producePath = { context.filesDir.resolve("settings.preferences_pb").absolutePath },
)

A fileName overload is available that appends the file name to a directory path:

val datastore = createPreferencesDatastore(
    fileName = "settings.preferences_pb",
    producePath = { context.filesDir.absolutePath },
)

Optional parameters allow customizing the corruption handler, migrations, coroutine scope, and the default Json instance used for serialization-based preferences:

val datastore = createPreferencesDatastore(
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
    migrations = listOf(myMigration),
    scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    defaultJson = Json { prettyPrint = true },
    producePath = { context.filesDir.resolve("settings.preferences_pb").absolutePath },
)

Overloads accepting okio.Path and kotlinx.io.files.Path are also available:

val datastore = createPreferencesDatastore(
    produceOkioPath = { "/data/settings.preferences_pb".toPath() },
)

val datastore = createPreferencesDatastore(
    produceKotlinxIoPath = { Path("/data/settings.preferences_pb") },
)

Alternatively, wrap an existing DataStore<Preferences> directly:

val datastore = GenericPreferencesDatastore(myExistingDataStore)

Defining Preferences

The GenericPreferencesDatastore provides factory methods for all supported types:

val userName: Preference<String> = datastore.string("user_name", "Guest")
val userScore: Preference<Int> = datastore.int("user_score", 0)
val highScore: Preference<Long> = datastore.long("high_score", 0L)
val volume: Preference<Float> = datastore.float("volume", 1.0f)
val precision: Preference<Double> = datastore.double("precision", 0.0)
val darkMode: Preference<Boolean> = datastore.bool("dark_mode", false)
val tags: Preference<Set<String>> = datastore.stringSet("tags")

Enum Preferences

Store enum values directly using the enum() extension:

enum class Theme { LIGHT, DARK, SYSTEM }

val themePref: Preference<Theme> = datastore.enum("theme", Theme.SYSTEM)

Custom Serialized Objects

Store any object by providing serializer/deserializer functions:

@Serializable
data class UserProfile(val id: Int, val email: String)

val userProfilePref: Preference<UserProfile> = datastore.serialized(
    key = "user_profile",
    defaultValue = UserProfile(0, ""),
    serializer = { Json.encodeToString(UserProfile.serializer(), it) },
    deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
)

Or with a sealed class:

sealed class Animal(val name: String) {
    data object Dog : Animal("Dog")
    data object Cat : Animal("Cat")

    companion object {
        fun from(value: String): Animal = when (value) {
            "Dog" -> Dog
            "Cat" -> Cat
            else -> throw Exception("Unknown animal type: $value")
        }
        fun to(animal: Animal): String = animal.name
    }
}

val animalPref = datastore.serialized(
    key = "animal",
    defaultValue = Animal.Dog,
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
)

Kotlin Serialization (kserialized)

Store any @Serializable type directly without manual serializer/deserializer functions:

@Serializable
data class UserProfile(val name: String, val age: Int)

val userProfilePref: Preference<UserProfile> = datastore.kserialized(
    key = "user_profile",
    defaultValue = UserProfile(name = "John", age = 25),
)

A custom Json instance can be provided if needed:

val customJson = Json { prettyPrint = true }

val userProfilePref: Preference<UserProfile> = datastore.kserialized(
    key = "user_profile",
    defaultValue = UserProfile(name = "John", age = 25),
    json = customJson,
)

Serialized Set

Store a Set of custom objects using per-element serialization with serializedSet():

val animalSetPref: Preference<Set<Animal>> = datastore.serializedSet(
    key = "animal_set",
    defaultValue = emptySet(),
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
)

Each element is individually serialized to a String and stored using a stringSetPreferencesKey. Elements that fail deserialization are silently skipped.

Kotlin Serialization Set (kserializedSet)

Store a Set of @Serializable objects without manual serializer/deserializer functions:

@Serializable
data class UserProfile(val name: String, val age: Int)

val profileSetPref: Preference<Set<UserProfile>> = datastore.kserializedSet(
    key = "profile_set",
    defaultValue = emptySet(),
)

Each element is individually serialized to JSON and stored using a stringSetPreferencesKey. Elements that fail deserialization are silently skipped. A custom Json instance can be provided if needed.

Serialized List

Store a List of custom objects using per-element serialization with serializedList():

val animalListPref: Preference<List<Animal>> = datastore.serializedList(
    key = "animal_list",
    defaultValue = emptyList(),
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
)

Each element is individually serialized to a String and stored as a JSON array string using a stringPreferencesKey. Elements that fail deserialization are silently skipped. Unlike sets, lists preserve insertion order and allow duplicates.

Kotlin Serialization List (kserializedList)

Store a List of @Serializable objects without manual serializer/deserializer functions:

@Serializable
data class UserProfile(val name: String, val age: Int)

val profileListPref: Preference<List<UserProfile>> = datastore.kserializedList(
    key = "profile_list",
    defaultValue = emptyList(),
)

The list is serialized to a JSON array string and stored using a stringPreferencesKey. If deserialization fails (e.g., due to corrupted data), the default value is returned. A custom Json instance can be provided if needed.

Enum Set

Store a Set of enum values with the enumSet() extension:

val themeSetPref: Preference<Set<Theme>> = datastore.enumSet<Theme>(
    key = "theme_set",
    defaultValue = emptySet(),
)

Each enum value is stored by its name. Unknown enum values encountered during deserialization are skipped.

Nullable Preferences

Create preferences that return null when no value has been set, instead of a default value:

val nickname: Preference<String?> = datastore.nullableString("nickname")
val age: Preference<Int?> = datastore.nullableInt("age")
val timestamp: Preference<Long?> = datastore.nullableLong("timestamp")
val weight: Preference<Float?> = datastore.nullableFloat("weight")
val latitude: Preference<Double?> = datastore.nullableDouble("latitude")
val agreed: Preference<Boolean?> = datastore.nullableBool("agreed")
val labels: Preference<Set<String>?> = datastore.nullableStringSet("labels")

Setting a nullable preference to null removes the key from DataStore. resetToDefault() also removes the key, since the default is null.

nickname.get()        // null (not set yet)
nickname.set("Alice") // stores "Alice"
nickname.get()        // "Alice"
nickname.set(null)    // removes the key
nickname.get()        // null

Nullable Enum

Store an enum value that returns null when not set:

val themePref: Preference<Theme?> = datastore.nullableEnum<Theme>("theme")

Nullable Custom Serialized Objects

Store a nullable custom-serialized object:

val animalPref: Preference<Animal?> = datastore.nullableSerialized(
    key = "animal",
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
)

Nullable Kotlin Serialization (nullableKserialized)

Store a nullable @Serializable type:

@Serializable
data class UserProfile(val name: String, val age: Int)

val userProfilePref: Preference<UserProfile?> =
    datastore.nullableKserialized<UserProfile>("user_profile")

Nullable Serialized List (nullableSerializedList)

Store a nullable list of custom-serialized objects:

val animalListPref: Preference<List<Animal>?> = datastore.nullableSerializedList(
    key = "animal_list",
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
)

Nullable Kotlin Serialization List (nullableKserializedList)

Store a nullable list of @Serializable objects:

val profileListPref: Preference<List<UserProfile>?> =
    datastore.nullableKserializedList<UserProfile>(
        key = "profile_list",
    )

All nullable variants return null when the key is not set. Setting null removes the key. If deserialization fails, null is returned.

Reading & Writing Values

Each Preference<T> provides multiple access patterns:

Suspend Functions

CoroutineScope(Dispatchers.IO).launch {
    val name = userName.get()
    userName.set("John Doe")
    userName.delete()
}

Flow-based Observation

userName.asFlow().collect { value -> /* react to changes */ }

val nameState: StateFlow<String> = userName.stateIn(viewModelScope)

Blocking Access

// Blocks the calling thread — avoid on the main/UI thread
val name = userName.getBlocking()
userName.setBlocking("John Doe")

Property Delegation

DelegatedPreference<T> implements ReadWriteProperty, so you can use it as a delegated property:

var currentUserName: String by userName

// Read (blocking)
println(currentUserName)

// Write (blocking)
currentUserName = "Jane Doe"

Atomic Update

Atomically read-modify-write a preference value in a single DataStore transaction:

userScore.update { current -> current + 1 }

Toggle

Flip a Boolean preference:

darkMode.toggle()

Toggle an item in a Set preference (adds if absent, removes if present):

tags.toggle("kotlin")

Reset to Default

// Suspend
userName.resetToDefault()

// Blocking — avoid on the main/UI thread
userName.resetToDefaultBlocking()

Key Access

Retrieve the underlying DataStore key name:

val key: String = userName.key()

Batch Operations

Batch operations let you read or write multiple preferences in a single DataStore transaction, avoiding redundant I/O and ensuring atomicity.

Batch Read

Read multiple preferences from a single snapshot:

class SettingsViewModel(
    private val datastore: GenericPreferencesDatastore,
) : ViewModel() {

    private val userName = datastore.string("user_name", "Guest")
    private val darkMode = datastore.bool("dark_mode", false)
    private val volume = datastore.float("volume", 1.0f)

    fun loadSettings() {
        viewModelScope.launch {
            val (name, isDark, vol) = datastore.batchGet {
                Triple(get[userName], get[darkMode], get[volume])
            }
        }
    }
}

Batch Read Flow

Observe multiple preferences reactively from the same snapshot. The flow re-emits whenever any preference in the datastore changes:

class SettingsViewModel(
    private val datastore: GenericPreferencesDatastore,
) : ViewModel() {

    private val userName = datastore.string("user_name", "Guest")
    private val darkMode = datastore.bool("dark_mode", false)

    val settingsFlow: Flow<Pair<String, Boolean>> = datastore
        .batchReadFlow{
            get(userName) to get(darkMode)
        }
        .distinctUntilChanged()
}

Batch Write

Write multiple preferences in a single atomic transaction:

class SettingsViewModel(
    private val datastore: GenericPreferencesDatastore,
) : ViewModel() {

    private val userName = datastore.string("user_name", "Guest")
    private val darkMode = datastore.bool("dark_mode", false)
    private val volume = datastore.float("volume", 1.0f)

    fun resetSettings() {
        viewModelScope.launch {
            datastore.batchWrite {
                set(userName, "Guest")
                set(darkMode, false)
                set(volume, 1.0f)
            }
        }
    }
}

Batch Update

Atomically read current values and write new values in a single transaction, guaranteeing consistency when new values depend on current ones:

class GameViewModel(
    private val datastore: GenericPreferencesDatastore,
) : ViewModel() {

    private val userScore = datastore.int("user_score", 0)
    private val highScore = datastore.long("high_score", 0L)

    fun submitScore(newScore: Int) {
        viewModelScope.launch {
            datastore.batchUpdate {
                set(userScore, newScore)
                val currentHigh = get(highScore)
                if (newScore > currentHigh) {
                    set(highScore, newScore.toLong())
                }
            }
        }
    }
}

The BatchUpdateScope also provides update, delete, and resetToDefault helpers:

datastore.batchUpdate {
    update(userScore) { current -> current + 10 }
    delete(nickname)
    resetToDefault(volume)
}

Blocking Batch Operations

Blocking variants are available for non-coroutine contexts. Avoid calling these on the main/UI thread:

val (name, isDark) = datastore.batchGetBlocking {
    get[userName] to get[darkMode]
}

datastore.batchWriteBlocking {
    set(userName, "Guest")
    set(darkMode, false)
}

datastore.batchUpdateBlocking {
    update(userScore) { current -> current + 1 }
}

Mapped Preferences

Transform a Preference<T> into a Preference<R> with converter functions:

val scoreAsString: Preference<String> = userScore.map(
    defaultValue = "0",
    convert = { it.toString() },
    reverse = { it.toIntOrNull() ?: 0 },
)

// Or infer the default value from the original preference's default:
val scoreAsString2: Preference<String> = userScore.mapIO(
    convert = { it.toString() },
    reverse = { it.toInt() },
)

map catches exceptions in conversions and falls back to defaults. mapIO throws if conversion of the default value fails.

Backup & Restore

GenericPreferencesDatastore supports exporting and importing all preferences using type-safe backup models:

Export as structured data

val backup: PreferencesBackup = datastore.exportAsData(
    exportPrivate = false,
    exportAppState = false,
)

Export as JSON string

val jsonString: String = datastore.exportAsString(
    exportPrivate = false,
    exportAppState = false,
)

A custom Json instance can be provided if needed:

val jsonString: String = datastore.exportAsString(
    exportPrivate = false,
    exportAppState = false,
    json = customJson,
)

Import from structured data

datastore.importData(
    backup = backup,
    importPrivate = false,
    importAppState = false,
)

Import from JSON string

datastore.importDataAsString(
    backupString = jsonString,
    importPrivate = false,
    importAppState = false,
)

Import merges into existing preferences. Call datastore.clearAll() before import for full replace semantics. A BackupParsingException is thrown if the JSON string is invalid or exceeds the 10 MB size limit.

Private & App-State Key Prefixes

Use BasePreference.privateKey(key) or BasePreference.appStateKey(key) to prefix keys so they can be filtered during backup:

val token = datastore.string(BasePreference.privateKey("auth_token"), "")
val onboarded = datastore.bool(BasePreference.appStateKey("onboarding_done"), false)

Proto DataStore

Setup Proto DataStore

Use the createProtoDatastore factory function to create a GenericProtoDatastore:

val protoDatastore = createProtoDatastore(
    serializer = MyProtoSerializer,
    defaultValue = MyProtoMessage.getDefaultInstance(),
    producePath = { context.filesDir.resolve("my_proto.pb").absolutePath },
)

A fileName overload is available that appends the file name to a directory path:

val protoDatastore = createProtoDatastore(
    serializer = MyProtoSerializer,
    defaultValue = MyProtoMessage.getDefaultInstance(),
    fileName = "my_proto.pb",
    producePath = { context.filesDir.absolutePath },
)

Optional parameters allow customizing the key, corruption handler, migrations, coroutine scope, and the default Json instance used for serialization-based field preferences:

val protoDatastore = createProtoDatastore(
    serializer = MyProtoSerializer,
    defaultValue = MyProtoMessage.getDefaultInstance(),
    key = "my_proto",
    corruptionHandler = ReplaceFileCorruptionHandler { MyProtoMessage.getDefaultInstance() },
    migrations = listOf(myMigration),
    scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
    defaultJson = Json { prettyPrint = true },
    producePath = { context.filesDir.resolve("my_proto.pb").absolutePath },
)

Overloads accepting okio.Path and kotlinx.io.files.Path are also available:

val protoDatastore = createProtoDatastore(
    serializer = MyProtoSerializer,
    defaultValue = MyProtoMessage.getDefaultInstance(),
    produceOkioPath = { "/data/my_proto.pb".toPath() },
)

val protoDatastore = createProtoDatastore(
    serializer = MyProtoSerializer,
    defaultValue = MyProtoMessage.getDefaultInstance(),
    produceKotlinxIoPath = { Path("/data/my_proto.pb") },
)

Alternatively, wrap an existing DataStore<T> directly:

val protoDatastore = GenericProtoDatastore(
    datastore = myExistingProtoDataStore,
    defaultValue = MyProtoMessage.getDefaultInstance(),
)

Usage

Whole-Object Access

val dataPref: ProtoPreference<MyProtoMessage> = protoDatastore.data()

// Then use get(), set(), asFlow(), etc. just like Preferences DataStore

Per-Field Access

Use field() to create a preference for an individual field of the proto message. The getter extracts the field value from a snapshot, and updater returns a new proto with the field updated:

val namePref: ProtoPreference<String> = protoDatastore.field(
    defaultValue = "",
    getter = { it.name },
    updater = { proto, value -> proto.copy(name = value) },
)

// Suspend
namePref.set("Alice")
val name = namePref.get() // "Alice"

// Flow
namePref.asFlow().collect { value -> /* react to changes */ }

// Blocking
namePref.setBlocking("Bob")
val blocking = namePref.getBlocking() // "Bob"

// Delegation
var userName: String by namePref

For nested fields, chain copy() calls in the updater:

data class Address(val street: String = "", val city: String = "")
data class Profile(val nickname: String = "", val address: Address = Address())
data class Settings(val id: Int = 0, val profile: Profile = Profile())

// Access a deeply nested field
val cityPref: ProtoPreference<String> = protoDatastore.field(
    defaultValue = "",
    getter = { it.profile.address.city },
    updater = { proto, value ->
        proto.copy(
            profile = proto.profile.copy(
                address = proto.profile.address.copy(city = value),
            ),
        )
    },
)

For nullable nested fields, provide fallback defaults in the updater:

data class NullableProfile(val nickname: String = "", val age: Int? = null)
data class NullableSettings(val id: Int = 0, val profile: NullableProfile? = null)

val agePref: ProtoPreference<Int?> = protoDatastore.field(
    defaultValue = null,
    getter = { it.profile?.age },
    updater = { proto, value ->
        proto.copy(
            profile = (proto.profile ?: NullableProfile()).copy(age = value),
        )
    },
)

Field preferences share the same underlying DataStore, so changes through field() are visible via data() and vice versa. delete() and resetToDefault() on a field reset only the targeted field to its default value (via set(defaultValue)updater(current, fieldDefault)). It does not reset the entire proto to its default.

Enum Field

Store an enum value in a proto String field using enumField():

enum class Theme { LIGHT, DARK, SYSTEM }

val themePref: ProtoPreference<Theme> = protoDatastore.enumField(
    defaultValue = Theme.SYSTEM,
    getter = { it.theme },
    updater = { proto, value -> proto.copy(theme = value) },
)

The reified overload infers enumValues automatically. Unknown enum names encountered during deserialization fall back to the default value.

Nullable Enum Field

Store a nullable enum field using nullableEnumField():

val themePref: ProtoPreference<Theme?> = protoDatastore.nullableEnumField<Settings, Theme>(
    getter = { it.theme },
    updater = { proto, value -> proto.copy(theme = value) },
)

Returns null when the proto field is null. Unknown enum names also return null.

Enum Set Field

Store a Set of enum values backed by a Set<String> proto field:

val themesPref: ProtoPreference<Set<Theme>> = protoDatastore.enumSetField<Settings, Theme>(
    defaultValue = emptySet(),
    getter = { it.themes },
    updater = { proto, value -> proto.copy(themes = value) },
)

Each enum value is stored by its name. Unknown enum names are skipped during deserialization.

Serialized Field

Store a custom-serialized object in a proto String field:

@Serializable
data class UserProfile(val id: Int, val email: String)

val profilePref: ProtoPreference<UserProfile> = protoDatastore.serializedField(
    defaultValue = UserProfile(0, ""),
    serializer = { Json.encodeToString(UserProfile.serializer(), it) },
    deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
    getter = { it.profileJson },
    updater = { proto, value -> proto.copy(profileJson = value) },
)

If the raw string is blank or deserialization fails, the default value is returned.

Nullable Serialized Field

Store a nullable custom-serialized object:

val profilePref: ProtoPreference<UserProfile?> = protoDatastore.nullableSerializedField(
    serializer = { Json.encodeToString(UserProfile.serializer(), it) },
    deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
    getter = { it.profileJson },
    updater = { proto, value -> proto.copy(profileJson = value) },
)

Returns null when the proto field is null. Setting null writes null to the proto field.

Kotlin Serialization Field (kserializedField)

Store any @Serializable type in a proto String field using Kotlin Serialization directly:

val profilePref: ProtoPreference<UserProfile> = protoDatastore.kserializedField(
    defaultValue = UserProfile(0, ""),
    getter = { it.profileJson },
    updater = { proto, value -> proto.copy(profileJson = value) },
)

The reified overload infers KSerializer automatically. A custom Json instance can be provided if needed.

Nullable Kotlin Serialization Field (nullableKserializedField)

Store a nullable @Serializable type:

val profilePref: ProtoPreference<UserProfile?> =
    protoDatastore.nullableKserializedField<Settings, UserProfile>(
        getter = { it.profileJson },
        updater = { proto, value -> proto.copy(profileJson = value) },
    )

Serialized List Field

Store a List of custom objects using per-element serialization in a proto String field:

val animalListPref: ProtoPreference<List<Animal>> = protoDatastore.serializedListField(
    defaultValue = emptyList(),
    elementSerializer = { Animal.to(it) },
    elementDeserializer = { Animal.from(it) },
    getter = { it.animalsJson },
    updater = { proto, value -> proto.copy(animalsJson = value) },
)

Each element is individually serialized and stored as a JSON array string.

Nullable Serialized List Field

Store a nullable list of custom-serialized objects:

val animalListPref: ProtoPreference<List<Animal>?> = protoDatastore.nullableSerializedListField(
    elementSerializer = { Animal.to(it) },
    elementDeserializer = { Animal.from(it) },
    getter = { it.animalsJson },
    updater = { proto, value -> proto.copy(animalsJson = value) },
)

Kotlin Serialization List Field (kserializedListField)

Store a List of @Serializable objects using Kotlin Serialization:

val profileListPref: ProtoPreference<List<UserProfile>> = protoDatastore.kserializedListField(
    defaultValue = emptyList(),
    getter = { it.profilesJson },
    updater = { proto, value -> proto.copy(profilesJson = value) },
)

Nullable Kotlin Serialization List Field (nullableKserializedListField)

Store a nullable list of @Serializable objects:

val profileListPref: ProtoPreference<List<UserProfile>?> =
    protoDatastore.nullableKserializedListField<Settings, UserProfile>(
        getter = { it.profilesJson },
        updater = { proto, value -> proto.copy(profilesJson = value) },
    )

Serialized Set Field

Store a Set of custom objects using per-element serialization backed by a Set<String> proto field:

val animalSetPref: ProtoPreference<Set<Animal>> = protoDatastore.serializedSetField(
    defaultValue = emptySet(),
    serializer = { Animal.to(it) },
    deserializer = { Animal.from(it) },
    getter = { it.animalNames },
    updater = { proto, value -> proto.copy(animalNames = value) },
)

Elements that fail deserialization are silently skipped.

Kotlin Serialization Set Field (kserializedSetField)

Store a Set of @Serializable objects with per-element JSON serialization backed by a Set<String> proto field:

val profileSetPref: ProtoPreference<Set<UserProfile>> = protoDatastore.kserializedSetField(
    defaultValue = emptySet(),
    getter = { it.profileNames },
    updater = { proto, value -> proto.copy(profileNames = value) },
)

Elements that fail deserialization are silently skipped. A custom Json instance can be provided if needed.

Compose Extensions (generic-datastore-compose)

remember()

The remember() extension turns any DelegatedPreference<T> into a lifecycle-aware MutableState<T>:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

@Composable
fun SettingsScreen(datastore: GenericPreferencesDatastore) {
    var userName by datastore.string("user_name", "Guest").remember()

    Column {
        Text("Current User: $userName")
        OutlinedTextField(
            value = userName,
            onValueChange = { userName = it },
            label = { Text("Enter username") },
        )
    }
}

Under the hood, remember() uses collectAsStateWithLifecycle for lifecycle-safe collection for Android while it uses collectAsState() for Desktop/JVM. It launches a coroutine for writes. An optimistic local override is applied immediately so that synchronous UI inputs reflect the new value without waiting for the DataStore round-trip.

rememberBatchRead()

Collects a batchReadFlow as a Compose State, reading multiple preferences from a single DataStore snapshot:

@Composable
fun SettingsScreen(datastore: GenericPreferencesDatastore) {
    val userName = datastore.string("user_name", "Guest")
    val darkMode = datastore.bool("dark_mode", false)

    val settings by datastore.rememberBatchRead {
        get(userName) to get(darkMode)
    }

    settings?.let { (name, isDark) ->
        Text("User: $name, Dark mode: $isDark")
    }
}

The returned State is null until the first snapshot is available.

rememberPreferences()

Remembers multiple preferences (2–5) as individual MutableState values, reading from a shared batchReadFlow snapshot and writing via batchWrite. All reads share a single DataStore observation, and writes are launched asynchronously with an optimistic local override:

@Composable
fun SettingsScreen(datastore: GenericPreferencesDatastore) {
    val userName = datastore.string("user_name", "Guest")
    val darkMode = datastore.bool("dark_mode", false)
    val volume = datastore.float("volume", 1.0f)

    val (name, isDark, vol) = datastore.rememberPreferences(userName, darkMode, volume)

    Column {
        var nameValue by name
        OutlinedTextField(
            value = nameValue,
            onValueChange = { nameValue = it },
            label = { Text("Username") },
        )

        var isDarkValue by isDark
        Switch(checked = isDarkValue, onCheckedChange = { isDarkValue = it })
    }
}

Overloads are available for 2, 3, 4, and 5 preferences. The returned PreferencesStateN also supports property delegation:

val prefs by datastore.rememberPreferences(userName, darkMode)
Text("User: ${prefs.first}, Dark: ${prefs.second}")
prefs.first = "New Name" // triggers an async write

ProvidePreferencesDatastore and LocalPreferencesDatastore

Use ProvidePreferencesDatastore to supply a PreferencesDatastore via CompositionLocal, then call the standalone rememberPreferences overloads without an explicit datastore receiver:

@Composable
fun App(datastore: GenericPreferencesDatastore) {
    ProvidePreferencesDatastore(datastore) {
        SettingsScreen()
    }
}

@Composable
fun SettingsScreen() {
    val userName = LocalPreferencesDatastore.current.string("user_name", "Guest")
    val darkMode = LocalPreferencesDatastore.current.bool("dark_mode", false)

    val (name, isDark) = rememberPreferences(userName, darkMode)

    var nameValue by name
    var isDarkValue by isDark

    Column {
        OutlinedTextField(
            value = nameValue,
            onValueChange = { nameValue = it },
            label = { Text("Username") },
        )
        Switch(checked = isDarkValue, onCheckedChange = { isDarkValue = it })
    }
}

Accessing LocalPreferencesDatastore without a provider throws an IllegalStateException.

License

Apache License 2.0

About

This library aims to reduce boilerplate code when working with Jetpack DataStore for common preference types and custom objects.

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors

Languages