feat: migrate FiatRateCache to DataStore

This commit is contained in:
2026-06-02 00:56:02 +02:00
parent 93332dc683
commit 712722120c
7 changed files with 162 additions and 51 deletions
+4
View File
@@ -58,6 +58,7 @@ dependencies {
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
// Networking // Networking
implementation(libs.retrofit2.retrofit) implementation(libs.retrofit2.retrofit)
implementation(libs.converter.gson) implementation(libs.converter.gson)
@@ -71,6 +72,9 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
// DataStore
implementation(libs.androidx.datastore.preferences)
// Room // Room
implementation(libs.room.runtime) implementation(libs.room.runtime)
implementation(libs.room.ktx) implementation(libs.room.ktx)
@@ -0,0 +1,116 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import java.io.IOException
private const val TAG = "FiatDataStore"
private const val DATASTORE_NAME = "fiat_prefs"
// Top-level extension — one instance per process
private val Context.fiatDataStore: DataStore<Preferences> by preferencesDataStore(
name = DATASTORE_NAME
)
class FiatDataStore(context: Context) {
private val dataStore = context.applicationContext.fiatDataStore
private val gson = Gson()
// ── Key factories ─────────────────────────────────────────────────────────
private fun rateKey(currency: String) = floatPreferencesKey("fiat_rate_$currency")
private fun rateFetchedAtKey(currency: String) = longPreferencesKey("fiat_rate_fetched_at_$currency")
private val currenciesKey = stringPreferencesKey("fiat_currencies_json")
// ── Rate ──────────────────────────────────────────────────────────────────
/**
* Returns the last persisted rate for [currency], or null if none exists.
* Used as a cold-start fallback while a fresh network fetch is in flight.
*/
suspend fun getPersistedRate(currency: String): Double? {
val prefs = dataStore.data
.catch { e ->
if (e is IOException) {
Log.w(TAG, "FIAT [RATE STALE ] read error — ${e.message}")
emit(emptyPreferences())
} else throw e
}
.first()
val value = prefs[rateKey(currency)]?.toDouble()
if (value != null && value > 0.0) {
Log.d(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $value")
}
return value?.takeIf { it > 0.0 }
}
/**
* Persists a freshly fetched [rate] for [currency].
* Always called alongside the in-memory update in [FiatRateCache.putRate].
*/
suspend fun persistRate(currency: String, rate: Double) {
runCatching {
dataStore.edit { prefs ->
prefs[rateKey(currency)] = rate.toFloat()
prefs[rateFetchedAtKey(currency)] = System.currentTimeMillis()
}
}.onFailure { e ->
Log.w(TAG, "FIAT [RATE FRESH ] persist failed for $currency${e.message}")
}
Log.d(TAG, "FIAT [RATE FRESH ] $currency = $rate — persisted to DataStore")
}
// ── Currency list ─────────────────────────────────────────────────────────
/**
* Returns the persisted currency list, or null if none has been stored yet.
*/
suspend fun getPersistedCurrencies(): List<String>? {
val prefs = dataStore.data
.catch { e ->
if (e is IOException) {
Log.w(TAG, "FIAT [CURRENCIES] read error — ${e.message}")
emit(emptyPreferences())
} else throw e
}
.first()
val json = prefs[currenciesKey] ?: return null
return runCatching {
val type = object : TypeToken<List<String>>() {}.type
val list: List<String> = gson.fromJson(json, type)
Log.d(TAG, "FIAT [CURRENCIES] DataStore hit (${list.size} currencies)")
list
}.getOrElse { e ->
Log.w(TAG, "FIAT [CURRENCIES] parse failed — ${e.message}")
null
}
}
/**
* Persists [currencies] to DataStore.
*/
suspend fun persistCurrencies(currencies: List<String>) {
if (currencies.isEmpty()) return
runCatching {
dataStore.edit { prefs ->
prefs[currenciesKey] = gson.toJson(currencies)
}
}.onFailure { e ->
Log.w(TAG, "FIAT [CURRENCIES] persist failed — ${e.message}")
}
Log.d(TAG, "FIAT [CURRENCIES] persisted ${currencies.size} currencies to DataStore")
}
}
@@ -1,24 +1,19 @@
package com.bitcointxoko.gudariwallet.data package com.bitcointxoko.gudariwallet.data
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.core.content.edit
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
private const val TAG = "FiatRateCache" private const val TAG = "FiatRateCache"
/** /**
* Two-tier (in-memory + SharedPreferences) cache for fiat exchange rates * Two-tier (in-memory + DataStore) cache for fiat exchange rates
* and the supported-currency list. * and the supported-currency list.
* *
* Receives the already-open [prefs] instance from [BalancePrefsStore] so that * Disk persistence is delegated to [FiatDataStore]; this class owns
* all wallet prefs stay in a single file. * only the in-memory (L1) layer and the cache-coordination logic.
*/ */
class FiatRateCache(store: BalancePrefsStore) { class FiatRateCache(private val fiatDataStore: FiatDataStore) {
private val prefs = store.prefs
// ── Rate ───────────────────────────────────────────────────────────────── // ── Rate ─────────────────────────────────────────────────────────────────
private var cachedRate : Double? = null private var cachedRate : Double? = null
private var rateLastFetchedAt : Long = 0L private var rateLastFetchedAt : Long = 0L
@@ -26,9 +21,10 @@ class FiatRateCache(store: BalancePrefsStore) {
/** /**
* Returns a fresh in-memory rate for [currency] if it is younger than * Returns a fresh in-memory rate for [currency] if it is younger than
* [ttlMs], then falls back to the last persisted float, then returns null. * [ttlMs], then falls back to the last persisted value via DataStore,
* then returns null.
*/ */
fun getRate(currency: String, ttlMs: Long): Double? { suspend fun getRate(currency: String, ttlMs: Long): Double? {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val age = now - rateLastFetchedAt val age = now - rateLastFetchedAt
@@ -37,9 +33,9 @@ class FiatRateCache(store: BalancePrefsStore) {
return cachedRate return cachedRate
} }
// Cold-start fallback: last persisted rate // Cold-start fallback: last persisted rate from DataStore
val persisted = prefs.getFloat("fiat_rate_$currency", 0f).toDouble() val persisted = fiatDataStore.getPersistedRate(currency)
if (persisted > 0.0) { if (persisted != null) {
Log.d(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted") Log.d(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted")
return persisted return persisted
} }
@@ -50,15 +46,12 @@ class FiatRateCache(store: BalancePrefsStore) {
/** /**
* Stores a freshly fetched [rate] for [currency] in memory and on disk. * Stores a freshly fetched [rate] for [currency] in memory and on disk.
*/ */
fun putRate(currency: String, rate: Double) { suspend fun putRate(currency: String, rate: Double) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
cachedRate = rate cachedRate = rate
rateLastFetchedAt = now rateLastFetchedAt = now
cachedRateCurrency = currency cachedRateCurrency = currency
prefs.edit { fiatDataStore.persistRate(currency, rate)
putFloat("fiat_rate_$currency", rate.toFloat())
putLong("fiat_rate_fetched_at_$currency", now)
}
Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted") Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
} }
@@ -72,35 +65,29 @@ class FiatRateCache(store: BalancePrefsStore) {
// ── Currency list ───────────────────────────────────────────────────────── // ── Currency list ─────────────────────────────────────────────────────────
private var cachedCurrencies: List<String>? = null private var cachedCurrencies: List<String>? = null
private val gson = Gson()
/** /**
* Returns the cached currency list (memory → disk → null). * Returns the cached currency list (memory → DataStore → null).
* Callers are responsible for fetching from the network on a null result. * Callers are responsible for fetching from the network on a null result.
*/ */
fun getCurrencies(): List<String>? { suspend fun getCurrencies(): List<String>? {
cachedCurrencies?.let { cachedCurrencies?.let {
Log.d(TAG, "FIAT [CURRENCIES] in-memory hit (${it.size} currencies)") Log.d(TAG, "FIAT [CURRENCIES] in-memory hit (${it.size} currencies)")
return it return it
} }
val json = prefs.getString("fiat_currencies_json", null) ?: return null val persisted = fiatDataStore.getPersistedCurrencies()
return runCatching { if (persisted != null) {
val type = object : TypeToken<List<String>>() {}.type cachedCurrencies = persisted
val list: List<String> = gson.fromJson(json, type)
Log.d(TAG, "FIAT [CURRENCIES] prefs hit (${list.size} currencies)")
list.also { cachedCurrencies = it }
}.getOrElse { e ->
Log.w(TAG, "FIAT [CURRENCIES] prefs parse failed — ${e.message}")
null
} }
return persisted
} }
/** Stores [currencies] in memory and on disk. */ /** Stores [currencies] in memory and on disk. */
fun putCurrencies(currencies: List<String>) { suspend fun putCurrencies(currencies: List<String>) {
if (currencies.isEmpty()) return if (currencies.isEmpty()) return
cachedCurrencies = currencies cachedCurrencies = currencies
prefs.edit { putString("fiat_currencies_json", gson.toJson(currencies)) } fiatDataStore.persistCurrencies(currencies)
Log.d(TAG, "FIAT [CURRENCIES] cached ${currencies.size} currencies") Log.d(TAG, "FIAT [CURRENCIES] cached ${currencies.size} currencies")
} }
@@ -7,9 +7,9 @@ import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.api.NodeAliasClient import com.bitcointxoko.gudariwallet.api.NodeAliasClient
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
import com.bitcointxoko.gudariwallet.data.FiatDataStore
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
@@ -30,10 +30,11 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val fiatDataStore = FiatDataStore(app)
val repo = LNbitsWalletRepository(api = api, secrets = secrets) val repo = LNbitsWalletRepository(api = api, secrets = secrets)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs) val balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm) val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm, fiatDataStore)
val receiveVm = ReceiveViewModel(repo) val receiveVm = ReceiveViewModel(repo)
val sendVm = SendViewModel( val sendVm = SendViewModel(
repo = repo, repo = repo,
@@ -4,10 +4,12 @@ import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatDataStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.BalanceState import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.util.WalletConstants.RATE_TTL_MS
import com.bitcointxoko.gudariwallet.util.satsToFiat import com.bitcointxoko.gudariwallet.util.satsToFiat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -17,15 +19,15 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val TAG = "FiatViewModel" private const val TAG = "FiatViewModel"
private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes
class FiatViewModel( class FiatViewModel(
private val repo : WalletRepository, private val repo : WalletRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
private val balanceVm : BalanceViewModel, private val balanceVm : BalanceViewModel,
fiatDataStore : FiatDataStore,
) : ViewModel() { ) : ViewModel() {
private val fiatCache = FiatRateCache(balancePrefs) private val fiatCache = FiatRateCache(fiatDataStore)
val fiatRate: Double? val fiatRate: Double?
get() = fiatCache.currentRate get() = fiatCache.currentRate
@@ -33,7 +35,7 @@ class FiatViewModel(
val fiatCurrency: String? val fiatCurrency: String?
get() = _selectedCurrency.value get() = _selectedCurrency.value
// ── Balance hidden ─────────────────────────────────────────────────────── // ── Balance hidden ───────────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden val balanceHidden: StateFlow<Boolean> = _balanceHidden
@@ -43,8 +45,9 @@ class FiatViewModel(
balancePrefs.setBalanceHidden(next) balancePrefs.setBalanceHidden(next)
} }
// ── Selected currency ──────────────────────────────────────────────────── // ── Selected currency ────────────────────────────────────────────────────
private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency) private val _selectedCurrency =
MutableStateFlow(balancePrefs.selectedCurrency)
val selectedCurrency: StateFlow<String?> = _selectedCurrency val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) { fun setSelectedCurrency(currency: String?) {
@@ -60,8 +63,7 @@ class FiatViewModel(
} }
} }
// ── Sats-per-fiat-unit derived flow ────────────────────────────────────── // ── Sats-per-fiat-unit derived flow ──────────────────────────────────────
// Used by UnitWheelPicker to convert between sats and fiat amounts.
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state -> .mapNotNull { state ->
(state as? BalanceState.Success)?.let { s -> (state as? BalanceState.Success)?.let { s ->
@@ -76,13 +78,12 @@ class FiatViewModel(
initialValue = null initialValue = null
) )
// ── Called by BalanceViewModel after every successful balance refresh ──── // ── Called by BalanceViewModel after every successful balance refresh ────
// Keeps fiat display in sync whenever the balance changes.
fun onBalanceRefreshed() { fun onBalanceRefreshed() {
viewModelScope.launch { fetchAndApplyFiatRate() } viewModelScope.launch { fetchAndApplyFiatRate() }
} }
// ── Rate fetch + apply ─────────────────────────────────────────────────── // ── Rate fetch + apply ───────────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() { private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return val currency = _selectedCurrency.value ?: return
@@ -103,7 +104,7 @@ class FiatViewModel(
balanceVm.applyFiat(fiat, currency) balanceVm.applyFiat(fiat, currency)
} }
// ── Currency list ──────────────────────────────────────────────────────── // ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> { suspend fun getSupportedCurrencies(): List<String> {
fiatCache.getCurrencies()?.let { return it } fiatCache.getCurrencies()?.let { return it }
return fetchAndCacheCurrencies() return fetchAndCacheCurrencies()
@@ -42,4 +42,7 @@ object WalletConstants {
const val BALANCE_PREFS_KEY = "last_balance_sats" const val BALANCE_PREFS_KEY = "last_balance_sats"
const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at" const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at"
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
// ── Fiat cache ─────────────────────────────────────────────────────────
const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes
} }
+2 -3
View File
@@ -21,8 +21,7 @@ navigationCompose = "2.8.9"
appcompat = "1.7.0" appcompat = "1.7.0"
ksp = "2.2.10-2.0.2" ksp = "2.2.10-2.0.2"
room = "2.7.1" room = "2.7.1"
datastore = "1.2.1"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
@@ -57,7 +56,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }