Coverage Summary for Class: PreferencesMirror (com.georgeci.moneysurfer.data.config)

Class Method, % Branch, % Line, % Instruction, %
PreferencesMirror 100% (6/6) 100% (2/2) 100% (22/22) 100% (135/135)
PreferencesMirror$changes$1 100% (1/1) 100% (3/3) 100% (40/40)
PreferencesMirror$changes$1$1 100% (1/1) 100% (2/2) 100% (6/6) 100% (30/30)
PreferencesMirror$changes$2 100% (1/1) 100% (2/2) 100% (23/23)
PreferencesMirror$Companion
PreferencesMirror$edit$1
PreferencesMirror$special$$inlined$map$1 0% (0/2)
PreferencesMirror$special$$inlined$map$1$1
PreferencesMirror$special$$inlined$map$1$2 0% (0/1)
PreferencesMirror$special$$inlined$map$1$2$1
Total 75% (9/12) 100% (4/4) 100% (33/33) 100% (228/228)


 package com.georgeci.moneysurfer.data.config
 
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.MutablePreferences
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.edit
 import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.stringPreferencesKey
 import co.touchlab.kermit.Logger
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.emitAll
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.shareIn
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlin.concurrent.Volatile
 
 /**
  * Shared plumbing for the DataStore-backed configuration layers.
  *
  * `ConfigSource.peek` is synchronous while DataStore is suspend-only, so the last known
  * `Preferences` snapshot is kept in memory. It is kept current by exactly one collection of
  * `dataStore.data` — started eagerly on the application scope and shared by [changes] — so every
  * observer of the layer, and every `Config.observe` collector above it, rides that one subscription
  * instead of opening its own.
  *
  * [snapshot] has two writers, that collection and [edit], and [writes] is what orders them: a value
  * the store handed the collection before a write committed is dropped rather than published over
  * that write. Ordering them explicitly, instead of routing both through the collection, is
  * deliberate — see [edit].
  */
 internal class PreferencesMirror(
     private val dataStore: DataStore<Preferences>,
     scope: CoroutineScope,
 ) {
 
     private val log = Logger.withTag(TAG)
 
     @Volatile
     private var snapshot: Preferences? = null
 
     @Volatile
     private var readFailed: Boolean = false
 
     /**
      * `true` while the file cannot be read. Cleared by the next successful read or write, so a store
      * that recovers stops being reported as degraded without any retry logic.
      */
     val isDegraded: Boolean get() = readFailed
 
     /** `null` until warm, which resolution reads as "this layer holds nothing". */
     fun raw(name: String): String? = snapshot?.get(stringPreferencesKey(name))
 
     /**
      * Guards the [snapshot] and [writes] assignments. Held only for the assignments themselves —
      * **never** across a store call.
      *
      * That distinction is the whole point. Holding it across the I/O would serialize correctly, but
      * it would also put the startup path behind a write: `AppLaunchViewModel` awaits [hydrate]
      * before a start route exists, the RemoteGlobal layer's mirror is written by a fire-and-forget
      * refresh in the same frame, and the splash would then wait out someone else's `dataStore.edit`
      * — including its fsync. DataStore's own reads take no such lock (`tryLock`, falling back to an
      * unlocked read), so that stall would be new.
      */
     private val publish = Mutex()
 
     /**
      * Bumped by every completed [edit]. It dates a write, so a store value read before the bump can
      * be recognised as older than that write and dropped instead of published over it.
      */
     @Volatile
     private var writes: Long = 0
 
     /**
      * One collection of `dataStore.data` per mirror, for the lifetime of the graph.
      *
      * `Eagerly` rather than `WhileSubscribed`: the collection is what keeps the snapshot current,
      * so it must not stop when the last screen observing a flag goes away — the next synchronous
      * `peek` would then read whatever was true when it stopped. `replay = 1` is what keeps the
      * `ConfigSource.changes` contract: a collector that arrives late still gets the current state
      * before any change of its own.
      *
      * Never fails. A `CorruptionException` from a truncated file would otherwise propagate to every
      * collector — including the startup read in `AppLaunchViewModel`, which has no route to fall
      * back to — so the failure is logged, recorded in [isDegraded] and served as an empty emission.
      * DataStore recreates the file on the next successful write, at which point the flag clears.
      * The `catch` sits *below* the publish step on purpose: a failed read must not overwrite a good
      * snapshot with an empty one.
      *
      * [writes] is sampled *before* each value is awaited, never after it arrives, and that ordering
      * is the correctness property here. Sampling afterwards would leave a window in which a write
      * commits and bumps between the store handing over a value and this code reading the counter —
      * the stale value would then pass the check and be published over the write. Sampled first, any
      * write that bumps later is by construction newer than the value in hand, so dropping it is
      * right. Dropping costs nothing: the dropped value is only ever one this layer already has (its
      * own write) or one belonging to another writer on the same file, whose keys this layer does
      * not read.
      */
     val changes: Flow<Unit> = flow {
         var sampledAt = writes
         emitAll(
             dataStore.data.onEach { preferences ->
                 publish.withLock {
                     if (sampledAt == writes) {
                         snapshot = preferences
                         readFailed = false
                     }
                 }
                 sampledAt = writes
             },
         )
     }
         .catch { failure ->
             reportUnreadable(failure)
             emit(emptyPreferences())
         }
         .map { }
         .shareIn(scope, SharingStarted.Eagerly, replay = 1)
 
     /** Warms the mirror by awaiting the shared collection's first emission. Idempotent. */
     suspend fun hydrate() {
         changes.first()
     }
 
     /**
      * Serialises writes to this mirror, so their publications keep commit order.
      *
      * Held across the store call, unlike [publish]: [hydrate] and `peek` never touch this one, so
      * the startup path still cannot end up behind someone else's `dataStore.edit`. DataStore
      * serialises the writes themselves anyway, so the only thing this adds is that the two
      * assignments below cannot be reordered against another write's.
      */
     private val writing = Mutex()
 
     /**
      * Publishes the committed value itself rather than waiting for the collection to publish it.
      *
      * Waiting would read better — one writer of [snapshot], no counter — but it cannot be made to
      * terminate here. The Local layer shares its DataStore file with the session pointers
      * (`SessionPointersImpl`), which write it without going through this class; `dataStore.data` is
      * backed by a conflating `StateFlow`, so a session write landing in the dispatch window makes
      * the collection skip this write's value entirely and hand over the newer one. A wait for "the
      * collection published exactly what I committed" would then never end, and would hold [writing]
      * while it did not — wedging every later write to the layer. Publishing here and ordering the
      * two writers through [writes] has no such failure mode.
      */
     suspend fun edit(transform: (MutablePreferences) -> Unit) {
         writing.withLock {
             val written = dataStore.edit(transform)
             publish.withLock {
                 writes++
                 snapshot = written
                 readFailed = false
             }
         }
     }
 
     private fun reportUnreadable(failure: Throwable) {
         readFailed = true
         // Error severity: `CrashReportingLogWriter` turns this into a Crashlytics non-fatal, which
         // is the only signal that an install is running on defaults rather than stored values.
         log.e(failure) { "preferences unreadable — resolving without this layer until it recovers" }
     }
 
     private companion object {
         const val TAG = "ConfigMirror"
     }
 }