Coverage Summary for Class: LayeredConfig (com.georgeci.moneysurfer.appconfig)

Class Method, % Branch, % Line, % Instruction, %
LayeredConfig 100% (11/11) 95.8% (23/24) 100% (50/50) 99.7% (335/336)
LayeredConfig$Companion
LayeredConfig$handle$1 100% (2/2) 100% (4/4) 100% (36/36)
LayeredConfig$handle$1$set$1
LayeredConfig$hydrate$1
LayeredConfig$hydrateOrNull$1
LayeredConfig$observe$$inlined$map$1 0% (0/2)
LayeredConfig$observe$$inlined$map$1$1
LayeredConfig$observe$$inlined$map$1$2 0% (0/1)
LayeredConfig$observe$$inlined$map$1$2$1
LayeredConfig$special$$inlined$combine$1 0% (0/2)
LayeredConfig$special$$inlined$combine$1$1
LayeredConfig$special$$inlined$combine$1$2 0% (0/1)
LayeredConfig$special$$inlined$combine$1$3 0% (0/1)
Total 65% (13/20) 95.8% (23/24) 100% (54/54) 99.7% (371/372)


 package com.georgeci.moneysurfer.appconfig
 
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.domain.preferences.Pref
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlin.concurrent.Volatile
 
 /**
  * [Config] over an explicitly ordered layer list.
  *
  * Order is passed in, never collected via `getAll()`: precedence is a correctness property and
  * must not depend on Koin module load order.
  *
  * @param scope process-lifetime scope the shared [changes] collection runs on.
  * @param failFastOnEarlySnapshot debug builds throw when [snapshot] runs before [hydrate], so an
  *   ordering mistake surfaces in development instead of shipping as a silently Build-only read.
  * @param overlay the layer a write has to retire, because it outranks the one being written to.
  *   Defaults to [SessionConfigOverlay.Inactive] so an engine assembled without it behaves exactly
  *   as it did before the overlay existed.
  */
 class LayeredConfig(
     private val layers: List<ConfigSource>,
     private val local: LocalConfigSource,
     scope: CoroutineScope,
     private val failFastOnEarlySnapshot: Boolean,
     private val overlay: SessionConfigOverlay = SessionConfigOverlay.Inactive,
 ) : Config {
 
     private val log = Logger.withTag(TAG)
     private val hydration = Mutex()
 
     @Volatile
     private var hydrated: Boolean = false
 
     /**
      * Layers whose `hydrate()` threw. Latched, because a source that throws rather than reporting
      * [ConfigSource.isDegraded] gives us no way to observe its recovery — the DataStore-backed
      * layers do report it, and those clear on their own.
      */
     @Volatile
     private var hydrationFailures: Set<ConfigLayer> = emptySet()
 
     override val degradedLayers: Set<ConfigLayer>
         get() = hydrationFailures + layers.filter { it.isDegraded }.map { it.layer }
 
     /**
      * Warms every layer it can and reports the ones it could not.
      *
      * A failure here must not stop startup. This is the first suspend call `AppLaunchViewModel`
      * awaits, so a throw would kill the coroutine before a start route is ever resolved — the app
      * would crash-loop on a truncated preferences file until its data is cleared. Falling through to
      * Build values and key defaults leaves a usable app instead, which is the same stance the
      * surrounding startup code already takes for the first-run seeder.
      *
      * The cost is silence, so it is paid for twice: the failure is logged at Error severity (which
      * `CrashReportingLogWriter` turns into a Crashlytics non-fatal) and the layer stays in
      * [degradedLayers] so the debug panel can say "unavailable" rather than implying the layer is
      * simply empty. Recovery needs no retry logic — the mirror refills from the source's `changes`
      * flow as soon as the store is readable again, and [ConfigSource.isDegraded] stops reporting it.
      *
      * A source is expected to swallow its own read failures and report [ConfigSource.isDegraded];
      * the catch here is the backstop for one that throws anyway.
      */
     override suspend fun hydrate() {
         if (hydrated) return
         hydration.withLock {
             if (hydrated) return
             hydrationFailures = layers.mapNotNullTo(mutableSetOf()) { source -> source.hydrateOrNull() }
             hydrated = true
         }
     }
 
     /** Returns the layer when hydration failed, `null` when it succeeded. */
     @Suppress("TooGenericExceptionCaught") // Any store failure must degrade, not propagate.
     private suspend fun ConfigSource.hydrateOrNull(): ConfigLayer? = try {
         hydrate()
         null
     } catch (e: Throwable) {
         log.e(e) { "[$layer] hydration failed — resolving without this layer until it recovers" }
         layer
     }
 
     /**
      * One combine over the layers for the whole process, not one per collector.
      *
      * The Settings screen alone puts roughly ten collectors on this flow — `observeSyncEnabled`,
      * `observePendingCount`, the palette source, the theme observer, the dashboard layout. Unshared,
      * each of them re-subscribed every layer; the store-backed ones would open a fresh
      * `dataStore.data` collection — and a fresh `config_entry` query — every time. `Eagerly` because
      * the layers below are already hot for the lifetime of the graph, and `replay = 1` so a
      * collector arriving later still resolves the current value before waiting for a change.
      */
     override val changes: Flow<Unit> =
         combine(layers.map { it.changes }) { }.shareIn(scope, SharingStarted.Eagerly, replay = 1)
 
     override fun <T : Any> observe(key: ConfigKey<T>): Flow<T> =
         changes.map { resolve(key).value }.distinctUntilChanged()
 
     override fun <T : Any> snapshot(key: ConfigKey<T>): T {
         if (!hydrated) {
             // "May", not "will": the store-backed layers warm themselves from their own shared
             // collection, so which of them a read this early sees is a race — the reason it is a
             // bug regardless of what any single run happens to return.
             val message = "Config.snapshot(${key.name}) before hydrate() — may resolve Build and defaults only"
             if (failFastOnEarlySnapshot) error(message)
             log.w { message }
         }
         return resolve(key).value
     }
 
     override fun <T : Any> handle(key: SettingKey<T>): Pref<T> = object : Pref<T> {
         override val flow: Flow<T> = observe(key)
 
         /**
          * Retiring the overlay entry is what keeps the write visible: the overlay outranks Local,
          * so a value picked while it still holds this key would be stored and then not shown. It is
          * a no-op in a normal session, where the overlay holds nothing at all.
          *
          * **After** the write, not before. Releasing first would expose the key's default for the
          * whole duration of `local.write` — a Room write plus an outbox enqueue in a second
          * database — and, if that write threw, would leave the value gone from both the overlay and
          * the store, snapping the running UI to a default the user never chose. Held-then-released
          * only ever shows the old value; released-then-failed shows neither.
          */
         override suspend fun set(value: T) {
             local.write(key, value)
             overlay.release(key.name)
         }
     }
 
     override fun <T : Any> resolve(key: ConfigKey<T>): ConfigResolution<T> {
         val perLayer = mutableMapOf<ConfigLayer, LayerValue<T>>()
         var winner: ConfigLayer? = null
         var winningValue: T? = null
         layers.forEach { source ->
             // What the layer holds goes into `perLayer` verbatim; what it is *allowed* to serve is a
             // separate question. Reporting a refused value as absent would leave the debug panel
             // unable to tell "the server sent nothing" from "the server sent something we ignored".
             val held = source.held(key)
             perLayer[source.layer] = held
             val served = if (source.mayServe(key)) held else LayerValue.Absent
             if (winner == null && served is LayerValue.Present) {
                 winner = source.layer
                 winningValue = served.value
             }
         }
         return ConfigResolution(
             value = winningValue ?: key.default,
             winner = winner,
             perLayer = perLayer,
         )
     }
 
     /**
      * The remote opt-in, applied centrally: a `RemoteGlobalConfigSource` that ignored
      * [ConfigKey.remoteOverridable] still cannot override a host fact or seed a user setting.
      */
     private fun ConfigSource.mayServe(key: ConfigKey<*>): Boolean =
         layer != ConfigLayer.RemoteGlobal || key.remoteOverridable
 
     /**
      * What the layer holds, whatever the engine will do with it. Also where an undecodable stored
      * value — and a remote value the opt-in refuses — is logged: once, at the resolution boundary,
      * instead of in every source.
      */
     private fun <T : Any> ConfigSource.held(key: ConfigKey<T>): LayerValue<T> {
         val value = peek(key)
         when {
             value is LayerValue.Undecodable ->
                 log.w { "[$layer] ${key.name} holds an undecodable value — treating the layer as absent" }
             value is LayerValue.Present && !mayServe(key) ->
                 log.w { "[$layer] ${key.name} is not remoteOverridable — value refused, resolving without it" }
         }
         return value
     }
 
     private companion object {
         const val TAG = "Config"
     }
 }