feat: migrate BalancePrefs to DataStore

This commit is contained in:
2026-06-02 15:00:32 +02:00
parent fabf18760d
commit 0846b1768c
4 changed files with 165 additions and 72 deletions
@@ -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<Preferences> 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<Boolean> = 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}")
}
}
}
@@ -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 ->
@@ -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<Boolean> = _balanceHidden
val balanceHidden: StateFlow<Boolean> = 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<String?>(null)
val selectedCurrency: StateFlow<String?> = _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)
}
}
}
@@ -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 ─────────────────────────────────────────────────────────