@mmerterden/multi-agent-pipeline 20.0.0 → 20.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +5 -5
  3. package/README.tr.md +5 -5
  4. package/SECURITY.md +3 -3
  5. package/docs/adr/0011-dormant-ci.md +10 -1
  6. package/docs/architecture.md +2 -2
  7. package/docs/ecosystem.md +5 -5
  8. package/docs/facts.json +8 -7
  9. package/install/_codex-agents.mjs +1 -1
  10. package/manifest.json +92 -64
  11. package/package.json +1 -1
  12. package/pipeline/agents/code-reviewer.md +2 -2
  13. package/pipeline/agents/dev-critic.md +5 -5
  14. package/pipeline/agents/security-auditor.md +80 -72
  15. package/pipeline/commands/figma-to-swiftui.md +1 -1
  16. package/pipeline/commands/multi-agent/SKILL.md +1 -1
  17. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  18. package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
  19. package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
  20. package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
  21. package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
  22. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  23. package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
  24. package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
  25. package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
  26. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  27. package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
  28. package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
  29. package/pipeline/multi-agent-refs/threat-model.md +39 -0
  30. package/pipeline/schemas/agent-state.schema.json +23 -0
  31. package/pipeline/schemas/phases.json +1 -2
  32. package/pipeline/schemas/prefs.schema.json +0 -4
  33. package/pipeline/schemas/reviewer-output.schema.json +99 -2
  34. package/pipeline/schemas/security-finding.schema.json +144 -0
  35. package/pipeline/scripts/_stack-routing.mjs +1 -0
  36. package/pipeline/scripts/gc-abandoned.sh +16 -9
  37. package/pipeline/scripts/render-work-summary.sh +7 -4
  38. package/pipeline/skills/.skill-manifest.json +47 -23
  39. package/pipeline/skills/.skills-index.json +75 -9
  40. package/pipeline/skills/shared/README.md +13 -7
  41. package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
  42. package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
  43. package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
  44. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  46. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  47. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  48. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  49. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  50. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  51. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  52. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  53. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  54. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  55. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  56. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  57. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  58. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  59. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  60. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  61. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  62. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  63. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  64. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  65. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  66. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  67. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  68. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  69. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  70. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  71. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  72. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  73. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  74. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  75. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  76. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  77. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  78. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  79. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  80. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  81. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  82. package/pipeline/skills/shared/external/security-review/SKILL.md +64 -0
  83. package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
  84. package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -0
  85. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  86. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  87. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  88. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  89. package/pipeline/skills/skills-index.md +8 -2
  90. package/pipeline/commands/security-review.md +0 -6
@@ -0,0 +1,432 @@
1
+ # Android Build Quality Gates - Patterns
2
+
3
+ Full, copy-ready code for the gates summarized in
4
+ [../SKILL.md](../SKILL.md). Each section mirrors a heading there. Adapt names to
5
+ the project's module conventions; nothing here hardcodes a product.
6
+
7
+ ## Layer-dependency gate
8
+
9
+ Convention plugin `build-logic/convention/src/main/kotlin/quality/LayerGate.kt`.
10
+ It derives a module's layer from its path, resolves the allowed set for that
11
+ layer, and fails on any project dependency outside it.
12
+
13
+ ```kotlin
14
+ package quality
15
+
16
+ import org.gradle.api.GradleException
17
+ import org.gradle.api.Project
18
+ import org.gradle.api.artifacts.ProjectDependency
19
+ import org.gradle.kotlin.dsl.withType
20
+
21
+ enum class Layer {
22
+ APP, FEATURE, DOMAIN, DATA, CORE, UNKNOWN;
23
+
24
+ fun allows(depPath: String): Boolean {
25
+ val depLayer = layerOf(depPath)
26
+ return when (this) {
27
+ APP -> true
28
+ FEATURE -> depLayer in setOf(DOMAIN, CORE)
29
+ DOMAIN -> depLayer in setOf(DOMAIN, CORE)
30
+ DATA -> depLayer in setOf(DOMAIN, CORE, DATA)
31
+ CORE -> depLayer in setOf(CORE)
32
+ UNKNOWN -> true
33
+ }
34
+ }
35
+
36
+ companion object {
37
+ fun layerOf(path: String): Layer {
38
+ val name = path.substringAfterLast(":")
39
+ return when {
40
+ name.startsWith("app") -> APP
41
+ name.startsWith("feature") -> FEATURE
42
+ name.startsWith("domain") -> DOMAIN
43
+ name.startsWith("data") -> DATA
44
+ name.startsWith("core") -> CORE
45
+ else -> UNKNOWN
46
+ }
47
+ }
48
+ }
49
+ }
50
+
51
+ fun Project.moduleLayer(): Layer = Layer.layerOf(path)
52
+
53
+ fun Project.registerLayerGate() {
54
+ val layer = moduleLayer()
55
+ val projectDeps = configurations
56
+ .matching { it.name in setOf("implementation", "api") }
57
+ .flatMap { conf -> conf.dependencies.withType<ProjectDependency>().map { it.path } }
58
+ .distinct()
59
+
60
+ val checkLayerDependencies = tasks.register("checkLayerDependencies") {
61
+ group = "verification"
62
+ description = "Fails when this module depends on a forbidden layer"
63
+ doLast {
64
+ val forbidden = projectDeps.filterNot { layer.allows(it) }
65
+ if (forbidden.isNotEmpty()) {
66
+ throw GradleException(
67
+ buildString {
68
+ appendLine("$path ($layer) declares forbidden dependencies:")
69
+ forbidden.forEach { appendLine(" - $it (${Layer.layerOf(it)})") }
70
+ append("Allowed targets for $layer are enforced in Layer.allows().")
71
+ }
72
+ )
73
+ }
74
+ }
75
+ }
76
+ tasks.named("check").configure { dependsOn(checkLayerDependencies) }
77
+ }
78
+ ```
79
+
80
+ `projectDeps` is materialized into a `List<String>` at configuration time, so
81
+ `doLast` never touches the live `configurations` container and the task is
82
+ configuration-cache safe. Apply from a module's `build.gradle.kts` with
83
+ `registerLayerGate()` after the plugin block, or fold the call into a broader
84
+ `AndroidLibraryConventionPlugin`.
85
+
86
+ For a temporary accepted exception, prefer a narrow per-edge allowlist over
87
+ widening `allows`:
88
+
89
+ ```kotlin
90
+ private val acceptedEdges = setOf(
91
+ ":feature-legacy" to ":data-legacy", // TRACKING-1234, remove after migration
92
+ )
93
+
94
+ fun Layer.allowsWithExceptions(fromPath: String, depPath: String): Boolean =
95
+ allows(depPath) || (fromPath to depPath) in acceptedEdges
96
+ ```
97
+
98
+ ## Coverage floor with Kover
99
+
100
+ Per-module: apply the plugin and exclude generated code.
101
+
102
+ ```kotlin
103
+ plugins {
104
+ id("org.jetbrains.kotlinx.kover")
105
+ }
106
+
107
+ kover {
108
+ reports {
109
+ filters {
110
+ excludes {
111
+ classes(
112
+ "*_Factory",
113
+ "*_Factory\$*",
114
+ "*.BuildConfig",
115
+ "*Args",
116
+ "*Directions",
117
+ "hilt_aggregated_deps.*",
118
+ "*_HiltModules*",
119
+ "dagger.hilt.*",
120
+ )
121
+ annotatedBy("androidx.compose.runtime.Composable")
122
+ }
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ Aggregation module `coverage/build.gradle.kts` depends on every measured module
129
+ and owns the single verify rule:
130
+
131
+ ```kotlin
132
+ plugins {
133
+ id("org.jetbrains.kotlinx.kover")
134
+ }
135
+
136
+ dependencies {
137
+ kover(project(":feature-a"))
138
+ kover(project(":feature-b"))
139
+ kover(project(":domain"))
140
+ kover(project(":data"))
141
+ }
142
+
143
+ kover {
144
+ reports {
145
+ total {
146
+ verify {
147
+ rule("Aggregate line coverage floor") {
148
+ bound {
149
+ minValue = 60 // seed at measured value, ratchet up
150
+ coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.LINE
151
+ aggregationForGroup =
152
+ kotlinx.kover.gradle.plugin.dsl.AggregationType.COVERED_PERCENTAGE
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ ```
160
+
161
+ Gate behind a property so local builds skip instrumented coverage:
162
+
163
+ ```kotlin
164
+ tasks.named("check").configure {
165
+ if (providers.gradleProperty("enableCoverageVerification").isPresent) {
166
+ dependsOn("koverVerify")
167
+ }
168
+ }
169
+ ```
170
+
171
+ CI: `./gradlew :coverage:check -PenableCoverageVerification`.
172
+
173
+ ## Compose-stability gate
174
+
175
+ Point the compiler at a metrics dir (per-module, in the Android/compose
176
+ convention plugin):
177
+
178
+ ```kotlin
179
+ composeCompiler {
180
+ val metricsDir = layout.buildDirectory.dir("compose-metrics")
181
+ metricsDestination.set(metricsDir)
182
+ reportsDestination.set(metricsDir)
183
+ }
184
+ ```
185
+
186
+ The check task parses `*-composables.txt`. A composable line the compiler emits
187
+ looks like `restartable skippable fun HomeScreen(...)` or, when it cannot skip,
188
+ `restartable fun HomeScreen(...)` (no `skippable`). The gate fails on the second
189
+ shape unless the composable is in the ignore-list.
190
+
191
+ ```kotlin
192
+ package quality
193
+
194
+ import org.gradle.api.DefaultTask
195
+ import org.gradle.api.GradleException
196
+ import org.gradle.api.file.DirectoryProperty
197
+ import org.gradle.api.file.RegularFileProperty
198
+ import org.gradle.api.provider.ListProperty
199
+ import org.gradle.api.tasks.InputDirectory
200
+ import org.gradle.api.tasks.InputFile
201
+ import org.gradle.api.tasks.Optional
202
+ import org.gradle.api.tasks.TaskAction
203
+
204
+ abstract class ComposeStabilityCheck : DefaultTask() {
205
+
206
+ @get:InputDirectory
207
+ abstract val reportDir: DirectoryProperty
208
+
209
+ @get:InputFile
210
+ @get:Optional
211
+ abstract val ignoreFile: RegularFileProperty
212
+
213
+ private val restartableNotSkippable =
214
+ Regex("""^restartable (?!skippable)fun (\w+)""")
215
+
216
+ @TaskAction
217
+ fun check() {
218
+ val dir = reportDir.get().asFile
219
+ if (!dir.exists()) return
220
+
221
+ val ignored: Set<String> = ignoreFile.orNull?.asFile
222
+ ?.takeIf { it.exists() }
223
+ ?.readLines()
224
+ ?.map { it.substringBefore('#').trim() }
225
+ ?.filter { it.isNotEmpty() }
226
+ ?.toSet()
227
+ ?: emptySet()
228
+
229
+ val offenders = dir.walkTopDown()
230
+ .filter { it.name.endsWith("-composables.txt") }
231
+ .flatMap { file -> file.readLines().asSequence() }
232
+ .mapNotNull { line -> restartableNotSkippable.find(line.trim())?.groupValues?.get(1) }
233
+ .filterNot { it in ignored }
234
+ .distinct()
235
+ .toList()
236
+
237
+ if (offenders.isNotEmpty()) {
238
+ throw GradleException(
239
+ buildString {
240
+ appendLine("Unskippable composables (restartable but not skippable):")
241
+ offenders.forEach { appendLine(" - $it") }
242
+ appendLine("Fix the unstable parameter, or add the name to")
243
+ append(ignoreFile.orNull?.asFile?.path ?: "the stability ignore-list")
244
+ }
245
+ )
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ Register it and finalize each Kotlin compile so the report is fresh:
252
+
253
+ ```kotlin
254
+ val checkComposeStability = tasks.register("checkComposeStability", ComposeStabilityCheck::class.java) {
255
+ group = "verification"
256
+ reportDir.set(layout.buildDirectory.dir("compose-metrics"))
257
+ ignoreFile.set(layout.projectDirectory.file("compose-stability-ignore.txt"))
258
+ }
259
+
260
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
261
+ finalizedBy(checkComposeStability)
262
+ }
263
+ tasks.named("check").configure { dependsOn(checkComposeStability) }
264
+ ```
265
+
266
+ `compose-stability-ignore.txt` (per module), seeded with today's offenders:
267
+
268
+ ```
269
+ # Accepted exceptions. Each line: composable name. Shrink over time; additions reviewed.
270
+ LegacyChartView # takes third-party unstable ChartModel, TRACKING-2201
271
+ ```
272
+
273
+ ## Lint on diff
274
+
275
+ Pre-commit hook `.git/hooks/pre-commit` (or a managed hook via a Gradle plugin):
276
+
277
+ ```bash
278
+ #!/usr/bin/env bash
279
+ set -euo pipefail
280
+
281
+ CHANGED=$(git diff --cached --name-only --diff-filter=d -- '*.kt' '*.kts')
282
+ if [ -z "$CHANGED" ]; then
283
+ exit 0
284
+ fi
285
+
286
+ ./gradlew detektDiff -PdetektInput="$CHANGED" --quiet
287
+ ```
288
+
289
+ `detektDiff` task, in the quality convention plugin, points detekt at only the
290
+ changed files while keeping the shared config and baseline:
291
+
292
+ ```kotlin
293
+ plugins {
294
+ id("io.gitlab.arturbosch.detekt")
295
+ }
296
+
297
+ detekt {
298
+ buildUponDefaultConfig = true
299
+ config.setFrom(rootProject.files("config/detekt/detekt.yml"))
300
+ baseline = rootProject.file("config/detekt/baseline.xml")
301
+ }
302
+
303
+ tasks.register("detektDiff", io.gitlab.arturbosch.detekt.Detekt::class.java) {
304
+ group = "verification"
305
+ description = "Runs detekt over only the files passed in -PdetektInput"
306
+ val input = providers.gradleProperty("detektInput").orElse("")
307
+ setSource(files(input.get().split(Regex("\\s+")).filter { it.isNotBlank() }))
308
+ config.setFrom(rootProject.files("config/detekt/detekt.yml"))
309
+ baseline.set(rootProject.file("config/detekt/baseline.xml"))
310
+ buildUponDefaultConfig = true
311
+ include("**/*.kt", "**/*.kts")
312
+ }
313
+ ```
314
+
315
+ CI runs the full task with the same config and baseline:
316
+
317
+ ```yaml
318
+ - name: Detekt (full)
319
+ run: ./gradlew detekt --continue
320
+ ```
321
+
322
+ Regenerate the baseline deliberately (`./gradlew detektBaseline`) and review the
323
+ diff; a baseline change that buries a new finding is a review smell.
324
+
325
+ ## Screenshot validation task
326
+
327
+ Author the tests per `compose-testing`. Here, register the record/verify pair
328
+ and the umbrella task. Example with Roborazzi:
329
+
330
+ ```kotlin
331
+ tasks.register("recordScreenshots") {
332
+ group = "verification"
333
+ description = "Regenerates golden images; run deliberately, review the diff"
334
+ dependsOn(tasks.matching { it.name == "recordRoborazziDebug" })
335
+ }
336
+
337
+ tasks.register("verifyScreenshots") {
338
+ group = "verification"
339
+ description = "Compares against goldens and fails on mismatch or missing golden"
340
+ dependsOn(tasks.matching { it.name == "verifyRoborazziDebug" })
341
+ }
342
+
343
+ tasks.named("check").configure { dependsOn("verifyScreenshots") }
344
+ ```
345
+
346
+ Configure the tool so a missing golden fails rather than records:
347
+
348
+ ```kotlin
349
+ roborazzi {
350
+ outputDir.set(layout.projectDirectory.dir("src/test/screenshots"))
351
+ verify {
352
+ // do NOT enable a record-on-missing fallback in the verify task
353
+ }
354
+ }
355
+ ```
356
+
357
+ CI uploads diff images on failure so the reviewer sees the pixel change:
358
+
359
+ ```yaml
360
+ - name: Verify screenshots
361
+ run: ./gradlew verifyScreenshots
362
+ - name: Upload diffs
363
+ if: failure()
364
+ uses: actions/upload-artifact@v4
365
+ with:
366
+ name: screenshot-diffs
367
+ path: '**/build/outputs/roborazzi/*_compare.png'
368
+ ```
369
+
370
+ ## Wiring gates into check
371
+
372
+ Assemble every gate in one quality convention plugin and expose a single
373
+ aggregate task. This is what a module gets by applying the plugin.
374
+
375
+ ```kotlin
376
+ package quality
377
+
378
+ import org.gradle.api.Plugin
379
+ import org.gradle.api.Project
380
+
381
+ class QualityGatesConventionPlugin : Plugin<Project> {
382
+ override fun apply(target: Project) = with(target) {
383
+ registerLayerGate()
384
+
385
+ val checkComposeStability = tasks.register(
386
+ "checkComposeStability", ComposeStabilityCheck::class.java
387
+ ) {
388
+ group = "verification"
389
+ reportDir.set(layout.buildDirectory.dir("compose-metrics"))
390
+ ignoreFile.set(layout.projectDirectory.file("compose-stability-ignore.txt"))
391
+ }
392
+ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java)
393
+ .configureEach { finalizedBy(checkComposeStability) }
394
+
395
+ val qualityGates = tasks.register("qualityGates") {
396
+ group = "verification"
397
+ description = "Runs all fast, always-on build quality gates"
398
+ dependsOn(
399
+ "checkLayerDependencies",
400
+ "checkComposeStability",
401
+ )
402
+ if (tasks.findByName("verifyScreenshots") != null) {
403
+ dependsOn("verifyScreenshots")
404
+ }
405
+ }
406
+
407
+ tasks.named("check").configure {
408
+ dependsOn(qualityGates)
409
+ if (providers.gradleProperty("enableCoverageVerification").isPresent) {
410
+ dependsOn("koverVerify")
411
+ }
412
+ }
413
+ }
414
+ }
415
+ ```
416
+
417
+ Register the plugin in `build-logic/convention/build.gradle.kts`:
418
+
419
+ ```kotlin
420
+ gradlePlugin {
421
+ plugins {
422
+ register("qualityGates") {
423
+ id = "convention.quality.gates"
424
+ implementationClass = "quality.QualityGatesConventionPlugin"
425
+ }
426
+ }
427
+ }
428
+ ```
429
+
430
+ Apply from any module: `plugins { id("convention.quality.gates") }`. A single
431
+ `./gradlew check` then runs the fast gates locally; CI adds `-P` flags for the
432
+ expensive ones.
@@ -0,0 +1,236 @@
1
+ ---
2
+ name: android-datastore
3
+ description: "Typed Jetpack DataStore as the modern SharedPreferences replacement: Preferences and typed stores, serializer-layer encryption, and migration. Use when persisting settings, session or tokens, encrypting key-value state, or migrating off EncryptedSharedPreferences."
4
+ risk: safe
5
+ source: multi-agent-pipeline
6
+ date_added: "2026-09-21"
7
+ ---
8
+
9
+ # Android DataStore
10
+
11
+ Jetpack DataStore is the current replacement for `SharedPreferences`: an
12
+ async, transactional, coroutine- and Flow-based key-value or typed store that
13
+ never blocks the UI thread and surfaces read errors instead of swallowing them.
14
+ Targets DataStore 1.1+ with Kotlin coroutines.
15
+
16
+ Full copy-ready code for every section lives in
17
+ [`references/patterns.md`](references/patterns.md), under headings that mirror
18
+ these sections. Read a section here for the decision, load the matching
19
+ reference section when writing the implementation.
20
+
21
+ ## Contents
22
+
23
+ - [Choosing a Store](#choosing-a-store)
24
+ - [Preferences DataStore](#preferences-datastore)
25
+ - [Typed DataStore with a Serializer](#typed-datastore-with-a-serializer)
26
+ - [Encrypted Typed DataStore](#encrypted-typed-datastore)
27
+ - [Migrating from SharedPreferences](#migrating-from-sharedpreferences)
28
+ - [Versioned Data Migrations](#versioned-data-migrations)
29
+ - [Reading and Writing as Flow](#reading-and-writing-as-flow)
30
+ - [Testing](#testing)
31
+ - [Do's and Don'ts](#dos-and-donts)
32
+ - [Troubleshooting](#troubleshooting)
33
+ - [Review Checklist](#review-checklist)
34
+
35
+ ## Choosing a Store
36
+
37
+ Two flavors share one engine. Pick by whether the shape of the data is fixed.
38
+
39
+ | Need | Store | Why |
40
+ |------|-------|-----|
41
+ | A handful of loose keys, types known only at the call site | Preferences DataStore | No schema, `Preferences.Key<T>` accessors, quickest to adopt |
42
+ | A fixed model with named fields, validation, defaults | Typed DataStore | One `Serializer`, compile-time-safe reads, atomic whole-object writes |
43
+ | Small sensitive state (tokens, flags tied to a session) | Typed DataStore + encrypting `Serializer` | Encryption sits in the serialization layer, transparent to callers |
44
+ | Large sensitive or relational data | Not DataStore | Use `room-database` (encrypted relational storage) |
45
+ | Cryptographic keys themselves | Not DataStore | Use `android-security` (Keystore key hierarchy) |
46
+
47
+ Preferences DataStore trades the schema for flexibility: no type safety, no
48
+ defaults, no validation. Reach for the typed flavor as soon as the data has a
49
+ stable shape. "Proto DataStore" is the same typed mechanism using a protobuf
50
+ `Serializer`; nothing here requires protobuf specifically, any serialization
51
+ (a hand-written `Serializer`, kotlinx-serialization JSON, protobuf) works
52
+ behind the `Serializer<T>` interface.
53
+
54
+ Declare each store once per process as a top-level property so a single
55
+ instance owns the file; two instances over one file corrupt it.
56
+
57
+ ## Preferences DataStore
58
+
59
+ Create the store with the `preferencesDataStore` delegate, define typed keys
60
+ with `stringPreferencesKey`, `intPreferencesKey`, `booleanPreferencesKey`, and
61
+ read `data` as a `Flow<Preferences>`, mapping each key with a default. Write
62
+ inside `edit { }`, a transactional block.
63
+
64
+ See [references/patterns.md#preferences-datastore](references/patterns.md#preferences-datastore)
65
+ for the store declaration, a typed read Flow, and a write.
66
+
67
+ ## Typed DataStore with a Serializer
68
+
69
+ A typed store needs a `Serializer<T>` supplying a `defaultValue` and
70
+ `readFrom` / `writeTo` over streams. Model the data as an immutable type
71
+ (a `data class` or a protobuf message), then create the store with the
72
+ `dataStore` delegate passing `fileName` and `serializer`.
73
+
74
+ `readFrom` failure must throw `CorruptionException`, which the optional
75
+ `corruptionHandler` (a `ReplaceFileCorruptionHandler`) turns into a recovery
76
+ value instead of crashing the app.
77
+
78
+ See [references/patterns.md#typed-datastore-with-a-serializer](references/patterns.md#typed-datastore-with-a-serializer)
79
+ for a `data class` model, a JSON `Serializer`, and the store declaration with a
80
+ corruption handler.
81
+
82
+ ## Encrypted Typed DataStore
83
+
84
+ Encrypt at the `Serializer` layer: the store persists ciphertext, callers see
85
+ the plain model, and encryption is invisible to the rest of the app. A generic
86
+ `createEncryptedDataStore(name, serializer)` factory wraps any inner
87
+ `Serializer<T>` in an encrypting one so a single implementation serves every
88
+ model.
89
+
90
+ Contract of the encrypting `Serializer`:
91
+
92
+ - **Encrypt per write.** Serialize with the inner serializer, encrypt the
93
+ bytes with an AES/GCM key from the Keystore.
94
+ - **Prepend the IV to the ciphertext.** GCM needs a fresh random IV per
95
+ encryption; write `IV || ciphertext` so `readFrom` can split the IV back off.
96
+ Never reuse an IV with the same key.
97
+ - **Fail safe on any read error.** A decryption failure, a truncated file, a
98
+ key rotation, a format change: `readFrom` returns the inner serializer's
99
+ `defaultValue` rather than throwing. Corrupt or unreadable encrypted state
100
+ degrades to "empty", it never crashes and never leaks a partial plaintext.
101
+ - **Debug-only plaintext escape hatch (optional).** Guarded by
102
+ `BuildConfig.DEBUG`, write plaintext instead of ciphertext so the file is
103
+ inspectable on a developer build. Release builds always encrypt; the branch
104
+ is compiled out by R8.
105
+
106
+ The AES/GCM Keystore key belongs to `android-security` (key hierarchy,
107
+ envelope encryption, `KeyPermanentlyInvalidatedException` handling); this
108
+ factory only consumes a `Cipher` from it. `EncryptedSharedPreferences` and the
109
+ `androidx.security-crypto` `MasterKey` it depends on are deprecated; a typed
110
+ DataStore with an encrypting `Serializer` is the current-best-practice
111
+ replacement and keeps encryption and typing in one place.
112
+
113
+ See [references/patterns.md#encrypted-typed-datastore](references/patterns.md#encrypted-typed-datastore)
114
+ for the full `EncryptingSerializer<T>` (IV split/prepend, fail-safe read,
115
+ debug plaintext branch) and the `createEncryptedDataStore` factory.
116
+
117
+ ## Migrating from SharedPreferences
118
+
119
+ Pass a `SharedPreferencesMigration` in the store's `produceMigrations`. On
120
+ first read after upgrade, DataStore copies the named keys out of the legacy
121
+ `SharedPreferences` into the store, then deletes the migrated keys so the copy
122
+ runs exactly once. List the keys explicitly to migrate a subset; omit the set
123
+ to migrate all keys.
124
+
125
+ For a typed store, the migration maps the flat `SharedPreferences` values onto
126
+ the model's fields inside the migration lambda.
127
+
128
+ See [references/patterns.md#migrating-from-sharedpreferences](references/patterns.md#migrating-from-sharedpreferences)
129
+ for a Preferences-store migration and a typed-store migration.
130
+
131
+ ## Versioned Data Migrations
132
+
133
+ Carry a `version` (or `schemaVersion`) field in the typed model and register a
134
+ `DataMigration<T>` whose `shouldMigrate` compares the stored version, whose
135
+ `migrate` returns the upgraded value, and whose `cleanUp` releases any
136
+ transient resources. Chain one migration per version step so an old install
137
+ walks forward one version at a time. Migrations run in order before the first
138
+ `data` emission.
139
+
140
+ See [references/patterns.md#versioned-data-migrations](references/patterns.md#versioned-data-migrations)
141
+ for a versioned model and a stepwise `DataMigration`.
142
+
143
+ ## Reading and Writing as Flow
144
+
145
+ `data` is a cold `Flow<T>` that re-emits the whole value on every write.
146
+
147
+ - **Observe** by collecting `data`; map to the field you need and add
148
+ `distinctUntilChanged()` to drop no-op re-emissions.
149
+ - **Read once** with `data.first()` inside a coroutine.
150
+ - **Handle IO errors in the stream**: catch `IOException` from `data` with
151
+ `.catch { }` and emit the default; let other exceptions propagate.
152
+ - **Write** with `updateData { current -> ... }` (typed) or `edit { }`
153
+ (Preferences). Both suspend, run on the DataStore dispatcher, and are atomic:
154
+ the transform receives the current value and returns the next, so
155
+ read-modify-write races cannot interleave. Never mutate and re-store from a
156
+ separately read snapshot; always transform inside the block.
157
+
158
+ Do the work in `viewModelScope` / an injected scope with structured
159
+ concurrency; DataStore confines its own IO, so no manual `Dispatchers.IO`
160
+ wrapping is needed around `updateData` or `edit`.
161
+
162
+ See [references/patterns.md#reading-and-writing-as-flow](references/patterns.md#reading-and-writing-as-flow)
163
+ for an observed Flow with error handling, a one-shot read, and an atomic update.
164
+
165
+ ## Testing
166
+
167
+ Point the store at a `TemporaryFolder` file and drive it with a `TestScope`.
168
+
169
+ - Build the store with `DataStoreFactory.create(...)` (or the typed factory)
170
+ and a `scope` of `TestScope(...) + Job()`, backed by a file under a JUnit
171
+ `TemporaryFolder` so each test is isolated and self-cleaning.
172
+ - Drive time with `runTest`; assert on `data.first()` after an `updateData`.
173
+ - For the encrypting serializer, inject a fake/in-memory `Cipher` provider so
174
+ the test does not touch the real Keystore, and assert a corrupt file yields
175
+ the default (fail-safe), not an exception.
176
+ - No `Thread.sleep`; `runTest` advances the virtual clock. No shared global
177
+ store between tests; one file per test.
178
+
179
+ See [references/patterns.md#testing](references/patterns.md#testing)
180
+ for a `TemporaryFolder`-backed store, a `runTest` round-trip, and a fail-safe
181
+ corruption test.
182
+
183
+ ## Do's and Don'ts
184
+
185
+ ### Do's
186
+ - Declare one DataStore instance per file, as a top-level property
187
+ - Prefer a typed DataStore once the data has a stable shape
188
+ - Encrypt in the `Serializer` layer so callers stay plaintext-only
189
+ - Prepend a fresh random IV to every GCM ciphertext
190
+ - Return the default value on any encrypted-read failure (fail safe)
191
+ - Read/write through `data`, `updateData`, and `edit` only
192
+ - Transform inside `updateData { }` / `edit { }`, never mutate a snapshot
193
+ - Add `distinctUntilChanged()` when observing a single field
194
+ - Migrate legacy keys with `SharedPreferencesMigration`
195
+ - Version the typed model and migrate stepwise
196
+ - Back tests with a `TemporaryFolder` file and a `TestScope`
197
+
198
+ ### Don'ts
199
+ - Do not create two DataStore instances over one file
200
+ - Do not use `runBlocking` to read on the main thread
201
+ - Do not reuse an IV with the same AES/GCM key
202
+ - Do not throw from an encrypted `readFrom`; degrade to the default
203
+ - Do not write plaintext in release builds (gate the branch on `BuildConfig.DEBUG`)
204
+ - Do not store cryptographic keys in DataStore (Keystore owns those)
205
+ - Do not put large or relational data in DataStore (use Room)
206
+ - Do not wrap `updateData`/`edit` in `Dispatchers.IO`; DataStore confines IO
207
+ - Do not read a snapshot, mutate, and store it back (race)
208
+ - Do not use `EncryptedSharedPreferences`; it is deprecated
209
+
210
+ ## Troubleshooting
211
+
212
+ | Problem | Cause | Fix |
213
+ |---------|-------|-----|
214
+ | `IllegalStateException: multiple DataStores active for the same file` | Two instances over one file | Keep a single top-level instance |
215
+ | `CorruptionException` on first read | Unreadable file / schema change | Add a `corruptionHandler` (`ReplaceFileCorruptionHandler`) or fail safe to default |
216
+ | Decrypt fails after re-enroll / OS update | Keystore key invalidated | Catch in `readFrom`, return default; recreate the key (see android-security) |
217
+ | `data` never emits | Collected off a cancelled scope, or a migration threw | Collect in a live scope; check migration `shouldMigrate`/`migrate` |
218
+ | SharedPreferences values not appearing | Keys not listed, or already migrated once | List the keys; migration copies each key only once |
219
+ | Stale value after write | Read from a cached snapshot, not `data` | Always observe `data`; write via `updateData` |
220
+ | `GeneralSecurityException` / `AEADBadTagException` on read | IV/ciphertext split wrong or tampered file | Verify `IV || ciphertext` framing; on failure return default |
221
+ | Flaky test, wrong value | Shared store or real dispatcher | One `TemporaryFolder` file per test; `TestScope` + `runTest` |
222
+
223
+ ## Review Checklist
224
+
225
+ - [ ] One DataStore instance per file, declared top-level
226
+ - [ ] Typed DataStore used where the data shape is stable
227
+ - [ ] Encryption lives in the `Serializer`, callers see plaintext
228
+ - [ ] Fresh random IV per write, prepended to the ciphertext
229
+ - [ ] Encrypted `readFrom` fails safe to the default value, never throws
230
+ - [ ] Any debug plaintext path is gated on `BuildConfig.DEBUG`
231
+ - [ ] Cryptographic keys come from the Keystore, not DataStore (android-security)
232
+ - [ ] Large / relational data uses Room, not DataStore (room-database)
233
+ - [ ] `SharedPreferencesMigration` covers the legacy keys
234
+ - [ ] Typed model is versioned with stepwise `DataMigration`s
235
+ - [ ] Reads/writes go through `data`, `updateData`, `edit`; no cached snapshots
236
+ - [ ] Tests use a `TemporaryFolder` file and a `TestScope`, no `sleep`