From 0846b1768c035af2fdbde794498dcca5fa6dcdbd Mon Sep 17 00:00:00 2001 From: rasputin Date: Tue, 2 Jun 2026 15:00:32 +0200 Subject: [PATCH] feat: migrate BalancePrefs to DataStore --- .../gudariwallet/data/BalancePrefsStore.kt | 145 ++++++++++++++---- .../ui/balance/BalanceViewModel.kt | 43 +++--- .../gudariwallet/ui/fiat/FiatViewModel.kt | 46 ++++-- .../gudariwallet/util/WalletConstants.kt | 3 - 4 files changed, 165 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt index c5e8675..7504e42 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt @@ -1,58 +1,135 @@ package com.bitcointxoko.gudariwallet.data import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import com.bitcointxoko.gudariwallet.util.WalletConstants +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.IOException + +private const val TAG = "BalancePrefsStore" +private const val DATASTORE_NAME = "balance_prefs" + +// Top-level extension — one instance per process, mirrors fiatDataStore pattern +private val Context.balanceDataStore: DataStore by preferencesDataStore( + name = DATASTORE_NAME +) -/** - * Wraps the single SharedPreferences file used for persisting wallet UI state: - * balance, balance visibility, fiat currency selection, fiat rates, and - * lightning address. - * - * Passed into [WalletViewModel] instead of a raw [Context] so the ViewModel - * holds no Android framework references. - */ class BalancePrefsStore(context: Context) { - // Exposed to FiatRateCache so both share the same prefs file - internal val prefs: SharedPreferences = context.getSharedPreferences( - WalletConstants.BALANCE_PREFS_NAME, Context.MODE_PRIVATE - ) + private val dataStore = context.applicationContext.balanceDataStore + + // ── Keys ────────────────────────────────────────────────────────────────── + + private companion object { + val BALANCE_SATS = longPreferencesKey("balance_sats") + val BALANCE_SAVED_AT = longPreferencesKey("balance_saved_at") + val BALANCE_HIDDEN = booleanPreferencesKey("balance_hidden") + val FIAT_CURRENCY = stringPreferencesKey("fiat_currency") + } // ── Balance ─────────────────────────────────────────────────────────────── - val savedBalanceSats: Long - get() = prefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L) + /** + * Returns the last persisted balance in satoshis, or -1 if none exists. + * Used as a cold-start fallback while a fresh network fetch is in flight. + */ + suspend fun getSavedBalanceSats(): Long { + val prefs = dataStore.data + .catch { e -> + if (e is IOException) { + Log.w(TAG, "BALANCE [SATS ] read error — ${e.message}") + emit(emptyPreferences()) + } else throw e + } + .first() + return prefs[BALANCE_SATS] ?: -1L + } - val balanceSavedAt: Long - get() = prefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L) + /** + * Returns the timestamp of the last balance persist, or 0 if never saved. + */ + suspend fun getBalanceSavedAt(): Long { + val prefs = dataStore.data + .catch { e -> + if (e is IOException) { + Log.w(TAG, "BALANCE [SAVED_AT] read error — ${e.message}") + emit(emptyPreferences()) + } else throw e + } + .first() + return prefs[BALANCE_SAVED_AT] ?: 0L + } - fun persistBalance(sats: Long) { - prefs.edit { - putLong(WalletConstants.BALANCE_PREFS_KEY, sats) - putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis()) + /** + * Atomically persists [sats] and the current timestamp. + */ + suspend fun persistBalance(sats: Long) { + runCatching { + dataStore.edit { prefs -> + prefs[BALANCE_SATS] = sats + prefs[BALANCE_SAVED_AT] = System.currentTimeMillis() + } + }.onFailure { e -> + Log.w(TAG, "BALANCE [SATS ] persist failed — ${e.message}") } + Log.d(TAG, "BALANCE [SATS ] $sats sats — persisted to DataStore") } // ── Balance visibility ──────────────────────────────────────────────────── - val isBalanceHidden: Boolean - get() = prefs.getBoolean("balance_hidden", false) + /** + * Emits the current hidden state and all subsequent changes as a [Flow]. + * Collect in the ViewModel via stateIn() for reactive UI updates. + */ + val isBalanceHiddenFlow: Flow = dataStore.data + .catch { e -> + if (e is IOException) { + Log.w(TAG, "BALANCE [HIDDEN ] read error — ${e.message}") + emit(emptyPreferences()) + } else throw e + } + .map { prefs -> prefs[BALANCE_HIDDEN] ?: false } - fun setBalanceHidden(hidden: Boolean) { - prefs.edit { putBoolean("balance_hidden", hidden) } + suspend fun setBalanceHidden(hidden: Boolean) { + runCatching { + dataStore.edit { prefs -> prefs[BALANCE_HIDDEN] = hidden } + }.onFailure { e -> + Log.w(TAG, "BALANCE [HIDDEN ] persist failed — ${e.message}") + } } // ── Fiat currency selection ─────────────────────────────────────────────── - val selectedCurrency: String? - get() = prefs.getString("fiat_currency", null) + suspend fun getSelectedCurrency(): String? { + val prefs = dataStore.data + .catch { e -> + if (e is IOException) { + Log.w(TAG, "BALANCE [CURRENCY] read error — ${e.message}") + emit(emptyPreferences()) + } else throw e + } + .first() + return prefs[FIAT_CURRENCY] + } - fun setSelectedCurrency(currency: String?) { - prefs.edit { - if (currency != null) putString("fiat_currency", currency) - else remove("fiat_currency") + suspend fun setSelectedCurrency(currency: String?) { + runCatching { + dataStore.edit { prefs -> + if (currency != null) prefs[FIAT_CURRENCY] = currency + else prefs.remove(FIAT_CURRENCY) + } + }.onFailure { e -> + Log.w(TAG, "BALANCE [CURRENCY] persist failed — ${e.message}") } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt index c2fde44..c39d026 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt @@ -40,29 +40,30 @@ class BalanceViewModel( } private fun loadBalanceWithCache() { - val savedSats = balancePrefs.savedBalanceSats - val savedAt = balancePrefs.balanceSavedAt - val ageMs = System.currentTimeMillis() - savedAt - - if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) { - Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately") - _balanceState.value = BalanceState.Success( - sats = savedSats, - isRefreshing = true, - lastUpdated = savedAt - ) - } else { - if (savedSats >= 0) { - Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL ${WalletConstants.BALANCE_CACHE_TTL_MS / 1000}s — cold load") + viewModelScope.launch { + val savedSats = balancePrefs.getSavedBalanceSats() + val savedAt = balancePrefs.getBalanceSavedAt() + val ageMs = System.currentTimeMillis() - savedAt + if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) { + Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — skipping network fetch") + _balanceState.value = BalanceState.Success( + sats = savedSats, + isRefreshing = false, + lastUpdated = savedAt + ) } else { - Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load") + if (savedSats >= 0) { + Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL — cold load") + } else { + Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load") + } + _balanceState.value = BalanceState.Loading + refreshBalance() } - _balanceState.value = BalanceState.Loading } - - refreshBalance() } + fun refreshBalance() { val current = _balanceState.value if (current is BalanceState.Success && !current.isRefreshing) { @@ -92,7 +93,7 @@ class BalanceViewModel( .onFailure { e -> Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}") val staleSats = (_balanceState.value as? BalanceState.Success)?.sats - ?: balancePrefs.savedBalanceSats.takeIf { it >= 0 } + ?: balancePrefs.getSavedBalanceSats().takeIf { it >= 0 } if (staleSats != null) { Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats") } else { @@ -106,11 +107,11 @@ class BalanceViewModel( } } - private fun persistBalance(sats: Long) { + private suspend fun persistBalance(sats: Long) { balancePrefs.persistBalance(sats) - Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences") } + private fun observePaymentEvents() { viewModelScope.launch { WalletNotificationService.paymentEvents.collect { event -> 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 64202e5..2ea4cf7 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 @@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -36,30 +37,47 @@ class FiatViewModel( get() = _selectedCurrency.value // ── Balance hidden ──────────────────────────────────────────────────────── - private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) - val balanceHidden: StateFlow = _balanceHidden + val balanceHidden: StateFlow = balancePrefs.isBalanceHiddenFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) fun toggleBalanceVisibility() { - val next = !_balanceHidden.value - _balanceHidden.value = next - balancePrefs.setBalanceHidden(next) + viewModelScope.launch { + balancePrefs.setBalanceHidden(!balanceHidden.value) + } } // ── Selected currency ───────────────────────────────────────────────────── - private val _selectedCurrency = - MutableStateFlow(balancePrefs.selectedCurrency) + private val _selectedCurrency = MutableStateFlow(null) val selectedCurrency: StateFlow = _selectedCurrency + init { + viewModelScope.launch { + _selectedCurrency.value = balancePrefs.getSelectedCurrency() + Log.d(TAG, "FIAT [CURRENCY ] loaded from DataStore: ${_selectedCurrency.value ?: "none"}") + balanceVm.balanceState.collect { state -> + if (state is BalanceState.Success && !state.isRefreshing) { + fetchAndApplyFiatRate() + } + } + } + } + fun setSelectedCurrency(currency: String?) { if (currency == _selectedCurrency.value) return _selectedCurrency.value = currency - balancePrefs.setSelectedCurrency(currency) - fiatCache.invalidateRate() - Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") - if (currency != null) { - viewModelScope.launch { fetchAndApplyFiatRate() } - } else { - balanceVm.applyFiat(null, null) + viewModelScope.launch { + balancePrefs.setSelectedCurrency(currency) + fiatCache.invalidateRate() + Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") + if (currency != null) { + viewModelScope.launch { fetchAndApplyFiatRate() } + } else { + balanceVm.applyFiat(null, null) + } } } 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 7c47235..b2b2f3f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt @@ -38,9 +38,6 @@ object WalletConstants { const val PAYMENTS_SYNC_CHECKPOINT = 100 // max payments fetched on auto-sync at startup // ── Balance cache ───────────────────────────────────────────────────────── - const val BALANCE_PREFS_NAME = "balance_cache_prefs" - 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 ─────────────────────────────────────────────────────────