# Android DataStore Patterns

Full code examples backing the guidance in `SKILL.md`. Headings mirror the
SKILL.md sections. Package, class, and key names are placeholders; adapt them.

## Contents

- [Preferences DataStore](#preferences-datastore)
- [Typed DataStore with a Serializer](#typed-datastore-with-a-serializer)
- [Encrypted Typed DataStore](#encrypted-typed-datastore)
- [Migrating from SharedPreferences](#migrating-from-sharedpreferences)
- [Versioned Data Migrations](#versioned-data-migrations)
- [Reading and Writing as Flow](#reading-and-writing-as-flow)
- [Testing](#testing)

## Preferences DataStore

```kotlin
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings"
)

object SettingsKeys {
    val THEME = stringPreferencesKey("theme")
    val LAUNCH_COUNT = intPreferencesKey("launch_count")
    val ONBOARDED = booleanPreferencesKey("onboarded")
}

class SettingsRepository(private val dataStore: DataStore<Preferences>) {

    val theme: Flow<String> = dataStore.data
        .map { prefs -> prefs[SettingsKeys.THEME] ?: "system" }
        .distinctUntilChanged()

    suspend fun setTheme(value: String) {
        dataStore.edit { prefs -> prefs[SettingsKeys.THEME] = value }
    }

    suspend fun incrementLaunchCount() {
        dataStore.edit { prefs ->
            prefs[SettingsKeys.LAUNCH_COUNT] = (prefs[SettingsKeys.LAUNCH_COUNT] ?: 0) + 1
        }
    }
}
```

## Typed DataStore with a Serializer

```kotlin
@Serializable
data class UserSettings(
    val theme: String = "system",
    val notificationsEnabled: Boolean = true,
    val syncIntervalMinutes: Int = 30
)

object UserSettingsSerializer : Serializer<UserSettings> {
    override val defaultValue: UserSettings = UserSettings()

    override suspend fun readFrom(input: InputStream): UserSettings =
        try {
            Json.decodeFromString(
                UserSettings.serializer(),
                input.readBytes().decodeToString()
            )
        } catch (e: SerializationException) {
            throw CorruptionException("Unable to read UserSettings", e)
        }

    override suspend fun writeTo(t: UserSettings, output: OutputStream) {
        output.write(
            Json.encodeToString(UserSettings.serializer(), t).encodeToByteArray()
        )
    }
}

val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.json",
    serializer = UserSettingsSerializer,
    corruptionHandler = ReplaceFileCorruptionHandler { UserSettings() }
)
```

## Encrypted Typed DataStore

The encrypting serializer wraps any inner `Serializer<T>`. It prepends the
GCM IV to the ciphertext, fails safe to the inner default on any read error,
and writes plaintext only on debug builds.

```kotlin
interface CipherProvider {
    fun encryptCipher(): Cipher
    fun decryptCipher(iv: ByteArray): Cipher
}

class EncryptingSerializer<T>(
    private val delegate: Serializer<T>,
    private val cipherProvider: CipherProvider,
    private val allowPlaintextInDebug: Boolean = BuildConfig.DEBUG
) : Serializer<T> {

    override val defaultValue: T = delegate.defaultValue

    override suspend fun readFrom(input: InputStream): T =
        try {
            val bytes = input.readBytes()
            if (bytes.isEmpty()) return defaultValue

            val plain = if (allowPlaintextInDebug && bytes[0] == PLAINTEXT_MARKER) {
                bytes.copyOfRange(1, bytes.size)
            } else {
                val ivSize = bytes[0].toInt() and 0xFF
                val iv = bytes.copyOfRange(1, 1 + ivSize)
                val cipherText = bytes.copyOfRange(1 + ivSize, bytes.size)
                cipherProvider.decryptCipher(iv).doFinal(cipherText)
            }
            delegate.readFrom(ByteArrayInputStream(plain))
        } catch (e: Exception) {
            defaultValue
        }

    override suspend fun writeTo(t: T, output: OutputStream) {
        val plain = ByteArrayOutputStream().also { delegate.writeTo(t, it) }.toByteArray()

        if (allowPlaintextInDebug) {
            output.write(byteArrayOf(PLAINTEXT_MARKER))
            output.write(plain)
            return
        }

        val cipher = cipherProvider.encryptCipher()
        val iv = cipher.iv
        val cipherText = cipher.doFinal(plain)
        output.write(byteArrayOf(iv.size.toByte()))
        output.write(iv)
        output.write(cipherText)
    }

    private companion object {
        const val PLAINTEXT_MARKER: Byte = 0x00
    }
}

fun <T> Context.createEncryptedDataStore(
    name: String,
    serializer: Serializer<T>,
    cipherProvider: CipherProvider
): DataStore<T> {
    val encrypting = EncryptingSerializer(serializer, cipherProvider)
    return DataStoreFactory.create(
        serializer = encrypting,
        corruptionHandler = ReplaceFileCorruptionHandler { serializer.defaultValue },
        produceFile = { File(filesDir, "datastore/$name") }
    )
}
```

The `CipherProvider` is backed by an AES/GCM Keystore key; that key's creation,
hierarchy, and invalidation handling belong to `android-security`. The plaintext
branch is compiled out of release builds because `BuildConfig.DEBUG` is a
compile-time constant.

## Migrating from SharedPreferences

Preferences store, copying named legacy keys once:

```kotlin
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_prefs",
                keysToMigrate = setOf("theme", "onboarded")
            )
        )
    }
)
```

Typed store, mapping flat legacy values onto the model:

```kotlin
val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.json",
    serializer = UserSettingsSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = "legacy_prefs"
            ) { prefs, current ->
                current.copy(
                    theme = prefs.getString("theme", current.theme),
                    notificationsEnabled = prefs.getBoolean(
                        "notifications", current.notificationsEnabled
                    )
                )
            }
        )
    }
)
```

## Versioned Data Migrations

```kotlin
@Serializable
data class UserSettings(
    val schemaVersion: Int = 2,
    val theme: String = "system",
    val syncIntervalMinutes: Int = 30
)

val v1ToV2 = object : DataMigration<UserSettings> {
    override suspend fun shouldMigrate(currentData: UserSettings): Boolean =
        currentData.schemaVersion < 2

    override suspend fun migrate(currentData: UserSettings): UserSettings =
        currentData.copy(
            schemaVersion = 2,
            syncIntervalMinutes = currentData.syncIntervalMinutes.coerceAtLeast(15)
        )

    override suspend fun cleanUp() = Unit
}

val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
    fileName = "user_settings.json",
    serializer = UserSettingsSerializer,
    produceMigrations = { listOf(v1ToV2) }
)
```

## Reading and Writing as Flow

```kotlin
class SettingsViewModel(
    private val dataStore: DataStore<UserSettings>
) : ViewModel() {

    val theme: StateFlow<String> = dataStore.data
        .catch { e -> if (e is IOException) emit(UserSettings()) else throw e }
        .map { it.theme }
        .distinctUntilChanged()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "system")

    suspend fun currentSettings(): UserSettings = dataStore.data.first()

    fun setSyncInterval(minutes: Int) {
        viewModelScope.launch {
            dataStore.updateData { current -> current.copy(syncIntervalMinutes = minutes) }
        }
    }
}
```

## Testing

```kotlin
class UserSettingsDataStoreTest {

    @get:Rule val tmp = TemporaryFolder()

    private lateinit var scope: TestScope
    private lateinit var dataStore: DataStore<UserSettings>

    @Before
    fun setUp() {
        scope = TestScope(UnconfinedTestDispatcher())
        dataStore = DataStoreFactory.create(
            serializer = UserSettingsSerializer,
            scope = scope.backgroundScope,
            produceFile = { tmp.newFile("user_settings.json") }
        )
    }

    @Test
    fun writeThenRead_returnsUpdatedValue() = scope.runTest {
        dataStore.updateData { it.copy(theme = "dark") }
        val result = dataStore.data.first()
        assertEquals("dark", result.theme)
    }

    @Test
    fun corruptEncryptedFile_failsSafeToDefault() = scope.runTest {
        val file = tmp.newFile("secure.bin").apply { writeBytes(byteArrayOf(9, 9, 9)) }
        val store = DataStoreFactory.create(
            serializer = EncryptingSerializer(UserSettingsSerializer, FakeCipherProvider()),
            scope = scope.backgroundScope,
            produceFile = { file }
        )
        assertEquals(UserSettings(), store.data.first())
    }
}
```
