From 712722120c6a45d8e59f6e4d8e29f2cbc699b87e Mon Sep 17 00:00:00 2001 From: rasputin Date: Tue, 2 Jun 2026 00:56:02 +0200 Subject: [PATCH] feat: migrate FiatRateCache to DataStore --- app/build.gradle.kts | 4 + .../gudariwallet/data/FiatDataStore.kt | 116 ++++++++++++++++++ .../gudariwallet/data/FiatRateCache.kt | 57 ++++----- .../gudariwallet/ui/WalletViewModelFactory.kt | 5 +- .../gudariwallet/ui/fiat/FiatViewModel.kt | 23 ++-- .../gudariwallet/util/WalletConstants.kt | 3 + gradle/libs.versions.toml | 5 +- 7 files changed, 162 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d93f88d..e18b608 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) + // Networking implementation(libs.retrofit2.retrofit) implementation(libs.converter.gson) @@ -71,6 +72,9 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + // DataStore + implementation(libs.androidx.datastore.preferences) + // Room implementation(libs.room.runtime) implementation(libs.room.ktx) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt new file mode 100644 index 0000000..aad170e --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatDataStore.kt @@ -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 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? { + 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>() {}.type + val list: List = 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) { + 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") + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt index 2ecca81..412b706 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt @@ -1,24 +1,19 @@ package com.bitcointxoko.gudariwallet.data -import android.content.SharedPreferences 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" /** - * 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. * - * Receives the already-open [prefs] instance from [BalancePrefsStore] so that - * all wallet prefs stay in a single file. + * Disk persistence is delegated to [FiatDataStore]; this class owns + * only the in-memory (L1) layer and the cache-coordination logic. */ -class FiatRateCache(store: BalancePrefsStore) { - private val prefs = store.prefs +class FiatRateCache(private val fiatDataStore: FiatDataStore) { - // ── Rate ───────────────────────────────────────────────────────────────── + // ── Rate ────────────────────────────────────────────────────────────────── private var cachedRate : Double? = null 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 - * [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 age = now - rateLastFetchedAt @@ -37,9 +33,9 @@ class FiatRateCache(store: BalancePrefsStore) { return cachedRate } - // Cold-start fallback: last persisted rate - val persisted = prefs.getFloat("fiat_rate_$currency", 0f).toDouble() - if (persisted > 0.0) { + // Cold-start fallback: last persisted rate from DataStore + val persisted = fiatDataStore.getPersistedRate(currency) + if (persisted != null) { Log.d(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted") return persisted } @@ -50,15 +46,12 @@ class FiatRateCache(store: BalancePrefsStore) { /** * 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() cachedRate = rate rateLastFetchedAt = now cachedRateCurrency = currency - prefs.edit { - putFloat("fiat_rate_$currency", rate.toFloat()) - putLong("fiat_rate_fetched_at_$currency", now) - } + fiatDataStore.persistRate(currency, rate) Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted") } @@ -72,38 +65,32 @@ class FiatRateCache(store: BalancePrefsStore) { // ── Currency list ───────────────────────────────────────────────────────── private var cachedCurrencies: List? = 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. */ - fun getCurrencies(): List? { + suspend fun getCurrencies(): List? { cachedCurrencies?.let { Log.d(TAG, "FIAT [CURRENCIES] in-memory hit (${it.size} currencies)") return it } - val json = prefs.getString("fiat_currencies_json", null) ?: return null - return runCatching { - val type = object : TypeToken>() {}.type - val list: List = 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 + val persisted = fiatDataStore.getPersistedCurrencies() + if (persisted != null) { + cachedCurrencies = persisted } + return persisted } /** Stores [currencies] in memory and on disk. */ - fun putCurrencies(currencies: List) { + suspend fun putCurrencies(currencies: List) { if (currencies.isEmpty()) return cachedCurrencies = currencies - prefs.edit { putString("fiat_currencies_json", gson.toJson(currencies)) } + fiatDataStore.persistCurrencies(currencies) Log.d(TAG, "FIAT [CURRENCIES] cached ${currencies.size} currencies") } val currentRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index b7e6288..fb3d3de 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -7,9 +7,9 @@ import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.NodeAliasClient import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository +import com.bitcointxoko.gudariwallet.data.FiatDataStore import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository -import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.service.NodeAliasService @@ -30,10 +30,11 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F val paymentCache = PaymentCacheRepository(app) val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) + val fiatDataStore = FiatDataStore(app) val repo = LNbitsWalletRepository(api = api, secrets = secrets) val balancePrefs = BalancePrefsStore(app) val balanceVm = BalanceViewModel(repo, balancePrefs) - val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm) + val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm, fiatDataStore) val receiveVm = ReceiveViewModel(repo) val sendVm = SendViewModel( repo = repo, diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt index e2e2f65..64202e5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt @@ -4,10 +4,12 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.bitcointxoko.gudariwallet.data.BalancePrefsStore +import com.bitcointxoko.gudariwallet.data.FiatDataStore import com.bitcointxoko.gudariwallet.data.FiatRateCache import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.ui.BalanceState import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel +import com.bitcointxoko.gudariwallet.util.WalletConstants.RATE_TTL_MS import com.bitcointxoko.gudariwallet.util.satsToFiat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -17,15 +19,15 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch private const val TAG = "FiatViewModel" -private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes class FiatViewModel( private val repo : WalletRepository, private val balancePrefs: BalancePrefsStore, private val balanceVm : BalanceViewModel, + fiatDataStore : FiatDataStore, ) : ViewModel() { - private val fiatCache = FiatRateCache(balancePrefs) + private val fiatCache = FiatRateCache(fiatDataStore) val fiatRate: Double? get() = fiatCache.currentRate @@ -33,7 +35,7 @@ class FiatViewModel( val fiatCurrency: String? get() = _selectedCurrency.value - // ── Balance hidden ─────────────────────────────────────────────────────── + // ── Balance hidden ──────────────────────────────────────────────────────── private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) val balanceHidden: StateFlow = _balanceHidden @@ -43,8 +45,9 @@ class FiatViewModel( balancePrefs.setBalanceHidden(next) } - // ── Selected currency ──────────────────────────────────────────────────── - private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency) + // ── Selected currency ───────────────────────────────────────────────────── + private val _selectedCurrency = + MutableStateFlow(balancePrefs.selectedCurrency) val selectedCurrency: StateFlow = _selectedCurrency fun setSelectedCurrency(currency: String?) { @@ -60,8 +63,7 @@ class FiatViewModel( } } - // ── Sats-per-fiat-unit derived flow ────────────────────────────────────── - // Used by UnitWheelPicker to convert between sats and fiat amounts. + // ── Sats-per-fiat-unit derived flow ─────────────────────────────────────── val fiatSatsPerUnit: StateFlow = balanceVm.balanceState .mapNotNull { state -> (state as? BalanceState.Success)?.let { s -> @@ -76,13 +78,12 @@ class FiatViewModel( initialValue = null ) - // ── Called by BalanceViewModel after every successful balance refresh ──── - // Keeps fiat display in sync whenever the balance changes. + // ── Called by BalanceViewModel after every successful balance refresh ───── fun onBalanceRefreshed() { viewModelScope.launch { fetchAndApplyFiatRate() } } - // ── Rate fetch + apply ─────────────────────────────────────────────────── + // ── Rate fetch + apply ──────────────────────────────────────────────────── private suspend fun fetchAndApplyFiatRate() { val currency = _selectedCurrency.value ?: return @@ -103,7 +104,7 @@ class FiatViewModel( balanceVm.applyFiat(fiat, currency) } - // ── Currency list ──────────────────────────────────────────────────────── + // ── Currency list ───────────────────────────────────────────────────────── suspend fun getSupportedCurrencies(): List { fiatCache.getCurrencies()?.let { return it } return fetchAndCacheCurrencies() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt index 7f999c9..e966902 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt @@ -42,4 +42,7 @@ object WalletConstants { const val BALANCE_PREFS_KEY = "last_balance_sats" const val BALANCE_PREFS_SAVED_AT = "last_balance_saved_at" const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour + + // ── Fiat cache ───────────────────────────────────────────────────────── + const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d54a992..e8b49a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,8 +21,7 @@ navigationCompose = "2.8.9" appcompat = "1.7.0" ksp = "2.2.10-2.0.2" room = "2.7.1" - - +datastore = "1.2.1" [libraries] 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-ktx = { group = "androidx.room", name = "room-ktx", 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] android-application = { id = "com.android.application", version.ref = "agp" }