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

Class Method, % Branch, % Line, % Instruction, %
LocalConfigSourceImpl 100% (8/8) 100% (8/8) 100% (23/23) 100% (201/201)
LocalConfigSourceImpl$Companion
LocalConfigSourceImpl$hydrate$2 100% (1/1) 100% (4/4) 100% (72/72)
LocalConfigSourceImpl$hydrate$2$preferencesWarm$1 100% (1/1) 100% (1/1) 100% (23/23)
LocalConfigSourceImpl$hydrate$2$syncedWarm$1 100% (1/1) 100% (1/1) 100% (23/23)
LocalConfigSourceImpl$special$$inlined$combine$1 0% (0/2)
LocalConfigSourceImpl$special$$inlined$combine$1$1
LocalConfigSourceImpl$special$$inlined$combine$1$2 0% (0/1)
LocalConfigSourceImpl$special$$inlined$combine$1$3 0% (0/1)
LocalConfigSourceImpl$write$1
Total 73.3% (11/15) 100% (8/8) 100% (29/29) 100% (319/319)


 package com.georgeci.moneysurfer.data.config
 
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.stringPreferencesKey
 import com.georgeci.moneysurfer.appconfig.ConfigKey
 import com.georgeci.moneysurfer.appconfig.LayerValue
 import com.georgeci.moneysurfer.appconfig.LocalConfigSource
 import com.georgeci.moneysurfer.appconfig.SettingKey
 import com.georgeci.moneysurfer.appconfig.layerValueOf
 import com.georgeci.moneysurfer.data.db.dao.ConfigEntryDao
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import com.georgeci.moneysurfer.domain.sync.SyncEntityTypes
 import com.georgeci.moneysurfer.sync.repository.MutationOperation
 import com.georgeci.moneysurfer.sync.repository.OutboxEnqueuer
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.launch
 import org.koin.core.annotation.Single
 
 /**
  * Device-local user settings, split across two stores by [SettingKey.sync].
  *
  * | | `sync = true` | `sync = false` |
  * | --- | --- | --- |
  * | Store | Room `config_entry` | the app's DataStore file |
  * | Scope | the account — wiped by `LocalDataResetRepositoryImpl.clearAll()` | the device — never wiped |
  * | Replication | pushed through the outbox, pulled per user | none |
  *
  * The split is what makes both halves correct at once: the push plugin needs `value + updatedAt`
  * readable by key and LWW needs a real `updatedAt` column, while `ui.onboarding_completed` must
  * survive the logout that resets the account's settings. Callers see none of it — they hold a
  * `Pref<T>` and the routing lives here.
  *
  * A plain (non-`SettingKey`) key is host- or server-owned and can never be written locally, so it
  * reads from DataStore, which is where the Local layer has always looked for it.
  *
  * DataStore values are stored under a `config.` prefix, which is what keeps this layer clear of the
  * legacy typed preferences in the same file: `ui.onboarding_completed` used to be a
  * `booleanPreferencesKey`, and `Preferences.Key` equality is by name only, so reading it back
  * through a `stringPreferencesKey` of the same name would throw on any existing install. The key
  * *name* stays unprefixed everywhere it is user- or wire-visible — the debug panel, the
  * `config_entry` primary key, and `users/{uid}/config/{keyName}`; the prefix belongs to that one
  * store.
  */
 @Single(binds = [LocalConfigSource::class])
 class LocalConfigSourceImpl(
     dataStore: DataStore<Preferences>,
     scope: CoroutineScope,
     configEntryDao: ConfigEntryDao,
     private val clock: ClockUseCase,
     private val outbox: OutboxEnqueuer,
 ) : LocalConfigSource {
 
     private val preferences = PreferencesMirror(dataStore, scope)
     private val synced = ConfigEntryMirror(configEntryDao)
 
     override fun <T : Any> peek(key: ConfigKey<T>): LayerValue<T> = when {
         key.isSynced -> key.layerValueOf(synced.raw(key.name))
         else -> key.layerValueOf(preferences.raw(key.preferenceName))
     }
 
     /**
      * Either store changing re-resolves the layer. `combine` waits for both to emit, which they both
      * do immediately with their current state.
      */
     override val changes: Flow<Unit> = combine(preferences.changes, synced.changes) { }
 
     /**
      * The two mirrors warm concurrently because they read different stores — a DataStore file and
      * Room — and this call blocks the splash: `AppLaunchViewModel` awaits it before a start route
      * exists. Serialising them would put the database open (and, on the first launch after an
      * upgrade, its migration) end-to-end behind the preferences read for no reason.
      */
     override suspend fun hydrate() = coroutineScope {
         val preferencesWarm = launch { preferences.hydrate() }
         val syncedWarm = launch { synced.hydrate() }
         preferencesWarm.join()
         syncedWarm.join()
     }
 
     override val isDegraded: Boolean get() = preferences.isDegraded || synced.isDegraded
 
     /**
      * Writes the value, then queues the push — a dual-write, not a transaction: the outbox lives in
      * a different database (`moneysurfer_sync.db`), so nothing here can be atomic with it. A crash
      * in between leaves a row whose `updatedAt` is newer than its `lastPushedAt`, which is what the
      * sign-in reconciliation looks for.
      *
      * The enqueue is unconditional on purpose. `OutboxEnqueuerImpl` already no-ops without a
      * Firebase uid (demo and signed-out sessions) and the offline host binds `NoOpOutboxEnqueuer`,
      * so gating it here would only duplicate that decision somewhere that cannot see it.
      */
     override suspend fun <T : Any> write(key: SettingKey<T>, value: T) {
         val raw = key.codec.encode(value)
         if (key.sync) {
             synced.write(key = key.name, value = raw, updatedAt = clock.now().toEpochMilliseconds())
             outbox.enqueueUpsert(
                 entityType = SyncEntityTypes.USER_CONFIG,
                 // One key is one entity: the key name *is* the document id, and settings are
                 // user-scoped, so there is no workspace to scope them to.
                 entityId = key.name,
                 scopeKey = null,
                 operation = MutationOperation.UPDATE,
             )
         } else {
             preferences.edit { it[stringPreferencesKey(key.preferenceName)] = raw }
         }
     }
 
     /**
      * Drops `config_entry` and retires the in-memory snapshot in the same call, so `peek` stops
      * serving the wiped account's settings immediately. The DataStore half is untouched — that is
      * the whole point of the split: `ui.onboarding_completed` must survive the wipe.
      */
     override suspend fun clearSynced() = synced.deleteAll()
 
     private val ConfigKey<*>.isSynced: Boolean get() = this is SettingKey && sync
 
     private val ConfigKey<*>.preferenceName: String get() = PREFIX + name
 
     private companion object {
         const val PREFIX = "config."
     }
 }