Coverage Summary for Class: RemoteGlobalConfigSourceImpl (com.georgeci.moneysurfer.appconfig.remote)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| RemoteGlobalConfigSourceImpl |
100%
(8/8)
|
92.9%
(13/14)
|
100%
(29/29)
|
99.1%
(215/217)
|
| RemoteGlobalConfigSourceImpl$mirrored$1 |
|
| RemoteGlobalConfigSourceImpl$refresh$1 |
|
| Total |
100%
(8/8)
|
92.9%
(13/14)
|
100%
(29/29)
|
99.1%
(215/217)
|
package com.georgeci.moneysurfer.appconfig.remote
import co.touchlab.kermit.Logger
import com.georgeci.moneysurfer.appconfig.ConfigKey
import com.georgeci.moneysurfer.appconfig.ConfigRegistry
import com.georgeci.moneysurfer.appconfig.ConfigSource
import com.georgeci.moneysurfer.appconfig.LayerValue
import com.georgeci.moneysurfer.appconfig.RemoteConfigMirror
import com.georgeci.moneysurfer.appconfig.RemoteGlobalConfigSource
import com.georgeci.moneysurfer.appconfig.layerValueOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import org.koin.core.annotation.Single
import kotlin.concurrent.Volatile
/**
* The RemoteGlobal layer, backed by `appConfig/flags`.
*
* Reads never touch the network: every value is served from [RemoteConfigMirror], which
* [RemoteFlagDocument] refills on launch and on foreground return. That is what makes a cold start
* resolve on the first frame and an offline launch resolve the last values the server sent.
*
* Only the online host binds this. The offline build keeps `RemoteGlobalConfigSource.Empty` from
* `app-config/api` and never has `app-config/remote` on its classpath at all.
*/
@Single(binds = [RemoteGlobalConfigSource::class])
class RemoteGlobalConfigSourceImpl(
private val document: RemoteFlagDocument,
private val registry: ConfigRegistry,
private val mirror: RemoteConfigMirror,
) : RemoteGlobalConfigSource {
private val log = Logger.withTag(REMOTE_CONFIG_LOG_TAG)
/** Held for the duration of one refresh. See [refresh] for why it is *dropped*, not awaited. */
private val fetching = Mutex()
/**
* `true` once a refresh could not reach the server or could not write what it read.
*
* Live rather than latched, matching [ConfigSource.isDegraded]: the next clean refresh clears
* it. Without this term a layer whose network side is permanently broken — revoked read
* permission, wrong project, a rules regression — reports healthy forever, and the debug panel
* presents weeks-old mirrored flags as the server's current answer.
*/
@Volatile
private var refreshFailed: Boolean = false
/**
* Names already reported as unservable, so each is logged once per process rather than once per
* refresh. Staging a flag for an unreleased version is the normal rollout pattern, and every
* install on the current version would otherwise churn the bounded Crashlytics breadcrumb ring
* on every foreground return, evicting genuinely diagnostic lines from real crash reports.
*
* Only ever touched from inside [fetching], so a plain set is enough.
*/
private val reportedRejections = mutableSetOf<String>()
override fun <T : Any> peek(key: ConfigKey<T>): LayerValue<T> = key.layerValueOf(mirror.raw(key.name))
override val changes: Flow<Unit> = mirror.changes
override suspend fun hydrate() = mirror.hydrate()
override val isDegraded: Boolean get() = mirror.isDegraded || refreshFailed
/**
* Drops an overlapping refresh instead of queueing behind it.
*
* `withLock` would line them up, and the fetch happens *inside* the lock — so on a slow network
* a burst of foreground returns (each Android Activity recreation is one: rotation, dark mode,
* font size, locale, split-screen) would each wait out the previous timeout and then pay their
* own billed document read, long after the event that asked for them. An in-flight refresh is
* already delivering current values, so a second one has nothing to add.
*/
override suspend fun refresh() {
if (!fetching.tryLock()) return
try {
when (val fetch = document.fetch()) {
// Already logged at the fetch site. Keeping the mirror is the point of having one.
RemoteFlagFetch.Unavailable -> refreshFailed = true
is RemoteFlagFetch.Read -> refreshFailed = !mirrored(fetch.values.filterKeys(::isServable))
}
} finally {
fetching.unlock()
}
}
/**
* Writes the mirror, reporting success rather than throwing.
*
* The write is the one step here that can still fail — a full disk, a read-only store — and
* this runs on a fire-and-forget coroutine with nobody to catch it. Swallowing costs one stale
* refresh; propagating would take the app down for a flag update. The `false` return is what
* keeps that silence honest: it feeds [isDegraded], so the debug panel stops presenting the
* previous values as current.
*/
@Suppress("TooGenericExceptionCaught") // Any store failure must degrade, not propagate.
private suspend fun mirrored(values: Map<String, String>): Boolean = try {
mirror.replaceAll(values)
true
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Throwable) {
// Error severity: `CrashReportingLogWriter` turns this into a Crashlytics non-fatal. Unlike
// an unreachable server, an unwritable store is never expected, so it is worth a non-fatal.
log.e(failure) { "flag mirror unwritable — resolving from the previous values" }
false
}
/**
* The `remoteOverridable` opt-in, applied at the write side.
*
* `LayeredConfig` enforces it centrally too, so this is not what makes the guarantee hold — it is
* what keeps a refused name out of the mirror entirely. Without it a stray `host.is_offline` in
* the document would sit in local storage being refused on every single read, and would show up
* in the debug panel as a value the RemoteGlobal layer holds.
*/
private fun isServable(name: String): Boolean {
val key = registry.find(name)
if (key == null) {
reportOnce(name) { "$name is not a known configuration key — ignoring it" }
return false
}
if (!key.remoteOverridable) {
reportOnce(name) { "$name is not remoteOverridable — ignoring it" }
return false
}
return true
}
private fun reportOnce(name: String, message: () -> String) {
if (reportedRejections.add(name)) log.w { message() }
}
}