@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.
- package/CHANGELOG.md +55 -0
- package/README.md +5 -5
- package/README.tr.md +5 -5
- package/SECURITY.md +3 -3
- package/docs/adr/0011-dormant-ci.md +10 -1
- package/docs/architecture.md +2 -2
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +8 -7
- package/install/_codex-agents.mjs +1 -1
- package/manifest.json +92 -64
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +2 -2
- package/pipeline/agents/dev-critic.md +5 -5
- package/pipeline/agents/security-auditor.md +80 -72
- package/pipeline/commands/figma-to-swiftui.md +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/diff-explain/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/scan/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/security-review/SKILL.md +52 -0
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/multi-agent-refs/component-dispatch.md +5 -5
- package/pipeline/multi-agent-refs/cross-cli-contract.md +6 -6
- package/pipeline/multi-agent-refs/features/security-audit.md +55 -0
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +9 -15
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +1 -1
- package/pipeline/multi-agent-refs/threat-model.md +39 -0
- package/pipeline/schemas/agent-state.schema.json +23 -0
- package/pipeline/schemas/phases.json +1 -2
- package/pipeline/schemas/prefs.schema.json +0 -4
- package/pipeline/schemas/reviewer-output.schema.json +99 -2
- package/pipeline/schemas/security-finding.schema.json +144 -0
- package/pipeline/scripts/_stack-routing.mjs +1 -0
- package/pipeline/scripts/gc-abandoned.sh +16 -9
- package/pipeline/scripts/render-work-summary.sh +7 -4
- package/pipeline/skills/.skill-manifest.json +47 -23
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +3 -4
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-security-review/SKILL.md +29 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- 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/security-review/SKILL.md +64 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-mobile-top10-2024.md +53 -0
- package/pipeline/skills/shared/external/security-review/references/owasp-web-api-top10-2021.md +56 -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
- package/pipeline/commands/security-review.md +0 -6
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Android MVI BaseViewModel Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples backing the guidance in `SKILL.md`. Load this file when you
|
|
4
|
+
need a complete implementation to adapt. Headings mirror the SKILL.md sections.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Assembled BaseViewModel](#assembled-baseviewmodel)
|
|
9
|
+
- [MVI contracts for a screen](#mvi-contracts-for-a-screen)
|
|
10
|
+
- [A concrete ViewModel](#a-concrete-viewmodel)
|
|
11
|
+
- [Lifecycle-aware event collector](#lifecycle-aware-event-collector)
|
|
12
|
+
- [Stateless screen composable](#stateless-screen-composable)
|
|
13
|
+
- [Testing the ViewModel](#testing-the-viewmodel)
|
|
14
|
+
|
|
15
|
+
## Assembled BaseViewModel
|
|
16
|
+
|
|
17
|
+
The complete base class combining state, events, intent dispatch, the use-case
|
|
18
|
+
executor, reference-counted loading, and the job registry.
|
|
19
|
+
|
|
20
|
+
```kotlin
|
|
21
|
+
import androidx.lifecycle.ViewModel
|
|
22
|
+
import androidx.lifecycle.viewModelScope
|
|
23
|
+
import kotlinx.coroutines.CancellationException
|
|
24
|
+
import kotlinx.coroutines.Job
|
|
25
|
+
import kotlinx.coroutines.channels.Channel
|
|
26
|
+
import kotlinx.coroutines.flow.Flow
|
|
27
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
28
|
+
import kotlinx.coroutines.flow.SharingStarted
|
|
29
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
30
|
+
import kotlinx.coroutines.flow.asStateFlow
|
|
31
|
+
import kotlinx.coroutines.flow.map
|
|
32
|
+
import kotlinx.coroutines.flow.receiveAsFlow
|
|
33
|
+
import kotlinx.coroutines.flow.stateIn
|
|
34
|
+
import kotlinx.coroutines.flow.update
|
|
35
|
+
import kotlinx.coroutines.launch
|
|
36
|
+
|
|
37
|
+
abstract class BaseViewModel<State, Intent, Event>(
|
|
38
|
+
initialState: State,
|
|
39
|
+
) : ViewModel() {
|
|
40
|
+
|
|
41
|
+
private val _state = MutableStateFlow(initialState)
|
|
42
|
+
val state: StateFlow<State> = _state.asStateFlow()
|
|
43
|
+
|
|
44
|
+
private val _events = Channel<Event>(Channel.RENDEZVOUS)
|
|
45
|
+
val events: Flow<Event> = _events.receiveAsFlow()
|
|
46
|
+
|
|
47
|
+
private val loadingCount = MutableStateFlow(0)
|
|
48
|
+
val isLoading: StateFlow<Boolean> =
|
|
49
|
+
loadingCount
|
|
50
|
+
.map { it > 0 }
|
|
51
|
+
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
|
52
|
+
|
|
53
|
+
private val jobs = mutableMapOf<String, Job>()
|
|
54
|
+
|
|
55
|
+
fun processIntent(intent: Intent) = handleIntent(intent)
|
|
56
|
+
|
|
57
|
+
protected abstract fun handleIntent(intent: Intent)
|
|
58
|
+
|
|
59
|
+
protected open fun routeError(throwable: Throwable) = Unit
|
|
60
|
+
|
|
61
|
+
protected fun reduce(reducer: State.() -> State) {
|
|
62
|
+
_state.update { it.reducer() }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected fun sendEvent(event: Event) {
|
|
66
|
+
viewModelScope.launch { _events.send(event) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected fun <T> execute(
|
|
70
|
+
showLoading: Boolean = true,
|
|
71
|
+
onError: ((Throwable) -> Unit)? = null,
|
|
72
|
+
block: suspend () -> T,
|
|
73
|
+
onSuccess: (T) -> Unit,
|
|
74
|
+
): Job = viewModelScope.launch {
|
|
75
|
+
if (showLoading) incrementLoading()
|
|
76
|
+
try {
|
|
77
|
+
runCatching { block() }
|
|
78
|
+
.onSuccess(onSuccess)
|
|
79
|
+
.onFailure { throwable ->
|
|
80
|
+
if (throwable is CancellationException) throw throwable
|
|
81
|
+
(onError ?: ::routeError)(throwable)
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
if (showLoading) decrementLoading()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
protected fun launchTracked(key: String, block: suspend () -> Unit): Job {
|
|
89
|
+
jobs.remove(key)?.cancel()
|
|
90
|
+
return viewModelScope.launch { block() }.also { jobs[key] = it }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
protected fun cancel(key: String) {
|
|
94
|
+
jobs.remove(key)?.cancel()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private fun incrementLoading() = loadingCount.update { it + 1 }
|
|
98
|
+
|
|
99
|
+
private fun decrementLoading() =
|
|
100
|
+
loadingCount.update { (it - 1).coerceAtLeast(0) }
|
|
101
|
+
|
|
102
|
+
override fun onCleared() {
|
|
103
|
+
jobs.values.forEach { it.cancel() }
|
|
104
|
+
jobs.clear()
|
|
105
|
+
super.onCleared()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## MVI contracts for a screen
|
|
111
|
+
|
|
112
|
+
State is the render model, Intent is every input, Event is every one-shot
|
|
113
|
+
effect. Keep all three next to the screen they serve.
|
|
114
|
+
|
|
115
|
+
```kotlin
|
|
116
|
+
@Immutable
|
|
117
|
+
data class SearchState(
|
|
118
|
+
val query: String = "",
|
|
119
|
+
val results: List<ResultItem> = emptyList(),
|
|
120
|
+
val error: String? = null,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
sealed interface SearchIntent {
|
|
124
|
+
data class QueryChanged(val text: String) : SearchIntent
|
|
125
|
+
data object Submit : SearchIntent
|
|
126
|
+
data object Retry : SearchIntent
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
sealed interface SearchEvent {
|
|
130
|
+
data class OpenDetail(val id: Id) : SearchEvent
|
|
131
|
+
data class ShowError(val message: String) : SearchEvent
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## A concrete ViewModel
|
|
136
|
+
|
|
137
|
+
The subclass supplies the initial state, implements the exhaustive `when`, and
|
|
138
|
+
uses the inherited helpers - no coroutine, loading, or error boilerplate.
|
|
139
|
+
|
|
140
|
+
```kotlin
|
|
141
|
+
@HiltViewModel
|
|
142
|
+
class SearchViewModel @Inject constructor(
|
|
143
|
+
private val search: SearchUseCase,
|
|
144
|
+
) : BaseViewModel<SearchState, SearchIntent, SearchEvent>(SearchState()) {
|
|
145
|
+
|
|
146
|
+
override fun handleIntent(intent: SearchIntent) = when (intent) {
|
|
147
|
+
is SearchIntent.QueryChanged -> reduce { copy(query = intent.text) }
|
|
148
|
+
SearchIntent.Submit -> runSearch()
|
|
149
|
+
SearchIntent.Retry -> runSearch()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun runSearch() {
|
|
153
|
+
val query = state.value.query
|
|
154
|
+
launchTracked("search") {
|
|
155
|
+
execute(
|
|
156
|
+
block = { search(query) },
|
|
157
|
+
onError = { sendEvent(SearchEvent.ShowError(it.toUserMessage())) },
|
|
158
|
+
onSuccess = { results -> reduce { copy(results = results, error = null) } },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
override fun routeError(throwable: Throwable) {
|
|
164
|
+
sendEvent(SearchEvent.ShowError(throwable.toUserMessage()))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Keying `launchTracked("search")` makes each new submit cancel the previous
|
|
170
|
+
in-flight request automatically.
|
|
171
|
+
|
|
172
|
+
## Lifecycle-aware event collector
|
|
173
|
+
|
|
174
|
+
Collect one-shot events only while the screen is at least `STARTED`, so a
|
|
175
|
+
backgrounded screen never consumes a navigation. `flowWithLifecycle` restarts
|
|
176
|
+
collection across stop/start without leaking the previous coroutine.
|
|
177
|
+
|
|
178
|
+
```kotlin
|
|
179
|
+
@Composable
|
|
180
|
+
fun <Event> ObserveEvents(
|
|
181
|
+
events: Flow<Event>,
|
|
182
|
+
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
|
183
|
+
onEvent: (Event) -> Unit,
|
|
184
|
+
) {
|
|
185
|
+
LaunchedEffect(events, lifecycleOwner) {
|
|
186
|
+
events
|
|
187
|
+
.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
|
|
188
|
+
.collect(onEvent)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Stateless screen composable
|
|
194
|
+
|
|
195
|
+
The screen receives a state value and lambdas only. It has no ViewModel
|
|
196
|
+
reference, so it renders in a `@Preview` and in tests without Hilt.
|
|
197
|
+
|
|
198
|
+
```kotlin
|
|
199
|
+
@Composable
|
|
200
|
+
fun SearchScreen(
|
|
201
|
+
state: SearchState,
|
|
202
|
+
isLoading: Boolean,
|
|
203
|
+
onIntent: (SearchIntent) -> Unit,
|
|
204
|
+
) {
|
|
205
|
+
Column {
|
|
206
|
+
SearchField(
|
|
207
|
+
value = state.query,
|
|
208
|
+
onValueChange = { onIntent(SearchIntent.QueryChanged(it)) },
|
|
209
|
+
onSubmit = { onIntent(SearchIntent.Submit) },
|
|
210
|
+
)
|
|
211
|
+
when {
|
|
212
|
+
isLoading -> LoadingIndicator()
|
|
213
|
+
state.error != null -> ErrorState(
|
|
214
|
+
message = state.error,
|
|
215
|
+
onRetry = { onIntent(SearchIntent.Retry) },
|
|
216
|
+
)
|
|
217
|
+
else -> ResultList(items = state.results)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Testing the ViewModel
|
|
224
|
+
|
|
225
|
+
Drive intents, assert on state with Turbine, and assert one-shot events on the
|
|
226
|
+
`events` flow. Use a `TestDispatcher` so `viewModelScope` is controllable.
|
|
227
|
+
|
|
228
|
+
```kotlin
|
|
229
|
+
@Test
|
|
230
|
+
fun submit_emitsResults() = runTest {
|
|
231
|
+
val viewModel = SearchViewModel(FakeSearchUseCase(results = sample))
|
|
232
|
+
|
|
233
|
+
viewModel.state.test {
|
|
234
|
+
assertEquals(SearchState(), awaitItem())
|
|
235
|
+
|
|
236
|
+
viewModel.processIntent(SearchIntent.QueryChanged("abc"))
|
|
237
|
+
assertEquals("abc", awaitItem().query)
|
|
238
|
+
|
|
239
|
+
viewModel.processIntent(SearchIntent.Submit)
|
|
240
|
+
assertEquals(sample, awaitItem().results)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@Test
|
|
245
|
+
fun useCaseFailure_emitsShowErrorEvent() = runTest {
|
|
246
|
+
val viewModel = SearchViewModel(FakeSearchUseCase(error = IOException()))
|
|
247
|
+
|
|
248
|
+
viewModel.events.test {
|
|
249
|
+
viewModel.processIntent(SearchIntent.Submit)
|
|
250
|
+
assertIs<SearchEvent.ShowError>(awaitItem())
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Set the main dispatcher to a `StandardTestDispatcher` in `@Before` /
|
|
256
|
+
`Dispatchers.setMain(...)` and reset it in `@After` so `viewModelScope`
|
|
257
|
+
coroutines run under test control.
|