@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/docs/facts.json +5 -5
  3. package/manifest.json +52 -31
  4. package/package.json +1 -1
  5. package/pipeline/skills/.skill-manifest.json +36 -20
  6. package/pipeline/skills/.skills-index.json +75 -9
  7. package/pipeline/skills/shared/README.md +13 -7
  8. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  9. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  10. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  11. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  12. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  13. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  14. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  15. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  16. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  17. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  18. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  19. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  20. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  21. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  22. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  23. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  24. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  25. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  26. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  27. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  28. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  29. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  30. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  31. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  32. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  33. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  34. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  35. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  36. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  37. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  38. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  39. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  40. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  41. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  42. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  43. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  44. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  45. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  46. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  47. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  48. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  49. package/pipeline/skills/skills-index.md +8 -2
@@ -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. Retrofit handles coroutine integration.
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
- ### DTO Classes
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
- ```kotlin
100
- @Module
101
- @InstallIn(SingletonComponent::class)
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
- ## OkHttp Interceptors
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
- ### Cache Interceptor
62
+ ## OkHttp Interceptors
224
63
 
225
- ```kotlin
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
- // Add cache to OkHttpClient
241
- val cacheDir = File(context.cacheDir, "http_cache")
242
- val cache = Cache(cacheDir, 10L * 1024 * 1024) // 10 MB
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
- OkHttpClient.Builder()
245
- .cache(cache)
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
- ### Sealed Result Type
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
- ### Safe API Call Wrapper
90
+ See `references/patterns.md` -> "Error Handling" for `safeApiCall` and the
91
+ repository + ViewModel consumption examples.
263
92
 
264
- ```kotlin
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
- ### Usage in Repository
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
- ```kotlin
285
- class FlightRepositoryImpl @Inject constructor(
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
- ### ViewModel Consumption
106
+ ## Unknown-Tolerant Enum Serialization
302
107
 
303
- ```kotlin
304
- @HiltViewModel
305
- class SearchViewModel @Inject constructor(
306
- private val repository: FlightRepository,
307
- ) : ViewModel() {
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
- ## Network-Bound Resource
114
+ See `references/patterns.md` -> "Unknown-Tolerant Enum Serialization" for the
115
+ `UnknownTolerantEnumSerializer` base class and an enum wired to it.
329
116
 
330
- Pattern for showing cached data while fetching fresh data from the network.
117
+ ## Client Composition and Environment Config
331
118
 
332
- ```kotlin
333
- inline fun <ResultType, RequestType> networkBoundResource(
334
- crossinline query: () -> Flow<ResultType>,
335
- crossinline fetch: suspend () -> RequestType,
336
- crossinline saveFetchResult: suspend (RequestType) -> Unit,
337
- crossinline shouldFetch: (ResultType) -> Boolean = { true },
338
- ): Flow<Resource<ResultType>> = flow {
339
- emit(Resource.Loading())
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
- sealed class Resource<T>(
361
- val data: T? = null,
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
- ## Multipart Uploads
131
+ ## Network-Bound Resource
390
132
 
391
- ```kotlin
392
- interface FileApiService {
393
-
394
- @Multipart
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
- // Usage
403
- suspend fun uploadFile(context: Context, uri: Uri, description: String) {
404
- val contentResolver = context.contentResolver
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
- val requestBody = inputStream.readBytes()
409
- .toRequestBody("application/octet-stream".toMediaType())
142
+ ## Multipart Uploads
410
143
 
411
- val part = MultipartBody.Part.createFormData("file", fileName, requestBody)
412
- val descriptionBody = description.toRequestBody("text/plain".toMediaType())
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
- api.uploadDocument(part, descriptionBody)
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
- ```kotlin
421
- class FlightUpdatesWebSocket @Inject constructor(
422
- private val client: OkHttpClient,
423
- ) {
424
- private var webSocket: WebSocket? = null
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