@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
|
@@ -9,12 +9,20 @@ Networking patterns for Android using Retrofit 2.11+, OkHttp 4.x, and
|
|
|
9
9
|
kotlinx.serialization. Covers API definition, interceptors, error handling,
|
|
10
10
|
caching, and Hilt integration.
|
|
11
11
|
|
|
12
|
+
Full code for every pattern below lives in
|
|
13
|
+
[`references/patterns.md`](references/patterns.md), under headings that mirror
|
|
14
|
+
these sections. This file is the guide: read a section for the decision, then
|
|
15
|
+
load the matching reference section when writing the implementation.
|
|
16
|
+
|
|
12
17
|
## Contents
|
|
13
18
|
|
|
14
19
|
- [API Interface](#api-interface)
|
|
15
20
|
- [Retrofit Setup with Hilt](#retrofit-setup-with-hilt)
|
|
16
21
|
- [OkHttp Interceptors](#okhttp-interceptors)
|
|
17
22
|
- [Error Handling](#error-handling)
|
|
23
|
+
- [Envelope to Typed Exception](#envelope-to-typed-exception)
|
|
24
|
+
- [Unknown-Tolerant Enum Serialization](#unknown-tolerant-enum-serialization)
|
|
25
|
+
- [Client Composition and Environment Config](#client-composition-and-environment-config)
|
|
18
26
|
- [Network-Bound Resource](#network-bound-resource)
|
|
19
27
|
- [Multipart Uploads](#multipart-uploads)
|
|
20
28
|
- [WebSocket Support](#websocket-support)
|
|
@@ -24,232 +32,52 @@ caching, and Hilt integration.
|
|
|
24
32
|
|
|
25
33
|
## API Interface
|
|
26
34
|
|
|
27
|
-
Define endpoints as suspend functions
|
|
35
|
+
Define endpoints as `suspend` functions so Retrofit integrates with coroutines
|
|
36
|
+
directly (no `Call<T>`/`enqueue`). Use `@Query`, `@Path`, `@Body` and `@Header`
|
|
37
|
+
for request shaping, and annotate DTOs with `@Serializable` plus `@SerialName`
|
|
38
|
+
for JSON keys that differ from Kotlin property names.
|
|
28
39
|
|
|
29
40
|
```kotlin
|
|
30
41
|
interface FlightApiService {
|
|
31
|
-
|
|
32
42
|
@GET("flights")
|
|
33
43
|
suspend fun searchFlights(
|
|
34
44
|
@Query("origin") origin: String,
|
|
35
45
|
@Query("destination") destination: String,
|
|
36
|
-
@Query("date") date: String,
|
|
37
|
-
): List<FlightDto>
|
|
38
|
-
|
|
39
|
-
@GET("flights/{id}")
|
|
40
|
-
suspend fun getFlightDetail(
|
|
41
|
-
@Path("id") flightId: String,
|
|
42
|
-
): FlightDetailDto
|
|
43
|
-
|
|
44
|
-
@POST("bookings")
|
|
45
|
-
suspend fun createBooking(
|
|
46
|
-
@Body request: BookingRequest,
|
|
47
|
-
): BookingResponse
|
|
48
|
-
|
|
49
|
-
@PUT("bookings/{id}")
|
|
50
|
-
suspend fun updateBooking(
|
|
51
|
-
@Path("id") bookingId: String,
|
|
52
|
-
@Body request: UpdateBookingRequest,
|
|
53
|
-
): BookingResponse
|
|
54
|
-
|
|
55
|
-
@DELETE("bookings/{id}")
|
|
56
|
-
suspend fun cancelBooking(
|
|
57
|
-
@Path("id") bookingId: String,
|
|
58
|
-
): Response<Unit>
|
|
59
|
-
|
|
60
|
-
@GET("flights")
|
|
61
|
-
suspend fun searchFlightsWithHeaders(
|
|
62
|
-
@Query("origin") origin: String,
|
|
63
|
-
@Header("Accept-Language") language: String = "en",
|
|
64
46
|
): List<FlightDto>
|
|
65
47
|
}
|
|
66
48
|
```
|
|
67
49
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```kotlin
|
|
71
|
-
@Serializable
|
|
72
|
-
data class FlightDto(
|
|
73
|
-
val id: String,
|
|
74
|
-
val origin: AirportDto,
|
|
75
|
-
val destination: AirportDto,
|
|
76
|
-
@SerialName("departure_time")
|
|
77
|
-
val departureTime: String,
|
|
78
|
-
@SerialName("arrival_time")
|
|
79
|
-
val arrivalTime: String,
|
|
80
|
-
val price: PriceDto,
|
|
81
|
-
val status: String,
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
@Serializable
|
|
85
|
-
data class AirportDto(
|
|
86
|
-
val code: String,
|
|
87
|
-
val name: String,
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
@Serializable
|
|
91
|
-
data class PriceDto(
|
|
92
|
-
val amount: Double,
|
|
93
|
-
val currency: String,
|
|
94
|
-
)
|
|
95
|
-
```
|
|
50
|
+
See `references/patterns.md` -> "API Interface" for the full endpoint set
|
|
51
|
+
(GET/POST/PUT/DELETE, header params) and the `@Serializable` DTO classes.
|
|
96
52
|
|
|
97
53
|
## Retrofit Setup with Hilt
|
|
98
54
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
object NetworkModule {
|
|
103
|
-
|
|
104
|
-
@Provides
|
|
105
|
-
@Singleton
|
|
106
|
-
fun provideJson(): Json = Json {
|
|
107
|
-
ignoreUnknownKeys = true
|
|
108
|
-
coerceInputValues = true
|
|
109
|
-
encodeDefaults = true
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
@Provides
|
|
113
|
-
@Singleton
|
|
114
|
-
fun provideOkHttpClient(
|
|
115
|
-
authInterceptor: AuthInterceptor,
|
|
116
|
-
loggingInterceptor: HttpLoggingInterceptor,
|
|
117
|
-
): OkHttpClient = OkHttpClient.Builder()
|
|
118
|
-
.connectTimeout(30, TimeUnit.SECONDS)
|
|
119
|
-
.readTimeout(30, TimeUnit.SECONDS)
|
|
120
|
-
.writeTimeout(30, TimeUnit.SECONDS)
|
|
121
|
-
.addInterceptor(authInterceptor)
|
|
122
|
-
.addInterceptor(loggingInterceptor)
|
|
123
|
-
.build()
|
|
124
|
-
|
|
125
|
-
@Provides
|
|
126
|
-
@Singleton
|
|
127
|
-
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
|
|
128
|
-
HttpLoggingInterceptor().apply {
|
|
129
|
-
level = if (BuildConfig.DEBUG) {
|
|
130
|
-
HttpLoggingInterceptor.Level.BODY
|
|
131
|
-
} else {
|
|
132
|
-
HttpLoggingInterceptor.Level.NONE
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
@Provides
|
|
137
|
-
@Singleton
|
|
138
|
-
fun provideRetrofit(
|
|
139
|
-
client: OkHttpClient,
|
|
140
|
-
json: Json,
|
|
141
|
-
): Retrofit = Retrofit.Builder()
|
|
142
|
-
.baseUrl(BuildConfig.BASE_URL)
|
|
143
|
-
.client(client)
|
|
144
|
-
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
|
145
|
-
.build()
|
|
146
|
-
|
|
147
|
-
@Provides
|
|
148
|
-
@Singleton
|
|
149
|
-
fun provideFlightApiService(retrofit: Retrofit): FlightApiService =
|
|
150
|
-
retrofit.create(FlightApiService::class.java)
|
|
151
|
-
}
|
|
152
|
-
```
|
|
55
|
+
Provide `Json`, `OkHttpClient`, `Retrofit`, and each API service as singletons in
|
|
56
|
+
a Hilt `@Module`. Configure `Json` with `ignoreUnknownKeys = true`, set explicit
|
|
57
|
+
timeouts (connect/read/write 30s), and read the base URL from `BuildConfig`.
|
|
153
58
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
### Auth Interceptor (Token Injection)
|
|
157
|
-
|
|
158
|
-
```kotlin
|
|
159
|
-
class AuthInterceptor @Inject constructor(
|
|
160
|
-
private val tokenProvider: TokenProvider,
|
|
161
|
-
) : Interceptor {
|
|
162
|
-
|
|
163
|
-
override fun intercept(chain: Interceptor.Chain): Response {
|
|
164
|
-
val token = tokenProvider.getAccessToken()
|
|
165
|
-
val request = chain.request().newBuilder().apply {
|
|
166
|
-
token?.let { addHeader("Authorization", "Bearer $it") }
|
|
167
|
-
}.build()
|
|
168
|
-
|
|
169
|
-
val response = chain.proceed(request)
|
|
170
|
-
|
|
171
|
-
// Handle 401: refresh token and retry
|
|
172
|
-
if (response.code == 401) {
|
|
173
|
-
response.close()
|
|
174
|
-
val newToken = tokenProvider.refreshToken()
|
|
175
|
-
?: return response
|
|
176
|
-
|
|
177
|
-
val retryRequest = chain.request().newBuilder()
|
|
178
|
-
.header("Authorization", "Bearer $newToken")
|
|
179
|
-
.build()
|
|
180
|
-
|
|
181
|
-
return chain.proceed(retryRequest)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return response
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
### Retry Interceptor
|
|
190
|
-
|
|
191
|
-
```kotlin
|
|
192
|
-
class RetryInterceptor(
|
|
193
|
-
private val maxRetries: Int = 3,
|
|
194
|
-
private val initialDelayMs: Long = 1_000,
|
|
195
|
-
) : Interceptor {
|
|
196
|
-
|
|
197
|
-
override fun intercept(chain: Interceptor.Chain): Response {
|
|
198
|
-
var lastException: IOException? = null
|
|
199
|
-
var delay = initialDelayMs
|
|
200
|
-
|
|
201
|
-
repeat(maxRetries) { attempt ->
|
|
202
|
-
try {
|
|
203
|
-
val response = chain.proceed(chain.request())
|
|
204
|
-
if (response.isSuccessful || response.code !in 500..599) {
|
|
205
|
-
return response
|
|
206
|
-
}
|
|
207
|
-
response.close()
|
|
208
|
-
} catch (e: IOException) {
|
|
209
|
-
lastException = e
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (attempt < maxRetries - 1) {
|
|
213
|
-
Thread.sleep(delay)
|
|
214
|
-
delay *= 2 // Exponential backoff
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
throw lastException ?: IOException("Request failed after $maxRetries retries")
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
```
|
|
59
|
+
See `references/patterns.md` -> "Retrofit Setup with Hilt" for the complete
|
|
60
|
+
`NetworkModule` with all `@Provides` functions.
|
|
222
61
|
|
|
223
|
-
|
|
62
|
+
## OkHttp Interceptors
|
|
224
63
|
|
|
225
|
-
|
|
226
|
-
class CacheInterceptor : Interceptor {
|
|
227
|
-
override fun intercept(chain: Interceptor.Chain): Response {
|
|
228
|
-
val response = chain.proceed(chain.request())
|
|
229
|
-
val cacheControl = CacheControl.Builder()
|
|
230
|
-
.maxAge(5, TimeUnit.MINUTES)
|
|
231
|
-
.build()
|
|
232
|
-
|
|
233
|
-
return response.newBuilder()
|
|
234
|
-
.header("Cache-Control", cacheControl.toString())
|
|
235
|
-
.removeHeader("Pragma")
|
|
236
|
-
.build()
|
|
237
|
-
}
|
|
238
|
-
}
|
|
64
|
+
Three interceptors cover most needs:
|
|
239
65
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
66
|
+
- Auth interceptor: attaches the bearer token and, on a 401, refreshes and
|
|
67
|
+
retries once. Keep token refresh here, not in every API call.
|
|
68
|
+
- Retry interceptor: retries 5xx / IO failures with exponential backoff.
|
|
69
|
+
- Cache interceptor: rewrites `Cache-Control` so GET responses cache for a bounded
|
|
70
|
+
window; pair it with an OkHttp `Cache` on the client.
|
|
243
71
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
.addNetworkInterceptor(CacheInterceptor())
|
|
247
|
-
.build()
|
|
248
|
-
```
|
|
72
|
+
See `references/patterns.md` -> "OkHttp Interceptors" for the `AuthInterceptor`,
|
|
73
|
+
`RetryInterceptor`, and `CacheInterceptor` implementations plus the cache wiring.
|
|
249
74
|
|
|
250
75
|
## Error Handling
|
|
251
76
|
|
|
252
|
-
|
|
77
|
+
Model outcomes with a sealed `NetworkResult` (Success / Error / Exception) and
|
|
78
|
+
wrap every call in `safeApiCall`. Always rethrow `CancellationException` -- never
|
|
79
|
+
let it fall into a generic catch. Repositories return `NetworkResult`; ViewModels
|
|
80
|
+
map it to UI state.
|
|
253
81
|
|
|
254
82
|
```kotlin
|
|
255
83
|
sealed interface NetworkResult<out T> {
|
|
@@ -259,203 +87,76 @@ sealed interface NetworkResult<out T> {
|
|
|
259
87
|
}
|
|
260
88
|
```
|
|
261
89
|
|
|
262
|
-
|
|
90
|
+
See `references/patterns.md` -> "Error Handling" for `safeApiCall` and the
|
|
91
|
+
repository + ViewModel consumption examples.
|
|
263
92
|
|
|
264
|
-
|
|
265
|
-
suspend fun <T> safeApiCall(
|
|
266
|
-
apiCall: suspend () -> T,
|
|
267
|
-
): NetworkResult<T> = try {
|
|
268
|
-
NetworkResult.Success(apiCall())
|
|
269
|
-
} catch (e: HttpException) {
|
|
270
|
-
val errorBody = e.response()?.errorBody()?.string()
|
|
271
|
-
NetworkResult.Error(
|
|
272
|
-
code = e.code(),
|
|
273
|
-
message = errorBody ?: e.message(),
|
|
274
|
-
)
|
|
275
|
-
} catch (e: IOException) {
|
|
276
|
-
NetworkResult.Exception(e)
|
|
277
|
-
} catch (e: CancellationException) {
|
|
278
|
-
throw e // Never swallow cancellation
|
|
279
|
-
}
|
|
280
|
-
```
|
|
93
|
+
## Envelope to Typed Exception
|
|
281
94
|
|
|
282
|
-
|
|
95
|
+
When the backend wraps every payload in a `success` envelope, fold that envelope
|
|
96
|
+
into the same failure path as transport errors instead of checking `success` at
|
|
97
|
+
every call site. A delegating `Converter.Factory` deserializes the envelope, and
|
|
98
|
+
when `success == false` throws a typed `ApiException(code, message)`. A central
|
|
99
|
+
throwable-to-sealed-error mapper then turns `ApiException`, `HttpException` and
|
|
100
|
+
IO failures into one `NetworkResult.Error`, so `safeApiCall` needs a single
|
|
101
|
+
catch path.
|
|
283
102
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
private val api: FlightApiService,
|
|
287
|
-
private val mapper: FlightMapper,
|
|
288
|
-
) : FlightRepository {
|
|
289
|
-
|
|
290
|
-
override suspend fun searchFlights(
|
|
291
|
-
origin: String,
|
|
292
|
-
destination: String,
|
|
293
|
-
date: LocalDate,
|
|
294
|
-
): NetworkResult<List<Flight>> = safeApiCall {
|
|
295
|
-
api.searchFlights(origin, destination, date.toString())
|
|
296
|
-
.map(mapper::toDomain)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
```
|
|
103
|
+
See `references/patterns.md` -> "Envelope to Typed Exception" for the
|
|
104
|
+
`EnvelopeUnwrapFactory`, `ApiException`, and the `toNetworkError` mapper.
|
|
300
105
|
|
|
301
|
-
|
|
106
|
+
## Unknown-Tolerant Enum Serialization
|
|
302
107
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
|
|
310
|
-
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
|
311
|
-
|
|
312
|
-
fun search(origin: String, destination: String, date: LocalDate) {
|
|
313
|
-
viewModelScope.launch {
|
|
314
|
-
_uiState.value = SearchUiState.Loading
|
|
315
|
-
|
|
316
|
-
_uiState.value = when (val result = repository.searchFlights(origin, destination, date)) {
|
|
317
|
-
is NetworkResult.Success -> SearchUiState.Success(result.data)
|
|
318
|
-
is NetworkResult.Error -> SearchUiState.Error("Server error: ${result.message}")
|
|
319
|
-
is NetworkResult.Exception -> SearchUiState.Error(
|
|
320
|
-
result.throwable.localizedMessage ?: "Network error"
|
|
321
|
-
)
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
```
|
|
108
|
+
`ignoreUnknownKeys` tolerates unknown object keys, not unknown enum values -- a
|
|
109
|
+
new server code crashes the whole response. Give backend-driven enums a base
|
|
110
|
+
`KSerializer` that maps any unrecognized value to a declared default (e.g.
|
|
111
|
+
`UNKNOWN`), so one added server code degrades a single field instead of failing
|
|
112
|
+
the payload.
|
|
327
113
|
|
|
328
|
-
|
|
114
|
+
See `references/patterns.md` -> "Unknown-Tolerant Enum Serialization" for the
|
|
115
|
+
`UnknownTolerantEnumSerializer` base class and an enum wired to it.
|
|
329
116
|
|
|
330
|
-
|
|
117
|
+
## Client Composition and Environment Config
|
|
331
118
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
val data = query().first()
|
|
342
|
-
|
|
343
|
-
val flow = if (shouldFetch(data)) {
|
|
344
|
-
emit(Resource.Loading(data))
|
|
345
|
-
try {
|
|
346
|
-
val fetchedData = fetch()
|
|
347
|
-
saveFetchResult(fetchedData)
|
|
348
|
-
query().map { Resource.Success(it) }
|
|
349
|
-
} catch (throwable: Throwable) {
|
|
350
|
-
if (throwable is CancellationException) throw throwable
|
|
351
|
-
query().map { Resource.Error(throwable, it) }
|
|
352
|
-
}
|
|
353
|
-
} else {
|
|
354
|
-
query().map { Resource.Success(it) }
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
emitAll(flow)
|
|
358
|
-
}
|
|
119
|
+
Derive specialized clients from one base with `newBuilder()`; an image pipeline
|
|
120
|
+
that must not carry auth headers clears inherited interceptors with
|
|
121
|
+
`newBuilder().interceptors().clear()`. Resolve the per-flavor base URL and
|
|
122
|
+
certificate-pin hashes from build config, and gate a runtime base-URL override
|
|
123
|
+
to non-production builds only (QA environment switching). For a suspend token
|
|
124
|
+
fetch inside `intercept()`, use the deadlock-safe bridge (`runBlocking` pinned to
|
|
125
|
+
a dedicated single-thread dispatcher) rather than a bare `runBlocking { }` that
|
|
126
|
+
resumes on the interceptor's own thread.
|
|
359
127
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
val error: Throwable? = null,
|
|
363
|
-
) {
|
|
364
|
-
class Success<T>(data: T) : Resource<T>(data)
|
|
365
|
-
class Loading<T>(data: T? = null) : Resource<T>(data)
|
|
366
|
-
class Error<T>(throwable: Throwable, data: T? = null) : Resource<T>(data, throwable)
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
### Usage
|
|
371
|
-
|
|
372
|
-
```kotlin
|
|
373
|
-
override fun getFlights(origin: String, destination: String): Flow<Resource<List<Flight>>> =
|
|
374
|
-
networkBoundResource(
|
|
375
|
-
query = {
|
|
376
|
-
dao.getFlights(origin, destination)
|
|
377
|
-
.map { entities -> entities.map(mapper::toDomain) }
|
|
378
|
-
},
|
|
379
|
-
fetch = { api.searchFlights(origin, destination, today()) },
|
|
380
|
-
saveFetchResult = { dtos ->
|
|
381
|
-
dao.upsertFlights(dtos.map(mapper::toEntity))
|
|
382
|
-
},
|
|
383
|
-
shouldFetch = { cachedFlights ->
|
|
384
|
-
cachedFlights.isEmpty() || cacheExpired()
|
|
385
|
-
},
|
|
386
|
-
)
|
|
387
|
-
```
|
|
128
|
+
See `references/patterns.md` -> "Client Composition and Environment Config" for
|
|
129
|
+
the image-client provider, `NetworkConfig`, and the `TokenInterceptor` bridge.
|
|
388
130
|
|
|
389
|
-
##
|
|
131
|
+
## Network-Bound Resource
|
|
390
132
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
@POST("documents/upload")
|
|
396
|
-
suspend fun uploadDocument(
|
|
397
|
-
@Part file: MultipartBody.Part,
|
|
398
|
-
@Part("description") description: RequestBody,
|
|
399
|
-
): UploadResponse
|
|
400
|
-
}
|
|
133
|
+
Use `networkBoundResource` to show cached data immediately while fetching fresh
|
|
134
|
+
data, then re-emit from the cache after saving. It emits a `Resource`
|
|
135
|
+
(Loading / Success / Error) that always carries the latest cached data. Rethrow
|
|
136
|
+
`CancellationException` inside the fetch block.
|
|
401
137
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
val inputStream = contentResolver.openInputStream(uri) ?: return
|
|
406
|
-
val fileName = getFileName(context, uri)
|
|
138
|
+
See `references/patterns.md` -> "Network-Bound Resource" for the
|
|
139
|
+
`networkBoundResource` builder, the `Resource` sealed class, and a DAO + API
|
|
140
|
+
usage example.
|
|
407
141
|
|
|
408
|
-
|
|
409
|
-
.toRequestBody("application/octet-stream".toMediaType())
|
|
142
|
+
## Multipart Uploads
|
|
410
143
|
|
|
411
|
-
|
|
412
|
-
|
|
144
|
+
Declare a `@Multipart @POST` endpoint taking `MultipartBody.Part` for the file
|
|
145
|
+
and `RequestBody` parts for metadata. Build the file part from the content
|
|
146
|
+
resolver stream with `createFormData`, matching the `@Part` name to the server's
|
|
147
|
+
expectation.
|
|
413
148
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
```
|
|
149
|
+
See `references/patterns.md` -> "Multipart Uploads" for the `FileApiService`
|
|
150
|
+
endpoint and the `uploadFile` helper.
|
|
417
151
|
|
|
418
152
|
## WebSocket Support
|
|
419
153
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
fun connect(flightId: String): Flow<FlightUpdate> = callbackFlow {
|
|
427
|
-
val request = Request.Builder()
|
|
428
|
-
.url("wss://api.example.com/flights/$flightId/updates")
|
|
429
|
-
.build()
|
|
430
|
-
|
|
431
|
-
val listener = object : WebSocketListener() {
|
|
432
|
-
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
433
|
-
val update = Json.decodeFromString<FlightUpdate>(text)
|
|
434
|
-
trySend(update)
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
438
|
-
close(t)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
442
|
-
channel.close()
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
webSocket = client.newWebSocket(request, listener)
|
|
447
|
-
|
|
448
|
-
awaitClose {
|
|
449
|
-
webSocket?.close(1000, "Client closed")
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
fun disconnect() {
|
|
454
|
-
webSocket?.close(1000, "Client closed")
|
|
455
|
-
webSocket = null
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
```
|
|
154
|
+
Wrap an OkHttp `WebSocket` in a `callbackFlow` so updates arrive as a cold `Flow`.
|
|
155
|
+
Forward `onMessage` with `trySend`, close the flow on `onFailure`/`onClosed`, and
|
|
156
|
+
close the socket in `awaitClose`.
|
|
157
|
+
|
|
158
|
+
See `references/patterns.md` -> "WebSocket Support" for the
|
|
159
|
+
`FlightUpdatesWebSocket` implementation.
|
|
459
160
|
|
|
460
161
|
## Do's and Don'ts
|
|
461
162
|
|
|
@@ -468,6 +169,10 @@ class FlightUpdatesWebSocket @Inject constructor(
|
|
|
468
169
|
- Implement token refresh in an interceptor, not in every API call
|
|
469
170
|
- Set reasonable timeouts (connect: 30s, read: 30s, write: 30s)
|
|
470
171
|
- Use OkHttp `Cache` for GET requests that tolerate staleness
|
|
172
|
+
- Fold a `success` response envelope into a typed `ApiException` in a converter
|
|
173
|
+
- Map backend-driven enums with an unknown-tolerant serializer (default value)
|
|
174
|
+
- Derive specialized clients from one base with `newBuilder()`
|
|
175
|
+
- Read base URL and pin hashes from build config; override only in non-prod
|
|
471
176
|
|
|
472
177
|
### Don'ts
|
|
473
178
|
- Do not use `enqueue()` with callbacks -- use `suspend` functions instead
|
|
@@ -478,6 +183,8 @@ class FlightUpdatesWebSocket @Inject constructor(
|
|
|
478
183
|
- Do not use `Gson` for new projects -- prefer `kotlinx.serialization` or Moshi
|
|
479
184
|
- Do not store tokens in SharedPreferences -- use EncryptedSharedPreferences
|
|
480
185
|
- Do not perform API calls on the main thread
|
|
186
|
+
- Do not rely on `ignoreUnknownKeys` to survive unknown enum values -- it does not
|
|
187
|
+
- Do not call a bare `runBlocking { }` in `intercept()` -- pin it to a dedicated dispatcher
|
|
481
188
|
|
|
482
189
|
## Troubleshooting
|
|
483
190
|
|
|
@@ -503,4 +210,8 @@ class FlightUpdatesWebSocket @Inject constructor(
|
|
|
503
210
|
- [ ] `OkHttpClient` is a singleton via Hilt
|
|
504
211
|
- [ ] Timeouts are set (connect, read, write)
|
|
505
212
|
- [ ] Error handling uses sealed `NetworkResult` type
|
|
213
|
+
- [ ] Response envelope failures surface as a typed `ApiException`
|
|
214
|
+
- [ ] Backend-driven enums use an unknown-tolerant serializer
|
|
215
|
+
- [ ] Base URL + pin hashes come from build config; override gated to non-prod
|
|
216
|
+
- [ ] Suspend work in interceptors is bridged on a dedicated dispatcher
|
|
506
217
|
- [ ] No tokens stored in plain SharedPreferences
|