@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.1
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 +37 -0
- package/docs/facts.json +6 -6
- package/manifest.json +55 -34
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +7 -3
- package/pipeline/schemas/prefs.schema.json +4 -0
- package/pipeline/scripts/usage-register.mjs +2 -0
- 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,297 @@
|
|
|
1
|
+
# Android DataStore Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples backing the guidance in `SKILL.md`. Headings mirror the
|
|
4
|
+
SKILL.md sections. Package, class, and key names are placeholders; adapt them.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Preferences DataStore](#preferences-datastore)
|
|
9
|
+
- [Typed DataStore with a Serializer](#typed-datastore-with-a-serializer)
|
|
10
|
+
- [Encrypted Typed DataStore](#encrypted-typed-datastore)
|
|
11
|
+
- [Migrating from SharedPreferences](#migrating-from-sharedpreferences)
|
|
12
|
+
- [Versioned Data Migrations](#versioned-data-migrations)
|
|
13
|
+
- [Reading and Writing as Flow](#reading-and-writing-as-flow)
|
|
14
|
+
- [Testing](#testing)
|
|
15
|
+
|
|
16
|
+
## Preferences DataStore
|
|
17
|
+
|
|
18
|
+
```kotlin
|
|
19
|
+
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
20
|
+
name = "settings"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
object SettingsKeys {
|
|
24
|
+
val THEME = stringPreferencesKey("theme")
|
|
25
|
+
val LAUNCH_COUNT = intPreferencesKey("launch_count")
|
|
26
|
+
val ONBOARDED = booleanPreferencesKey("onboarded")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
|
|
30
|
+
|
|
31
|
+
val theme: Flow<String> = dataStore.data
|
|
32
|
+
.map { prefs -> prefs[SettingsKeys.THEME] ?: "system" }
|
|
33
|
+
.distinctUntilChanged()
|
|
34
|
+
|
|
35
|
+
suspend fun setTheme(value: String) {
|
|
36
|
+
dataStore.edit { prefs -> prefs[SettingsKeys.THEME] = value }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
suspend fun incrementLaunchCount() {
|
|
40
|
+
dataStore.edit { prefs ->
|
|
41
|
+
prefs[SettingsKeys.LAUNCH_COUNT] = (prefs[SettingsKeys.LAUNCH_COUNT] ?: 0) + 1
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Typed DataStore with a Serializer
|
|
48
|
+
|
|
49
|
+
```kotlin
|
|
50
|
+
@Serializable
|
|
51
|
+
data class UserSettings(
|
|
52
|
+
val theme: String = "system",
|
|
53
|
+
val notificationsEnabled: Boolean = true,
|
|
54
|
+
val syncIntervalMinutes: Int = 30
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
object UserSettingsSerializer : Serializer<UserSettings> {
|
|
58
|
+
override val defaultValue: UserSettings = UserSettings()
|
|
59
|
+
|
|
60
|
+
override suspend fun readFrom(input: InputStream): UserSettings =
|
|
61
|
+
try {
|
|
62
|
+
Json.decodeFromString(
|
|
63
|
+
UserSettings.serializer(),
|
|
64
|
+
input.readBytes().decodeToString()
|
|
65
|
+
)
|
|
66
|
+
} catch (e: SerializationException) {
|
|
67
|
+
throw CorruptionException("Unable to read UserSettings", e)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
override suspend fun writeTo(t: UserSettings, output: OutputStream) {
|
|
71
|
+
output.write(
|
|
72
|
+
Json.encodeToString(UserSettings.serializer(), t).encodeToByteArray()
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
|
|
78
|
+
fileName = "user_settings.json",
|
|
79
|
+
serializer = UserSettingsSerializer,
|
|
80
|
+
corruptionHandler = ReplaceFileCorruptionHandler { UserSettings() }
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Encrypted Typed DataStore
|
|
85
|
+
|
|
86
|
+
The encrypting serializer wraps any inner `Serializer<T>`. It prepends the
|
|
87
|
+
GCM IV to the ciphertext, fails safe to the inner default on any read error,
|
|
88
|
+
and writes plaintext only on debug builds.
|
|
89
|
+
|
|
90
|
+
```kotlin
|
|
91
|
+
interface CipherProvider {
|
|
92
|
+
fun encryptCipher(): Cipher
|
|
93
|
+
fun decryptCipher(iv: ByteArray): Cipher
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class EncryptingSerializer<T>(
|
|
97
|
+
private val delegate: Serializer<T>,
|
|
98
|
+
private val cipherProvider: CipherProvider,
|
|
99
|
+
private val allowPlaintextInDebug: Boolean = BuildConfig.DEBUG
|
|
100
|
+
) : Serializer<T> {
|
|
101
|
+
|
|
102
|
+
override val defaultValue: T = delegate.defaultValue
|
|
103
|
+
|
|
104
|
+
override suspend fun readFrom(input: InputStream): T =
|
|
105
|
+
try {
|
|
106
|
+
val bytes = input.readBytes()
|
|
107
|
+
if (bytes.isEmpty()) return defaultValue
|
|
108
|
+
|
|
109
|
+
val plain = if (allowPlaintextInDebug && bytes[0] == PLAINTEXT_MARKER) {
|
|
110
|
+
bytes.copyOfRange(1, bytes.size)
|
|
111
|
+
} else {
|
|
112
|
+
val ivSize = bytes[0].toInt() and 0xFF
|
|
113
|
+
val iv = bytes.copyOfRange(1, 1 + ivSize)
|
|
114
|
+
val cipherText = bytes.copyOfRange(1 + ivSize, bytes.size)
|
|
115
|
+
cipherProvider.decryptCipher(iv).doFinal(cipherText)
|
|
116
|
+
}
|
|
117
|
+
delegate.readFrom(ByteArrayInputStream(plain))
|
|
118
|
+
} catch (e: Exception) {
|
|
119
|
+
defaultValue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
override suspend fun writeTo(t: T, output: OutputStream) {
|
|
123
|
+
val plain = ByteArrayOutputStream().also { delegate.writeTo(t, it) }.toByteArray()
|
|
124
|
+
|
|
125
|
+
if (allowPlaintextInDebug) {
|
|
126
|
+
output.write(byteArrayOf(PLAINTEXT_MARKER))
|
|
127
|
+
output.write(plain)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
val cipher = cipherProvider.encryptCipher()
|
|
132
|
+
val iv = cipher.iv
|
|
133
|
+
val cipherText = cipher.doFinal(plain)
|
|
134
|
+
output.write(byteArrayOf(iv.size.toByte()))
|
|
135
|
+
output.write(iv)
|
|
136
|
+
output.write(cipherText)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private companion object {
|
|
140
|
+
const val PLAINTEXT_MARKER: Byte = 0x00
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fun <T> Context.createEncryptedDataStore(
|
|
145
|
+
name: String,
|
|
146
|
+
serializer: Serializer<T>,
|
|
147
|
+
cipherProvider: CipherProvider
|
|
148
|
+
): DataStore<T> {
|
|
149
|
+
val encrypting = EncryptingSerializer(serializer, cipherProvider)
|
|
150
|
+
return DataStoreFactory.create(
|
|
151
|
+
serializer = encrypting,
|
|
152
|
+
corruptionHandler = ReplaceFileCorruptionHandler { serializer.defaultValue },
|
|
153
|
+
produceFile = { File(filesDir, "datastore/$name") }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The `CipherProvider` is backed by an AES/GCM Keystore key; that key's creation,
|
|
159
|
+
hierarchy, and invalidation handling belong to `android-security`. The plaintext
|
|
160
|
+
branch is compiled out of release builds because `BuildConfig.DEBUG` is a
|
|
161
|
+
compile-time constant.
|
|
162
|
+
|
|
163
|
+
## Migrating from SharedPreferences
|
|
164
|
+
|
|
165
|
+
Preferences store, copying named legacy keys once:
|
|
166
|
+
|
|
167
|
+
```kotlin
|
|
168
|
+
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(
|
|
169
|
+
name = "settings",
|
|
170
|
+
produceMigrations = { context ->
|
|
171
|
+
listOf(
|
|
172
|
+
SharedPreferencesMigration(
|
|
173
|
+
context = context,
|
|
174
|
+
sharedPreferencesName = "legacy_prefs",
|
|
175
|
+
keysToMigrate = setOf("theme", "onboarded")
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Typed store, mapping flat legacy values onto the model:
|
|
183
|
+
|
|
184
|
+
```kotlin
|
|
185
|
+
val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
|
|
186
|
+
fileName = "user_settings.json",
|
|
187
|
+
serializer = UserSettingsSerializer,
|
|
188
|
+
produceMigrations = { context ->
|
|
189
|
+
listOf(
|
|
190
|
+
SharedPreferencesMigration(
|
|
191
|
+
context = context,
|
|
192
|
+
sharedPreferencesName = "legacy_prefs"
|
|
193
|
+
) { prefs, current ->
|
|
194
|
+
current.copy(
|
|
195
|
+
theme = prefs.getString("theme", current.theme),
|
|
196
|
+
notificationsEnabled = prefs.getBoolean(
|
|
197
|
+
"notifications", current.notificationsEnabled
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Versioned Data Migrations
|
|
207
|
+
|
|
208
|
+
```kotlin
|
|
209
|
+
@Serializable
|
|
210
|
+
data class UserSettings(
|
|
211
|
+
val schemaVersion: Int = 2,
|
|
212
|
+
val theme: String = "system",
|
|
213
|
+
val syncIntervalMinutes: Int = 30
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
val v1ToV2 = object : DataMigration<UserSettings> {
|
|
217
|
+
override suspend fun shouldMigrate(currentData: UserSettings): Boolean =
|
|
218
|
+
currentData.schemaVersion < 2
|
|
219
|
+
|
|
220
|
+
override suspend fun migrate(currentData: UserSettings): UserSettings =
|
|
221
|
+
currentData.copy(
|
|
222
|
+
schemaVersion = 2,
|
|
223
|
+
syncIntervalMinutes = currentData.syncIntervalMinutes.coerceAtLeast(15)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
override suspend fun cleanUp() = Unit
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
val Context.userSettingsDataStore: DataStore<UserSettings> by dataStore(
|
|
230
|
+
fileName = "user_settings.json",
|
|
231
|
+
serializer = UserSettingsSerializer,
|
|
232
|
+
produceMigrations = { listOf(v1ToV2) }
|
|
233
|
+
)
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Reading and Writing as Flow
|
|
237
|
+
|
|
238
|
+
```kotlin
|
|
239
|
+
class SettingsViewModel(
|
|
240
|
+
private val dataStore: DataStore<UserSettings>
|
|
241
|
+
) : ViewModel() {
|
|
242
|
+
|
|
243
|
+
val theme: StateFlow<String> = dataStore.data
|
|
244
|
+
.catch { e -> if (e is IOException) emit(UserSettings()) else throw e }
|
|
245
|
+
.map { it.theme }
|
|
246
|
+
.distinctUntilChanged()
|
|
247
|
+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "system")
|
|
248
|
+
|
|
249
|
+
suspend fun currentSettings(): UserSettings = dataStore.data.first()
|
|
250
|
+
|
|
251
|
+
fun setSyncInterval(minutes: Int) {
|
|
252
|
+
viewModelScope.launch {
|
|
253
|
+
dataStore.updateData { current -> current.copy(syncIntervalMinutes = minutes) }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Testing
|
|
260
|
+
|
|
261
|
+
```kotlin
|
|
262
|
+
class UserSettingsDataStoreTest {
|
|
263
|
+
|
|
264
|
+
@get:Rule val tmp = TemporaryFolder()
|
|
265
|
+
|
|
266
|
+
private lateinit var scope: TestScope
|
|
267
|
+
private lateinit var dataStore: DataStore<UserSettings>
|
|
268
|
+
|
|
269
|
+
@Before
|
|
270
|
+
fun setUp() {
|
|
271
|
+
scope = TestScope(UnconfinedTestDispatcher())
|
|
272
|
+
dataStore = DataStoreFactory.create(
|
|
273
|
+
serializer = UserSettingsSerializer,
|
|
274
|
+
scope = scope.backgroundScope,
|
|
275
|
+
produceFile = { tmp.newFile("user_settings.json") }
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@Test
|
|
280
|
+
fun writeThenRead_returnsUpdatedValue() = scope.runTest {
|
|
281
|
+
dataStore.updateData { it.copy(theme = "dark") }
|
|
282
|
+
val result = dataStore.data.first()
|
|
283
|
+
assertEquals("dark", result.theme)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@Test
|
|
287
|
+
fun corruptEncryptedFile_failsSafeToDefault() = scope.runTest {
|
|
288
|
+
val file = tmp.newFile("secure.bin").apply { writeBytes(byteArrayOf(9, 9, 9)) }
|
|
289
|
+
val store = DataStoreFactory.create(
|
|
290
|
+
serializer = EncryptingSerializer(UserSettingsSerializer, FakeCipherProvider()),
|
|
291
|
+
scope = scope.backgroundScope,
|
|
292
|
+
produceFile = { file }
|
|
293
|
+
)
|
|
294
|
+
assertEquals(UserSettings(), store.data.first())
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: android-design-tokens-codegen
|
|
3
|
+
description: "A single-source JSON to typed-Kotlin codegen pipeline for design tokens, UI-test ids, analytics events and deeplink manifests, run as Gradle tasks with a --check CI freshness gate. Use when a design system or cross-platform app needs one source of truth that generates typed code."
|
|
4
|
+
risk: safe
|
|
5
|
+
source: multi-agent-pipeline
|
|
6
|
+
date_added: "2026-09-21"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Android Design Tokens Codegen
|
|
10
|
+
|
|
11
|
+
A pipeline that treats one or more JSON files as the single source of truth and
|
|
12
|
+
generates typed Kotlin from them at build time: design tokens, UI-test
|
|
13
|
+
identifiers, analytics event definitions, and deeplink manifests. Each generator
|
|
14
|
+
is a thin Gradle task that wraps a generator binary or script, runs before
|
|
15
|
+
compilation, is incremental, and exposes a check mode so CI can fail when the
|
|
16
|
+
committed output is stale. Targets AGP 8.x+, Gradle 8.x+, and KSP-era Kotlin
|
|
17
|
+
(2024-2025).
|
|
18
|
+
|
|
19
|
+
Full, copy-ready Gradle and Kotlin for every section below lives in
|
|
20
|
+
[references/patterns.md](references/patterns.md). Load that file when you need a
|
|
21
|
+
complete task, plugin, or generator example to adapt.
|
|
22
|
+
|
|
23
|
+
## Contents
|
|
24
|
+
|
|
25
|
+
- [Why single-source codegen](#why-single-source-codegen)
|
|
26
|
+
- [Pipeline shape](#pipeline-shape)
|
|
27
|
+
- [The Gradle task wrapper](#the-gradle-task-wrapper)
|
|
28
|
+
- [Wiring into preBuild](#wiring-into-prebuild)
|
|
29
|
+
- [Check mode and the CI contract](#check-mode-and-the-ci-contract)
|
|
30
|
+
- [Caching and incrementality](#caching-and-incrementality)
|
|
31
|
+
- [The four generated domains](#the-four-generated-domains)
|
|
32
|
+
- [Do's and Don'ts](#dos-and-donts)
|
|
33
|
+
- [Review Checklist](#review-checklist)
|
|
34
|
+
- [Ownership map](#ownership-map)
|
|
35
|
+
|
|
36
|
+
## Why single-source codegen
|
|
37
|
+
|
|
38
|
+
Constants that describe the same thing in more than one place drift: a color
|
|
39
|
+
value edited in a stylesheet but not in Kotlin, a test tag renamed in the UI but
|
|
40
|
+
not in the test, an analytics event whose name no longer matches the dashboard,
|
|
41
|
+
a deeplink path the app parses one way and the marketing team links another.
|
|
42
|
+
Hand-written constant files are the drift surface.
|
|
43
|
+
|
|
44
|
+
A codegen pipeline removes the surface. The JSON is authored (or exported from a
|
|
45
|
+
design tool) once; a generator turns it into typed Kotlin; nothing downstream is
|
|
46
|
+
hand-edited. Because the output is typed, a removed or renamed token becomes a
|
|
47
|
+
compile error at every call site rather than a silent runtime miss. Reviewers
|
|
48
|
+
diff the JSON, not the generated Kotlin.
|
|
49
|
+
|
|
50
|
+
In a cross-platform shop the same JSON feeds every platform's generator, so the
|
|
51
|
+
guarantee widens: one schema, many typed outputs, no per-platform divergence.
|
|
52
|
+
The mechanism below is platform-neutral in its input and Android-specific only
|
|
53
|
+
in what it emits.
|
|
54
|
+
|
|
55
|
+
## Pipeline shape
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
tokens.json ─┐
|
|
59
|
+
test-ids.json ┤ ┌─ generateDesignTokens ─┐
|
|
60
|
+
analytics.json┤─ JSON ─┤─ generateTestIds ├─ typed Kotlin ─ compile
|
|
61
|
+
deeplinks.json┘ (SoT) └─ generate... ┘ (build dir)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each JSON file is an input; each generator is one Gradle task; each task writes
|
|
65
|
+
Kotlin into a generated-source directory under the build folder, which is added
|
|
66
|
+
to the Kotlin source set. Generated code is not committed unless a check-mode
|
|
67
|
+
contract requires it (see below); either way it is never hand-edited.
|
|
68
|
+
|
|
69
|
+
## The Gradle task wrapper
|
|
70
|
+
|
|
71
|
+
Keep the generator itself out of the build script. The task's only job is to
|
|
72
|
+
declare inputs and outputs, then invoke a binary or script. A minimal typed task
|
|
73
|
+
looks like this:
|
|
74
|
+
|
|
75
|
+
```kotlin
|
|
76
|
+
abstract class GenerateFromSchemaTask : DefaultTask() {
|
|
77
|
+
@get:InputFile
|
|
78
|
+
@get:PathSensitive(PathSensitivity.RELATIVE)
|
|
79
|
+
abstract val schema: RegularFileProperty
|
|
80
|
+
|
|
81
|
+
@get:OutputDirectory
|
|
82
|
+
abstract val outputDir: DirectoryProperty
|
|
83
|
+
|
|
84
|
+
@get:Input
|
|
85
|
+
abstract val checkMode: Property<Boolean>
|
|
86
|
+
|
|
87
|
+
@get:Inject
|
|
88
|
+
abstract val execOps: ExecOperations
|
|
89
|
+
|
|
90
|
+
@TaskAction
|
|
91
|
+
fun generate() {
|
|
92
|
+
execOps.exec {
|
|
93
|
+
executable = "codegen"
|
|
94
|
+
args(
|
|
95
|
+
"--schema", schema.get().asFile.absolutePath,
|
|
96
|
+
"--out", outputDir.get().asFile.absolutePath,
|
|
97
|
+
)
|
|
98
|
+
if (checkMode.get()) args("--check")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Register one instance per domain and point each at its own schema and output
|
|
105
|
+
directory. The generator binary can be a bundled JAR run via `javaexec`, a CLI
|
|
106
|
+
on the path, or a `JavaExec`/`WorkAction`; the wrapper contract is identical.
|
|
107
|
+
Full registration, `WorkAction` isolation, and `javaexec` variants:
|
|
108
|
+
[references/patterns.md#task-wrapper](references/patterns.md#task-wrapper).
|
|
109
|
+
|
|
110
|
+
## Wiring into preBuild
|
|
111
|
+
|
|
112
|
+
Generation must finish before Kotlin compiles. Depend on the task from
|
|
113
|
+
`preBuild` and register its output as a generated source directory so the
|
|
114
|
+
compiler and the IDE both see it:
|
|
115
|
+
|
|
116
|
+
```kotlin
|
|
117
|
+
val generateTokens = tasks.register<GenerateFromSchemaTask>("generateDesignTokens") {
|
|
118
|
+
schema.set(layout.projectDirectory.file("schema/tokens.json"))
|
|
119
|
+
outputDir.set(layout.buildDirectory.dir("generated/source/tokens"))
|
|
120
|
+
checkMode.set(providers.gradleProperty("codegen.check").isPresent)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
androidComponents.onVariants { variant ->
|
|
124
|
+
variant.sources.java?.addGeneratedSourceDirectory(
|
|
125
|
+
generateTokens,
|
|
126
|
+
GenerateFromSchemaTask::outputDir,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
tasks.named("preBuild").configure { dependsOn(generateTokens) }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`addGeneratedSourceDirectory` is the modern AGP variant API; it wires the task
|
|
134
|
+
dependency and the source set in one call. Prefer it to manually mutating
|
|
135
|
+
`sourceSets`. Full multi-domain wiring and the KSP-consumer ordering note:
|
|
136
|
+
[references/patterns.md#prebuild-wiring](references/patterns.md#prebuild-wiring).
|
|
137
|
+
|
|
138
|
+
## Check mode and the CI contract
|
|
139
|
+
|
|
140
|
+
The build must fail when someone edits a schema and forgets to regenerate, or
|
|
141
|
+
edits generated Kotlin by hand. Give every generator a `--check` (dry-run) mode:
|
|
142
|
+
it regenerates into a temp location, compares against what is on disk, and exits
|
|
143
|
+
non-zero on any difference instead of writing.
|
|
144
|
+
|
|
145
|
+
Two workable contracts, pick one per repo:
|
|
146
|
+
|
|
147
|
+
1. Output committed. Generated Kotlin lives in `src` and is tracked. CI runs the
|
|
148
|
+
generators in check mode; a diff means the commit is stale and the build
|
|
149
|
+
fails. Contributors run the plain generate task and commit the result.
|
|
150
|
+
2. Output not committed. Generated Kotlin lives only in the build dir and is
|
|
151
|
+
produced on every build. CI still runs check mode against a schema-derived
|
|
152
|
+
fixture to prove the generator is deterministic and the schema is valid.
|
|
153
|
+
|
|
154
|
+
Contract 1 is the common choice because it keeps generated code reviewable and
|
|
155
|
+
makes the failure legible ("run ./gradlew generateDesignTokens and commit").
|
|
156
|
+
Wire the check task into the verification lifecycle so it runs in CI:
|
|
157
|
+
|
|
158
|
+
```kotlin
|
|
159
|
+
val checkCodegen = tasks.register("checkCodegenFresh") {
|
|
160
|
+
dependsOn(generateTokens, generateTestIds, generateAnalytics, generateDeeplinks)
|
|
161
|
+
}
|
|
162
|
+
tasks.named("check").configure { dependsOn(checkCodegen) }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Determinism is a hard requirement for check mode: stable key ordering, fixed
|
|
166
|
+
formatting, no timestamps, no absolute paths in the output. A non-deterministic
|
|
167
|
+
generator makes check mode flap. Full check-mode task, the failure message
|
|
168
|
+
pattern, and a determinism checklist:
|
|
169
|
+
[references/patterns.md#check-mode](references/patterns.md#check-mode).
|
|
170
|
+
|
|
171
|
+
## Caching and incrementality
|
|
172
|
+
|
|
173
|
+
Declaring `@InputFile` and `@OutputDirectory` (as above) is what lets Gradle
|
|
174
|
+
skip a generator when nothing changed and pull the result from the build cache
|
|
175
|
+
when it did before. With the inputs and outputs declared, the task is up-to-date
|
|
176
|
+
whenever the schema and generator version are unchanged, so a normal build pays
|
|
177
|
+
for generation only on the first run and after a schema edit.
|
|
178
|
+
|
|
179
|
+
Two rules keep this honest:
|
|
180
|
+
|
|
181
|
+
- Every input the output depends on is a declared input. If the generator's own
|
|
182
|
+
version can change the output, declare it as an `@Input` (a version string or
|
|
183
|
+
the generator artifact as an `@Classpath` input) so a generator upgrade
|
|
184
|
+
invalidates the cache instead of serving stale output.
|
|
185
|
+
- Nothing outside `@OutputDirectory` is written. Side-writes defeat
|
|
186
|
+
up-to-date checks and corrupt the cache.
|
|
187
|
+
|
|
188
|
+
Mark the task cacheable and keep `PathSensitivity.RELATIVE` on the schema so the
|
|
189
|
+
cache key is portable across machines and CI checkouts. `@CacheableTask`,
|
|
190
|
+
`@Classpath` generator input, and a `gradle.properties` snippet enabling the
|
|
191
|
+
build and configuration caches: [references/patterns.md#caching](references/patterns.md#caching).
|
|
192
|
+
|
|
193
|
+
## The four generated domains
|
|
194
|
+
|
|
195
|
+
The same wrapper shape serves four schema families. They differ only in the JSON
|
|
196
|
+
they read and the Kotlin they emit; keep one generator and one task per family
|
|
197
|
+
rather than one mega-generator, so a stale check names the domain that drifted.
|
|
198
|
+
|
|
199
|
+
- Design tokens. Colors, spacing, typography, radii, elevation as typed
|
|
200
|
+
constants or a semantic data class. The generated data class is consumed by
|
|
201
|
+
the Theme layer (see ownership map), not read directly by feature code.
|
|
202
|
+
- UI-test identifiers. One typed constant per test tag, referenced by both the
|
|
203
|
+
`Modifier.testTag` call site in the UI and the test that asserts on it, so a
|
|
204
|
+
rename is a compile error on both sides.
|
|
205
|
+
- Analytics events. Event name plus its typed parameter set, so tracking calls
|
|
206
|
+
cannot pass an unknown event or misspell a parameter key.
|
|
207
|
+
- Deeplink manifests. The route paths and their typed parameters the app parses,
|
|
208
|
+
generated from the same manifest that documents them, so parser and
|
|
209
|
+
documentation cannot diverge.
|
|
210
|
+
|
|
211
|
+
Neutral emit shapes for each domain (a token data class, a test-tag object, a
|
|
212
|
+
sealed analytics event, a deeplink route enum) are in
|
|
213
|
+
[references/patterns.md#generated-shapes](references/patterns.md#generated-shapes).
|
|
214
|
+
Treat those as illustrative structure, not a prescribed token set.
|
|
215
|
+
|
|
216
|
+
## Do's and Don'ts
|
|
217
|
+
|
|
218
|
+
- Do keep the generator binary versioned and declared as a task input.
|
|
219
|
+
- Do make output deterministic; check mode depends on it.
|
|
220
|
+
- Do run check mode in CI on the `check` lifecycle.
|
|
221
|
+
- Do keep one generator per domain.
|
|
222
|
+
- Don't hand-edit generated Kotlin; edit the schema and regenerate.
|
|
223
|
+
- Don't put generator logic inline in `build.gradle.kts`; wrap a binary.
|
|
224
|
+
- Don't write outside the declared `@OutputDirectory`.
|
|
225
|
+
- Don't emit timestamps, absolute paths, or unstable ordering.
|
|
226
|
+
- Don't let a generator reach the network at build time; the schema is local.
|
|
227
|
+
|
|
228
|
+
## Review Checklist
|
|
229
|
+
|
|
230
|
+
- [ ] Each generator is a typed task with declared `@InputFile`/`@OutputDirectory`.
|
|
231
|
+
- [ ] Output directory registered via `addGeneratedSourceDirectory`.
|
|
232
|
+
- [ ] `preBuild` depends on every generator.
|
|
233
|
+
- [ ] `--check`/dry-run mode exists and is wired into `check` for CI.
|
|
234
|
+
- [ ] Generator version is a declared task input (cache invalidates on upgrade).
|
|
235
|
+
- [ ] Output is deterministic (stable ordering, no timestamps/paths).
|
|
236
|
+
- [ ] `@CacheableTask` with `PathSensitivity.RELATIVE` on schema inputs.
|
|
237
|
+
- [ ] One generator per domain (tokens, test-ids, analytics, deeplinks).
|
|
238
|
+
- [ ] No hand-edits to generated Kotlin.
|
|
239
|
+
|
|
240
|
+
## Ownership map
|
|
241
|
+
|
|
242
|
+
This skill owns the single-source-JSON to typed-Kotlin generation mechanism and
|
|
243
|
+
its Gradle/CI wiring. It does not cover:
|
|
244
|
+
|
|
245
|
+
- Consuming the generated semantic token data class in Compose (Theme accessor,
|
|
246
|
+
`CompositionLocal`, dynamic color) -> `compose-components`.
|
|
247
|
+
- General convention-plugin and version-catalog build setup -> `gradle-kotlin-dsl`.
|
|
248
|
+
- Build-failing quality gates in general (lint, detekt, coverage thresholds) ->
|
|
249
|
+
`android-build-quality-gates`.
|