feat: migrate BalancePrefs to DataStore
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user