Skip to content

Commit 811ae76

Browse files
therajanmauryaMobileByteLabsCommunityclaude
authored
fix(cmp-remote-config): non-blocking render + last-good cache that respects cooldown (#139)
The host previously blocked on a fresh Supabase fetch (no timeout, no cache) every time a config flag fired, so on a slow/blocked network the dialog hung blank or was auto-dismissed. - RemoteConfigLocalStore: persist the last-good active config (getCachedConfig / cacheConfig) so the host can render instantly. - RemoteConfigViewModel.fetchAndEvaluate: bound the fetch with withTimeout(2500ms); on timeout fall back to the cached config — but ONLY if it still passes the SAME eligibility rules (impression cap / cooldown / dismissed) via the evaluator, so a cooldown-gated dialog (e.g. once-per-day "Rate Us") is never re-shown from cache. The evaluator remains authoritative for what shows; the cache is a network fallback, not a bypass of the server rules. Co-authored-by: Mobile Byte Labs <mobilebytelabs@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fb3e9dd commit 811ae76

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

‎cmp-remote-config/src/commonMain/kotlin/com/mobilebytelabs/remoteconfig/local/RemoteConfigLocalStore.kt‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.mobilebytelabs.remoteconfig.local
22

3+
import com.mobilebytelabs.remoteconfig.model.RemoteConfig
34
import com.russhwolf.settings.Settings
5+
import kotlinx.serialization.decodeFromString
6+
import kotlinx.serialization.encodeToString
7+
import kotlinx.serialization.json.Json
48

59
class RemoteConfigLocalStore(private val settings: Settings = Settings()) {
610
fun getImpressions(configId: String): Int = settings.getInt("rc_${configId}_impressions", 0)
@@ -29,4 +33,28 @@ class RemoteConfigLocalStore(private val settings: Settings = Settings()) {
2933
settings.remove("rc_${configId}_last_shown")
3034
settings.remove("rc_${configId}_dismissed")
3135
}
36+
37+
// --- Last-good config cache -------------------------------------------------
38+
// Persists the most recent active config so the host can render INSTANTLY from
39+
// cache while a fresh fetch runs in the background (or the network is slow/blocked).
40+
41+
fun getCachedConfig(): RemoteConfig? = settings.getStringOrNull(KEY_LAST_CONFIG)?.let { raw ->
42+
runCatching { json.decodeFromString<RemoteConfig>(raw) }.getOrNull()
43+
}
44+
45+
fun cacheConfig(config: RemoteConfig) {
46+
runCatching { settings.putString(KEY_LAST_CONFIG, json.encodeToString(config)) }
47+
}
48+
49+
fun clearCachedConfig() {
50+
settings.remove(KEY_LAST_CONFIG)
51+
}
52+
53+
companion object {
54+
private const val KEY_LAST_CONFIG = "rc_last_config"
55+
private val json = Json {
56+
ignoreUnknownKeys = true
57+
encodeDefaults = true
58+
}
59+
}
3260
}

‎cmp-remote-config/src/commonMain/kotlin/com/mobilebytelabs/remoteconfig/ui/RemoteConfigViewModel.kt‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
1414
import kotlinx.coroutines.flow.asStateFlow
1515
import kotlinx.coroutines.flow.update
1616
import kotlinx.coroutines.launch
17+
import kotlinx.coroutines.withTimeoutOrNull
1718

1819
data class RemoteConfigState(val activeConfig: RemoteConfig? = null, val isLoading: Boolean = true)
1920

@@ -33,21 +34,40 @@ class RemoteConfigViewModel(
3334
viewModelScope.launch {
3435
_state.update { it.copy(isLoading = true) }
3536

36-
val configs = service.getActiveConfigs()
37-
38-
// Fetch server impressions (survives reinstall)
39-
val serverImpressions = try {
40-
service.getDeviceImpressions(deviceId)
41-
.associateBy { it.configId }
42-
} catch (_: Exception) {
43-
emptyMap<String, DeviceImpression>()
37+
// Hard timeout so a slow/blocked network can't hang the dialog.
38+
val configs = withTimeoutOrNull(FETCH_TIMEOUT_MS) { service.getActiveConfigs() }
39+
if (configs == null) {
40+
// Slow/blocked network — fall back to the last-good cached config, but
41+
// ONLY if it still passes the SAME eligibility rules (impression cap /
42+
// cooldown / dismissed). This never re-shows a cooldown-gated dialog
43+
// (e.g. a once-per-day "Rate Us"); the cache is a network fallback, not
44+
// a bypass of the server rules.
45+
val cachedEligible = localStore.getCachedConfig()?.let { evaluator.evaluate(listOf(it)) }
46+
_state.update { it.copy(activeConfig = cachedEligible ?: it.activeConfig, isLoading = false) }
47+
return@launch
4448
}
4549

50+
// Fetch server impressions (survives reinstall), also bounded.
51+
val serverImpressions = withTimeoutOrNull(FETCH_TIMEOUT_MS) {
52+
try {
53+
service.getDeviceImpressions(deviceId).associateBy { it.configId }
54+
} catch (_: Exception) {
55+
emptyMap<String, DeviceImpression>()
56+
}
57+
} ?: emptyMap()
58+
59+
// The evaluator is authoritative — it applies the impression/cooldown/dismiss
60+
// rules. Cache the eligible result only as a network fallback for next time.
4661
val active = evaluator.evaluate(configs, serverImpressions)
62+
active?.let { localStore.cacheConfig(it) }
4763
_state.update { it.copy(activeConfig = active, isLoading = false) }
4864
}
4965
}
5066

67+
private companion object {
68+
const val FETCH_TIMEOUT_MS = 2500L
69+
}
70+
5171
fun onConfigShown(configId: String) {
5272
val now = GMTDate().timestamp
5373
localStore.incrementImpressions(configId, now)

0 commit comments

Comments
 (0)