@mmerterden/multi-agent-pipeline 20.1.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.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -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`
|