Skip to content

Latest commit

 

History

History
1395 lines (1069 loc) · 43.1 KB

File metadata and controls

1395 lines (1069 loc) · 43.1 KB

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 Published umbrella artifact that depends on generic-datastore-preferences and generic-datastore-proto
generic-datastore-preferences Published Preferences DataStore wrapper module
generic-datastore-proto Published Proto DataStore wrapper module
generic-datastore-core Shared internal core module used by the feature modules
generic-datastore-compose Published Compose helpers built on top of core and preferences

KMP Targets

The library modules target Android, Desktop (JVM), and iOS (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.ArthurKun21:generic-datastore:<version>")

    // Or choose a feature module directly
    implementation("com.github.ArthurKun21:generic-datastore-preferences:<version>")
    implementation("com.github.ArthurKun21:generic-datastore-proto:<version>")
    implementation("com.github.ArthurKun21: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>")

    // Or choose a feature module directly
    implementation("com.github.ArthurKun21:generic-datastore-preferences:<version>")
    implementation("com.github.ArthurKun21:generic-datastore-proto:<version>")
    implementation("com.github.ArthurKun21:generic-datastore-compose:<version>")
}

Preferences DataStore

Setup Preference DataStore

Use the createPreferencesDatastore factory function to create a PreferencesDatastore:

val datastore: PreferencesDatastore = 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(SupervisorJob() + IO),
    defaultJson = Json { prettyPrint = true },
    producePath = { context.filesDir.resolve("settings.preferences_pb").absolutePath },
)

createPreferencesDatastore creates a DataStore scope owned by the returned datastore wrapper. Call datastore.close() when the datastore is no longer needed; if you pass scope, it is used as a parent lifecycle and is not cancelled by close().

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. This bypasses the factory lifecycle wiring and requires opting in to InternalGenericDatastoreApi:

@OptIn(InternalGenericDatastoreApi::class)
val datastore = GenericPreferencesDatastore(myExistingDataStore)

GenericPreferenceDatastore is a deprecated compatibility typealias for GenericPreferencesDatastore. Prefer PreferencesDatastore for public API boundaries and GenericPreferencesDatastore only when directly wiring an existing DataStore<Preferences>.

Defining Preferences

The PreferencesDatastore 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")

Current Preferences API surface:

Category APIs
Lifecycle close()
Primitives string, long, int, float, double, bool, stringSet
Lists stringList, nullableStringList
Nullable primitives nullableString, nullableStringSet, nullableInt, nullableLong, nullableFloat, nullableDouble, nullableBool
Custom values serialized, serializedSet, serializedList, nullableSerialized, nullableSerializedList
Kotlin Serialization kserialized, kserializedSet, kserializedList, nullableKserialized, nullableKserializedList
Enum helpers enum, enumSet, nullableEnum
Reads and writes get, set, update, delete, resetToDefault, asFlow, stateIn, stateInCurrent, blocking variants, property delegation
Batch operations batchReadValues/batchReadFlowValues (+ batchRead/batchReadFlow projections), batchWrite, batchUpdate, batchDelete, blocking variants
Backup and restore exportAsData, exportAsString, importData, importDataAsString, clearAll
Utilities map, mapIO, toggle, toJsonElement, toJsonMap, BasePreference.privateKey, BasePreference.appStateKey
Deprecated compatibility GenericPreferenceDatastore, export, import

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.

Decode Failure Policy

Serializer-backed preferences are intentionally lenient. A non-null serialized preference returns its defaultValue when the stored payload cannot be decoded. Nullable serialized preferences return null. Set and list variants skip individual elements that fail to decode when the outer collection can still be parsed.

This keeps reads resilient after app downgrades, partial migrations, or user-edited backup data. If decode failures must be surfaced, validate the serialized payload in your serializer or migration code before exposing it as a preference value.

Reading & Writing Values

Each Preference<T> provides multiple access patterns:

Suspend Functions

CoroutineScope(SupervisorJob() + 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)

// Suspends once to initialize StateFlow.value with the persisted value.
val currentNameState: StateFlow<String> = userName.stateInCurrent(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 multiple preferences from one DataStore snapshot or write, update, or delete multiple preferences in one DataStore transaction, avoiding redundant I/O and keeping related values consistent.

Every batch operation takes an inline declaration block directly on the datastore. Inside the block you either reuse existing preferences via add(pref) or declare from scratch with string(…), int(…), … — both have the same effect:

val text = datastore.string("text", defaultValue = "Hello World!")
val num = datastore.int("num", defaultValue = 0)

// Reuse existing preferences
val values = datastore.batchReadValues {
    add(text)
    add(num)
}

// …or declare from scratch, without pre-built variables
val sameValues = datastore.batchReadValues {
    string("text", defaultValue = "Hello World!")
    int("num", defaultValue = 0)
}

Migrating from the scope-based API: batch operations previously took Preference<T> instances inside BatchReadScope/BatchWriteScope/BatchUpdateScope blocks (e.g. datastore.batchRead { get(myPref) }). That API is removed. Declare inline instead:

// Before
val name = datastore.batchRead { get(namePref) }
datastore.batchWrite { set(namePref, "Ada") }

// After
val values = datastore.batchReadValues { add(namePref) }
val name: String = values[nameHandle]
datastore.batchWrite { set(nameHandle, "Ada") }

Existing library-created Preference objects join a batch via add(pref) — capture its returned BatchPref<T> handle for typed access.

Declaring a Batch

Each declaration function creates, registers, and returns a typed BatchPref<T> handle — capture the returned handles for typed snapshot access:

class SettingsStore(
    private val datastore: PreferencesDatastore,
) {
    val userNamePref = datastore.string("user_name", "Guest")
    val darkModePref = datastore.bool("dark_mode", false)
}

Declaration functions exist for every preference type: int, long, float, double, bool, string, stringSet, stringList, their nullable* variants, serialized, serializedSet, serializedList, kserialized, kserializedSet, kserializedList (with reified overloads that infer the KSerializer), their nullable custom variants, and enum, enumSet, nullableEnum. Keys must be unique and non-blank inside a batch (IllegalArgumentException otherwise).

A previously declared BatchPref handle can be re-registered with add(handle). Handles compare by value (key + default + storage kind), so an equal declaration reads the same entry.

Batch Read

batchReadValues maps every declared preference to its stored value (or default) in one DataStore snapshot. Read values with the typed indexing operator and the captured handles, with toMap() / toList(), or by destructuring positionally in declaration order:

class SettingsViewModel(
    private val datastore: PreferencesDatastore,
    private val store: SettingsStore,
) : ViewModel() {

    fun loadSettings() {
        viewModelScope.launch {
            var nameH: BatchPref<String>? = null
            var darkH: BatchPref<Boolean>? = null
            val values = datastore.batchReadValues {
                nameH = add(store.userNamePref)
                darkH = add(store.darkModePref)
            }
            val name: String = values[requireNotNull(nameH)]
            val isDark: Boolean = values[requireNotNull(darkH)]

            // …or destructure (order-sensitive, same types as declared):
            val (plainName: String, plainDark: Boolean) = values
        }
    }
}

batchRead takes a second block to derive a projection from the whole snapshot (batchRead is batchReadFlow(…).first()):

var userNameH: BatchPref<String>? = null
var darkModeH: BatchPref<Boolean>? = null
val isConfigured = datastore.batchRead(
    declare = {
        userNameH = add(store.userNamePref)
        darkModeH = add(store.darkModePref)
    },
) {
    this[requireNotNull(userNameH)].isNotEmpty() || this[requireNotNull(darkModeH)]
}

Batch Read Flow

Observe the batch reactively. The flow re-emits whenever any preference in the datastore changes, mapping the whole batch in a single emission. BatchValues implements equals, so distinctUntilChanged can be applied either to the whole snapshot or to a derived projection:

class SettingsViewModel(
    private val datastore: PreferencesDatastore,
    private val store: SettingsStore,
) : ViewModel() {

    // Handles cross from the declare block to the projection via captured vars.
    var userNameH: BatchPref<String>? = null
    var darkModeH: BatchPref<Boolean>? = null

    val settingsFlow: Flow<Pair<String, Boolean>> = datastore.batchReadFlow(
        distinctUntilChanged = true,
        declare = {
            userNameH = add(store.userNamePref)
            darkModeH = add(store.darkModePref)
        },
    ) {
        this[requireNotNull(userNameH)] to this[requireNotNull(darkModeH)]
    }

    val snapshotFlow: Flow<BatchValues> = datastore
        .batchReadFlowValues(distinctUntilChanged = true) {
            add(store.userNamePref)
            add(store.darkModePref)
        }
}

Batch Write

The write scope both declares preferences and writes them in a single atomic transaction:

class SettingsViewModel(
    private val datastore: PreferencesDatastore,
    private val store: SettingsStore,
) : ViewModel() {

    fun resetSettings() {
        viewModelScope.launch {
            datastore.batchWrite {
                val name = add(store.userNamePref)
                val dark = add(store.darkModePref)
                set(name, "Guest")
                set(dark, false)
                // the index operator is equivalent: this[name] = "Guest"
            }
        }
    }
}

Batch membership is not enforced, so batchWrite { set(handle, value) } also works without re-declaring. Writing null to a nullable declaration removes its key. The write scope also provides delete(pref) and resetToDefault(pref).

Batch Delete

Remove the key of every declared preference in one transaction. Afterwards, reads return each preference's default:

datastore.batchDelete {
    add(store.userNamePref)
    add(store.darkModePref)
}

Batch Update

The update scope both declares preferences and atomically reads/writes them in a single transaction, guaranteeing consistency when new values depend on current ones. Reads observe writes made earlier in the same block:

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

    fun submitScore(newScore: Int) {
        viewModelScope.launch {
            datastore.batchUpdate {
                val userScore = int("user_score", 0)
                val highScore = long("high_score", 0L)
                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 {
    val volume = add(volumePref)
    val nickname = add(nicknamePref)
    update(volume) { 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:

var blockingNameH: BatchPref<String>? = null
val values = datastore.batchReadBlockingValues {
    blockingNameH = add(store.userNamePref)
}
val name = values[requireNotNull(blockingNameH)]

datastore.batchWriteBlocking {
    set(requireNotNull(blockingNameH), "Guest")
}

datastore.batchUpdateBlocking {
    val score = int("user_score", 0)
    update(score) { current -> current + 1 }
}

datastore.batchDeleteBlocking { add(store.userNamePref) }

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

PreferencesDatastore 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.

The older export() and import() APIs remain available for source compatibility, but are deprecated. Prefer exportAsData / exportAsString and importData / importDataAsString.

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 ProtoDatastore:

val protoDatastore: ProtoDatastore<MyProtoMessage> = 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(SupervisorJob() + IO),
    defaultJson = Json { prettyPrint = true },
    producePath = { context.filesDir.resolve("my_proto.pb").absolutePath },
)

createProtoDatastore creates a DataStore scope owned by the returned datastore wrapper. Call protoDatastore.close() when the datastore is no longer needed; if you pass scope, it is used as a parent lifecycle and is not cancelled by close().

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. This bypasses the factory lifecycle wiring and requires opting in to InternalGenericDatastoreApi:

@OptIn(InternalGenericDatastoreApi::class)
val protoDatastore = GenericProtoDatastore(
    datastore = myExistingProtoDataStore,
    defaultValue = MyProtoMessage.getDefaultInstance(),
)

Usage

Current Proto API surface:

Category APIs
Lifecycle close()
Byte backup exportAsByteArray, importFromByteArray
Whole message data()
Raw fields field()
Enum fields enumField, nullableEnumField, enumSetField
Caller-serialized fields serializedField, nullableSerializedField, serializedListField, nullableSerializedListField, serializedSetField
Kotlin Serialization fields kserializedField, nullableKserializedField, kserializedListField, nullableKserializedListField, kserializedSetField
Preference operations get, set, update, delete, resetToDefault, asFlow, stateIn, stateInCurrent, blocking variants, property delegation

Whole-Object Access

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

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

Byte Backup and Restore

Factory-created proto datastores can export and import the raw datastore file bytes:

val backupBytes: ByteArray = protoDatastore.exportAsByteArray()

protoDatastore.importFromByteArray(backupBytes)

exportAsByteArray() returns an empty ByteArray if the backing file has not been created yet. importFromByteArray() decodes the bytes with the datastore's OkioSerializer and replaces the whole proto message. Low-level GenericProtoDatastore wrappers only support these APIs when they are constructed with the serializer and resolved path; otherwise they throw UnsupportedOperationException.

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: PreferencesDatastore) {
    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 batchReadFlowValues as a Compose State, reading a whole declared batch from a single DataStore snapshot:

@Composable
fun SettingsScreen(datastore: PreferencesDatastore, store: SettingsStore) {
    // Handles for typed access, captured once from an equal declaration.
    var nameH: BatchPref<String>? = null
    var darkH: BatchPref<Boolean>? = null
    remember(store) {
        prefBatch {
            nameH = add(store.userNamePref)
            darkH = add(store.darkModePref)
        }
    }

    val settings by datastore.rememberBatchRead({
        add(store.userNamePref)
        add(store.darkModePref)
    })

    settings?.let { values ->
        Text("User: ${values[requireNotNull(nameH)]}, Dark mode: ${values[requireNotNull(darkH)]}")
    }
}

A block variant derives a projection from each BatchValues snapshot:

var lengthH: BatchPref<String>? = null
val nameLength by datastore.rememberBatchRead(
    declare = {
        lengthH = add(store.userNamePref)
    },
) {
    this[requireNotNull(lengthH)].length
}

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: PreferencesDatastore) {
    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: PreferencesDatastore) {
    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