@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/docs/facts.json +6 -6
  3. package/manifest.json +55 -34
  4. package/package.json +1 -1
  5. package/pipeline/multi-agent-refs/features/usage-reporting.md +7 -3
  6. package/pipeline/schemas/prefs.schema.json +4 -0
  7. package/pipeline/scripts/usage-register.mjs +2 -0
  8. package/pipeline/skills/.skill-manifest.json +36 -20
  9. package/pipeline/skills/.skills-index.json +75 -9
  10. package/pipeline/skills/shared/README.md +13 -7
  11. package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
  12. package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
  13. package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
  14. package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
  15. package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
  16. package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
  17. package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
  18. package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
  19. package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
  20. package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
  21. package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
  22. package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
  23. package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
  24. package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
  25. package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
  26. package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
  27. package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
  28. package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
  29. package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
  30. package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
  31. package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
  32. package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
  33. package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
  34. package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
  35. package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
  36. package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
  37. package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
  38. package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
  39. package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
  40. package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
  41. package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
  42. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
  43. package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
  44. package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
  45. package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
  46. package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
  47. package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
  48. package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
  49. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
  50. package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
  51. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
  52. package/pipeline/skills/skills-index.md +8 -2
@@ -0,0 +1,640 @@
1
+ # Retrofit Networking -- Reference Patterns
2
+
3
+ Full code for the patterns summarized in `SKILL.md`. Load the section you need
4
+ when implementing that part of the networking layer.
5
+
6
+ ## Contents
7
+
8
+ - [API Interface](#api-interface)
9
+ - [Retrofit Setup with Hilt](#retrofit-setup-with-hilt)
10
+ - [OkHttp Interceptors](#okhttp-interceptors)
11
+ - [Error Handling](#error-handling)
12
+ - [Network-Bound Resource](#network-bound-resource)
13
+ - [Multipart Uploads](#multipart-uploads)
14
+ - [WebSocket Support](#websocket-support)
15
+ - [Envelope to Typed Exception](#envelope-to-typed-exception)
16
+ - [Unknown-Tolerant Enum Serialization](#unknown-tolerant-enum-serialization)
17
+ - [Client Composition and Environment Config](#client-composition-and-environment-config)
18
+
19
+ ## API Interface
20
+
21
+ Define endpoints as suspend functions. Retrofit handles coroutine integration.
22
+
23
+ ```kotlin
24
+ interface FlightApiService {
25
+
26
+ @GET("flights")
27
+ suspend fun searchFlights(
28
+ @Query("origin") origin: String,
29
+ @Query("destination") destination: String,
30
+ @Query("date") date: String,
31
+ ): List<FlightDto>
32
+
33
+ @GET("flights/{id}")
34
+ suspend fun getFlightDetail(
35
+ @Path("id") flightId: String,
36
+ ): FlightDetailDto
37
+
38
+ @POST("bookings")
39
+ suspend fun createBooking(
40
+ @Body request: BookingRequest,
41
+ ): BookingResponse
42
+
43
+ @PUT("bookings/{id}")
44
+ suspend fun updateBooking(
45
+ @Path("id") bookingId: String,
46
+ @Body request: UpdateBookingRequest,
47
+ ): BookingResponse
48
+
49
+ @DELETE("bookings/{id}")
50
+ suspend fun cancelBooking(
51
+ @Path("id") bookingId: String,
52
+ ): Response<Unit>
53
+
54
+ @GET("flights")
55
+ suspend fun searchFlightsWithHeaders(
56
+ @Query("origin") origin: String,
57
+ @Header("Accept-Language") language: String = "en",
58
+ ): List<FlightDto>
59
+ }
60
+ ```
61
+
62
+ ### DTO Classes
63
+
64
+ ```kotlin
65
+ @Serializable
66
+ data class FlightDto(
67
+ val id: String,
68
+ val origin: AirportDto,
69
+ val destination: AirportDto,
70
+ @SerialName("departure_time")
71
+ val departureTime: String,
72
+ @SerialName("arrival_time")
73
+ val arrivalTime: String,
74
+ val price: PriceDto,
75
+ val status: String,
76
+ )
77
+
78
+ @Serializable
79
+ data class AirportDto(
80
+ val code: String,
81
+ val name: String,
82
+ )
83
+
84
+ @Serializable
85
+ data class PriceDto(
86
+ val amount: Double,
87
+ val currency: String,
88
+ )
89
+ ```
90
+
91
+ ## Retrofit Setup with Hilt
92
+
93
+ ```kotlin
94
+ @Module
95
+ @InstallIn(SingletonComponent::class)
96
+ object NetworkModule {
97
+
98
+ @Provides
99
+ @Singleton
100
+ fun provideJson(): Json = Json {
101
+ ignoreUnknownKeys = true
102
+ coerceInputValues = true
103
+ encodeDefaults = true
104
+ }
105
+
106
+ @Provides
107
+ @Singleton
108
+ fun provideOkHttpClient(
109
+ authInterceptor: AuthInterceptor,
110
+ loggingInterceptor: HttpLoggingInterceptor,
111
+ ): OkHttpClient = OkHttpClient.Builder()
112
+ .connectTimeout(30, TimeUnit.SECONDS)
113
+ .readTimeout(30, TimeUnit.SECONDS)
114
+ .writeTimeout(30, TimeUnit.SECONDS)
115
+ .addInterceptor(authInterceptor)
116
+ .addInterceptor(loggingInterceptor)
117
+ .build()
118
+
119
+ @Provides
120
+ @Singleton
121
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor =
122
+ HttpLoggingInterceptor().apply {
123
+ level = if (BuildConfig.DEBUG) {
124
+ HttpLoggingInterceptor.Level.BODY
125
+ } else {
126
+ HttpLoggingInterceptor.Level.NONE
127
+ }
128
+ }
129
+
130
+ @Provides
131
+ @Singleton
132
+ fun provideRetrofit(
133
+ client: OkHttpClient,
134
+ json: Json,
135
+ ): Retrofit = Retrofit.Builder()
136
+ .baseUrl(BuildConfig.BASE_URL)
137
+ .client(client)
138
+ .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
139
+ .build()
140
+
141
+ @Provides
142
+ @Singleton
143
+ fun provideFlightApiService(retrofit: Retrofit): FlightApiService =
144
+ retrofit.create(FlightApiService::class.java)
145
+ }
146
+ ```
147
+
148
+ ## OkHttp Interceptors
149
+
150
+ ### Auth Interceptor (Token Injection)
151
+
152
+ ```kotlin
153
+ class AuthInterceptor @Inject constructor(
154
+ private val tokenProvider: TokenProvider,
155
+ ) : Interceptor {
156
+
157
+ override fun intercept(chain: Interceptor.Chain): Response {
158
+ val token = tokenProvider.getAccessToken()
159
+ val request = chain.request().newBuilder().apply {
160
+ token?.let { addHeader("Authorization", "Bearer $it") }
161
+ }.build()
162
+
163
+ val response = chain.proceed(request)
164
+
165
+ // Handle 401: refresh token and retry
166
+ if (response.code == 401) {
167
+ response.close()
168
+ val newToken = tokenProvider.refreshToken()
169
+ ?: return response
170
+
171
+ val retryRequest = chain.request().newBuilder()
172
+ .header("Authorization", "Bearer $newToken")
173
+ .build()
174
+
175
+ return chain.proceed(retryRequest)
176
+ }
177
+
178
+ return response
179
+ }
180
+ }
181
+ ```
182
+
183
+ ### Retry Interceptor
184
+
185
+ ```kotlin
186
+ class RetryInterceptor(
187
+ private val maxRetries: Int = 3,
188
+ private val initialDelayMs: Long = 1_000,
189
+ ) : Interceptor {
190
+
191
+ override fun intercept(chain: Interceptor.Chain): Response {
192
+ var lastException: IOException? = null
193
+ var delay = initialDelayMs
194
+
195
+ repeat(maxRetries) { attempt ->
196
+ try {
197
+ val response = chain.proceed(chain.request())
198
+ if (response.isSuccessful || response.code !in 500..599) {
199
+ return response
200
+ }
201
+ response.close()
202
+ } catch (e: IOException) {
203
+ lastException = e
204
+ }
205
+
206
+ if (attempt < maxRetries - 1) {
207
+ Thread.sleep(delay)
208
+ delay *= 2 // Exponential backoff
209
+ }
210
+ }
211
+
212
+ throw lastException ?: IOException("Request failed after $maxRetries retries")
213
+ }
214
+ }
215
+ ```
216
+
217
+ ### Cache Interceptor
218
+
219
+ ```kotlin
220
+ class CacheInterceptor : Interceptor {
221
+ override fun intercept(chain: Interceptor.Chain): Response {
222
+ val response = chain.proceed(chain.request())
223
+ val cacheControl = CacheControl.Builder()
224
+ .maxAge(5, TimeUnit.MINUTES)
225
+ .build()
226
+
227
+ return response.newBuilder()
228
+ .header("Cache-Control", cacheControl.toString())
229
+ .removeHeader("Pragma")
230
+ .build()
231
+ }
232
+ }
233
+
234
+ // Add cache to OkHttpClient
235
+ val cacheDir = File(context.cacheDir, "http_cache")
236
+ val cache = Cache(cacheDir, 10L * 1024 * 1024) // 10 MB
237
+
238
+ OkHttpClient.Builder()
239
+ .cache(cache)
240
+ .addNetworkInterceptor(CacheInterceptor())
241
+ .build()
242
+ ```
243
+
244
+ ## Error Handling
245
+
246
+ ### Sealed Result Type
247
+
248
+ ```kotlin
249
+ sealed interface NetworkResult<out T> {
250
+ data class Success<T>(val data: T) : NetworkResult<T>
251
+ data class Error(val code: Int, val message: String) : NetworkResult<Nothing>
252
+ data class Exception(val throwable: Throwable) : NetworkResult<Nothing>
253
+ }
254
+ ```
255
+
256
+ ### Safe API Call Wrapper
257
+
258
+ ```kotlin
259
+ suspend fun <T> safeApiCall(
260
+ apiCall: suspend () -> T,
261
+ ): NetworkResult<T> = try {
262
+ NetworkResult.Success(apiCall())
263
+ } catch (e: HttpException) {
264
+ val errorBody = e.response()?.errorBody()?.string()
265
+ NetworkResult.Error(
266
+ code = e.code(),
267
+ message = errorBody ?: e.message(),
268
+ )
269
+ } catch (e: IOException) {
270
+ NetworkResult.Exception(e)
271
+ } catch (e: CancellationException) {
272
+ throw e // Never swallow cancellation
273
+ }
274
+ ```
275
+
276
+ ### Usage in Repository
277
+
278
+ ```kotlin
279
+ class FlightRepositoryImpl @Inject constructor(
280
+ private val api: FlightApiService,
281
+ private val mapper: FlightMapper,
282
+ ) : FlightRepository {
283
+
284
+ override suspend fun searchFlights(
285
+ origin: String,
286
+ destination: String,
287
+ date: LocalDate,
288
+ ): NetworkResult<List<Flight>> = safeApiCall {
289
+ api.searchFlights(origin, destination, date.toString())
290
+ .map(mapper::toDomain)
291
+ }
292
+ }
293
+ ```
294
+
295
+ ### ViewModel Consumption
296
+
297
+ ```kotlin
298
+ @HiltViewModel
299
+ class SearchViewModel @Inject constructor(
300
+ private val repository: FlightRepository,
301
+ ) : ViewModel() {
302
+
303
+ private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
304
+ val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
305
+
306
+ fun search(origin: String, destination: String, date: LocalDate) {
307
+ viewModelScope.launch {
308
+ _uiState.value = SearchUiState.Loading
309
+
310
+ _uiState.value = when (val result = repository.searchFlights(origin, destination, date)) {
311
+ is NetworkResult.Success -> SearchUiState.Success(result.data)
312
+ is NetworkResult.Error -> SearchUiState.Error("Server error: ${result.message}")
313
+ is NetworkResult.Exception -> SearchUiState.Error(
314
+ result.throwable.localizedMessage ?: "Network error"
315
+ )
316
+ }
317
+ }
318
+ }
319
+ }
320
+ ```
321
+
322
+ ## Network-Bound Resource
323
+
324
+ Pattern for showing cached data while fetching fresh data from the network.
325
+
326
+ ```kotlin
327
+ inline fun <ResultType, RequestType> networkBoundResource(
328
+ crossinline query: () -> Flow<ResultType>,
329
+ crossinline fetch: suspend () -> RequestType,
330
+ crossinline saveFetchResult: suspend (RequestType) -> Unit,
331
+ crossinline shouldFetch: (ResultType) -> Boolean = { true },
332
+ ): Flow<Resource<ResultType>> = flow {
333
+ emit(Resource.Loading())
334
+
335
+ val data = query().first()
336
+
337
+ val flow = if (shouldFetch(data)) {
338
+ emit(Resource.Loading(data))
339
+ try {
340
+ val fetchedData = fetch()
341
+ saveFetchResult(fetchedData)
342
+ query().map { Resource.Success(it) }
343
+ } catch (throwable: Throwable) {
344
+ if (throwable is CancellationException) throw throwable
345
+ query().map { Resource.Error(throwable, it) }
346
+ }
347
+ } else {
348
+ query().map { Resource.Success(it) }
349
+ }
350
+
351
+ emitAll(flow)
352
+ }
353
+
354
+ sealed class Resource<T>(
355
+ val data: T? = null,
356
+ val error: Throwable? = null,
357
+ ) {
358
+ class Success<T>(data: T) : Resource<T>(data)
359
+ class Loading<T>(data: T? = null) : Resource<T>(data)
360
+ class Error<T>(throwable: Throwable, data: T? = null) : Resource<T>(data, throwable)
361
+ }
362
+ ```
363
+
364
+ ### Usage
365
+
366
+ ```kotlin
367
+ override fun getFlights(origin: String, destination: String): Flow<Resource<List<Flight>>> =
368
+ networkBoundResource(
369
+ query = {
370
+ dao.getFlights(origin, destination)
371
+ .map { entities -> entities.map(mapper::toDomain) }
372
+ },
373
+ fetch = { api.searchFlights(origin, destination, today()) },
374
+ saveFetchResult = { dtos ->
375
+ dao.upsertFlights(dtos.map(mapper::toEntity))
376
+ },
377
+ shouldFetch = { cachedFlights ->
378
+ cachedFlights.isEmpty() || cacheExpired()
379
+ },
380
+ )
381
+ ```
382
+
383
+ ## Multipart Uploads
384
+
385
+ ```kotlin
386
+ interface FileApiService {
387
+
388
+ @Multipart
389
+ @POST("documents/upload")
390
+ suspend fun uploadDocument(
391
+ @Part file: MultipartBody.Part,
392
+ @Part("description") description: RequestBody,
393
+ ): UploadResponse
394
+ }
395
+
396
+ // Usage
397
+ suspend fun uploadFile(context: Context, uri: Uri, description: String) {
398
+ val contentResolver = context.contentResolver
399
+ val inputStream = contentResolver.openInputStream(uri) ?: return
400
+ val fileName = getFileName(context, uri)
401
+
402
+ val requestBody = inputStream.readBytes()
403
+ .toRequestBody("application/octet-stream".toMediaType())
404
+
405
+ val part = MultipartBody.Part.createFormData("file", fileName, requestBody)
406
+ val descriptionBody = description.toRequestBody("text/plain".toMediaType())
407
+
408
+ api.uploadDocument(part, descriptionBody)
409
+ }
410
+ ```
411
+
412
+ ## WebSocket Support
413
+
414
+ ```kotlin
415
+ class FlightUpdatesWebSocket @Inject constructor(
416
+ private val client: OkHttpClient,
417
+ ) {
418
+ private var webSocket: WebSocket? = null
419
+
420
+ fun connect(flightId: String): Flow<FlightUpdate> = callbackFlow {
421
+ val request = Request.Builder()
422
+ .url("wss://api.example.com/flights/$flightId/updates")
423
+ .build()
424
+
425
+ val listener = object : WebSocketListener() {
426
+ override fun onMessage(webSocket: WebSocket, text: String) {
427
+ val update = Json.decodeFromString<FlightUpdate>(text)
428
+ trySend(update)
429
+ }
430
+
431
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
432
+ close(t)
433
+ }
434
+
435
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
436
+ channel.close()
437
+ }
438
+ }
439
+
440
+ webSocket = client.newWebSocket(request, listener)
441
+
442
+ awaitClose {
443
+ webSocket?.close(1000, "Client closed")
444
+ }
445
+ }
446
+
447
+ fun disconnect() {
448
+ webSocket?.close(1000, "Client closed")
449
+ webSocket = null
450
+ }
451
+ }
452
+ ```
453
+
454
+ ## Envelope to Typed Exception
455
+
456
+ The backend wraps every payload in a `success` envelope. A delegating
457
+ `Converter.Factory` unwraps it and turns a business failure into a typed
458
+ `ApiException`, so `success == false` surfaces through the same catch path as a
459
+ transport failure instead of forcing every call site to inspect the body.
460
+
461
+ ```kotlin
462
+ @Serializable
463
+ data class ApiEnvelope<T>(
464
+ val success: Boolean,
465
+ val data: T? = null,
466
+ @SerialName("error_code") val errorCode: Int? = null,
467
+ val message: String? = null,
468
+ )
469
+
470
+ class ApiException(
471
+ val code: Int,
472
+ override val message: String,
473
+ ) : IOException(message)
474
+
475
+ private fun parameterized(raw: Type, arg: Type): ParameterizedType =
476
+ object : ParameterizedType {
477
+ override fun getRawType(): Type = raw
478
+ override fun getActualTypeArguments(): Array<Type> = arrayOf(arg)
479
+ override fun getOwnerType(): Type? = null
480
+ }
481
+
482
+ class EnvelopeUnwrapFactory(
483
+ private val delegate: Converter.Factory,
484
+ ) : Converter.Factory() {
485
+ override fun responseBodyConverter(
486
+ type: Type,
487
+ annotations: Array<out Annotation>,
488
+ retrofit: Retrofit,
489
+ ): Converter<ResponseBody, *>? {
490
+ val envelopeType = parameterized(ApiEnvelope::class.java, type)
491
+ val inner = delegate.responseBodyConverter(envelopeType, annotations, retrofit)
492
+ ?: return null
493
+ return Converter<ResponseBody, Any?> { body ->
494
+ val envelope = inner.convert(body) as ApiEnvelope<*>
495
+ if (!envelope.success) {
496
+ throw ApiException(envelope.errorCode ?: -1, envelope.message.orEmpty())
497
+ }
498
+ envelope.data
499
+ }
500
+ }
501
+ }
502
+ ```
503
+
504
+ Register it in front of the serialization converter:
505
+
506
+ ```kotlin
507
+ Retrofit.Builder()
508
+ .baseUrl(config.baseUrl)
509
+ .client(client)
510
+ .addConverterFactory(EnvelopeUnwrapFactory(json.asConverterFactory(contentType)))
511
+ .build()
512
+ ```
513
+
514
+ Central throwable-to-sealed mapper, called from `safeApiCall`:
515
+
516
+ ```kotlin
517
+ fun Throwable.toNetworkError(): NetworkResult.Error = when (this) {
518
+ is ApiException -> NetworkResult.Error(code, message)
519
+ is HttpException -> NetworkResult.Error(code(), message())
520
+ is SocketTimeoutException -> NetworkResult.Error(-1, "Request timed out")
521
+ is IOException -> NetworkResult.Error(-1, "No network connection")
522
+ else -> NetworkResult.Error(-1, message ?: "Unexpected error")
523
+ }
524
+
525
+ suspend fun <T> safeApiCall(block: suspend () -> T): NetworkResult<T> =
526
+ try {
527
+ NetworkResult.Success(block())
528
+ } catch (e: CancellationException) {
529
+ throw e
530
+ } catch (e: Throwable) {
531
+ e.toNetworkError()
532
+ }
533
+ ```
534
+
535
+ ## Unknown-Tolerant Enum Serialization
536
+
537
+ `ignoreUnknownKeys` tolerates unknown object keys, not unknown enum values: a new
538
+ server code fails the whole response. A base `KSerializer` maps any unrecognized
539
+ value to a declared default so a single field degrades instead of the payload.
540
+
541
+ ```kotlin
542
+ interface DefaultedEnum {
543
+ val serialName: String
544
+ }
545
+
546
+ abstract class UnknownTolerantEnumSerializer<T>(
547
+ serialName: String,
548
+ private val values: Array<T>,
549
+ private val default: T,
550
+ ) : KSerializer<T> where T : Enum<T>, T : DefaultedEnum {
551
+ override val descriptor: SerialDescriptor =
552
+ PrimitiveSerialDescriptor(serialName, PrimitiveKind.STRING)
553
+
554
+ override fun serialize(encoder: Encoder, value: T) = encoder.encodeString(value.serialName)
555
+
556
+ override fun deserialize(decoder: Decoder): T {
557
+ val raw = decoder.decodeString()
558
+ return values.firstOrNull { it.serialName == raw } ?: default
559
+ }
560
+ }
561
+ ```
562
+
563
+ Usage:
564
+
565
+ ```kotlin
566
+ @Serializable(with = CabinClassSerializer::class)
567
+ enum class CabinClass(override val serialName: String) : DefaultedEnum {
568
+ ECONOMY("ECONOMY"),
569
+ BUSINESS("BUSINESS"),
570
+ UNKNOWN("UNKNOWN"),
571
+ }
572
+
573
+ object CabinClassSerializer : UnknownTolerantEnumSerializer<CabinClass>(
574
+ "CabinClass", CabinClass.entries.toTypedArray(), CabinClass.UNKNOWN,
575
+ )
576
+ ```
577
+
578
+ ## Client Composition and Environment Config
579
+
580
+ Derive every specialized client from one base with `newBuilder()`. An image
581
+ pipeline must not send auth headers, so it clears inherited interceptors:
582
+
583
+ ```kotlin
584
+ @Provides
585
+ @Singleton
586
+ @ImageClient
587
+ fun provideImageClient(
588
+ @BaseClient base: OkHttpClient,
589
+ cache: Cache,
590
+ ): OkHttpClient = base.newBuilder()
591
+ .apply { interceptors().clear() }
592
+ .cache(cache)
593
+ .build()
594
+ ```
595
+
596
+ Resolve the base URL and pin hashes per flavor from build config, with a runtime
597
+ override allowed only outside production (QA environment switching):
598
+
599
+ ```kotlin
600
+ class NetworkConfig(
601
+ private val settings: EnvironmentSettings,
602
+ ) {
603
+ val baseUrl: String
604
+ get() = if (BuildConfig.IS_PRODUCTION) {
605
+ BuildConfig.BASE_URL
606
+ } else {
607
+ settings.overrideBaseUrl ?: BuildConfig.BASE_URL
608
+ }
609
+
610
+ fun certificatePinner(): CertificatePinner {
611
+ val host = baseUrl.toHttpUrl().host
612
+ return CertificatePinner.Builder()
613
+ .apply { BuildConfig.PIN_HASHES.forEach { add(host, it) } }
614
+ .build()
615
+ }
616
+ }
617
+ ```
618
+
619
+ `BASE_URL` and `PIN_HASHES` are `buildConfigField`s per product flavor;
620
+ `IS_PRODUCTION` gates the override so QA builds can retarget without a rebuild.
621
+
622
+ Deadlock-safe suspend-in-interceptor bridge. Plain `runBlocking { }` resumes on
623
+ the interceptor's own thread and can deadlock when the token fetch reuses the
624
+ same dispatcher. Pin the suspend work to a dedicated single-thread dispatcher:
625
+
626
+ ```kotlin
627
+ class TokenInterceptor @Inject constructor(
628
+ private val tokenProvider: TokenProvider,
629
+ ) : Interceptor {
630
+ private val tokenDispatcher = Dispatchers.IO.limitedParallelism(1)
631
+
632
+ override fun intercept(chain: Interceptor.Chain): Response {
633
+ val token = runBlocking(tokenDispatcher) { tokenProvider.currentToken() }
634
+ val request = chain.request().newBuilder()
635
+ .header("Authorization", "Bearer $token")
636
+ .build()
637
+ return chain.proceed(request)
638
+ }
639
+ }
640
+ ```