refactor: WalletViewModel BalancePrefs

This commit is contained in:
2026-06-01 19:35:29 +02:00
parent 395f540516
commit 8ce83356ec
4 changed files with 213 additions and 127 deletions
@@ -1,7 +1,5 @@
package com.bitcointxoko.gudariwallet.ui
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -23,55 +21,42 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import androidx.core.content.edit
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel"
class WalletViewModel(
val repo : WalletRepository,
context : Context,
val repo : WalletRepository,
val aliasRepo : NodeAliasRepository,
private val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository
val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore,
) : ViewModel() {
// ── Balance prefs ─────────────────────────────────────────────────────────
private val balancePrefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.BALANCE_PREFS_NAME, Context.MODE_PRIVATE
)
// ── Balance state ─────────────────────────────────────────────────────────
private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading)
val balanceState: StateFlow<BalanceState> = _balanceState
// ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(
balancePrefs.getBoolean("balance_hidden", false)
)
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden
fun toggleBalanceVisibility() {
val next = !_balanceHidden.value
_balanceHidden.value = next
balancePrefs.edit { putBoolean("balance_hidden", next) }
balancePrefs.setBalanceHidden(next)
}
// ── Fiat cache ────────────────────────────────────────────────────────────
private var cachedRate : Double? = null // in-memory rate value
private var rateLastFetchedAt : Long = 0L // epoch ms of last fetch
private var cachedRateCurrency : String = "" // currency the cached rate is for
private var cachedCurrencies : List<String>? = null
val fiatRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
private val fiatCache = FiatRateCache(balancePrefs)
val fiatRate: Double?
get() = fiatCache.currentRate
val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceState
.mapNotNull { state ->
@@ -86,29 +71,18 @@ class WalletViewModel(
started = SharingStarted.Eagerly,
initialValue = null
)
private val _selectedCurrency = MutableStateFlow(
balancePrefs.getString("fiat_currency", null)
)
private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency)
val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) {
if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency
if (currency != null) {
balancePrefs.edit { putString("fiat_currency", currency) }
} else {
// Remove the key so next cold start also starts with null
balancePrefs.edit { remove("fiat_currency") }
}
// Invalidate rate cache regardless
cachedRate = null
rateLastFetchedAt = 0L
cachedRateCurrency = ""
balancePrefs.setSelectedCurrency(currency)
fiatCache.invalidateRate() // ← replaces the three raw-var resets
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() }
} else {
// Clear fiat from balance state immediately
val current = _balanceState.value as? BalanceState.Success ?: return
_balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null)
}
@@ -123,16 +97,11 @@ class WalletViewModel(
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
val sendState: StateFlow<SendState> = _sendState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
private val _lightningAddress = MutableStateFlow<String?>(
balancePrefs.getString("lightning_address", null)
)
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
val lightningAddress: StateFlow<String?> = _lightningAddress
// ── Init ──────────────────────────────────────────────────────────────────
@@ -146,8 +115,8 @@ class WalletViewModel(
// ── Balance loading ───────────────────────────────────────────────────────
private fun loadBalanceWithCache() {
val savedSats = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L)
val savedAt = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L)
val savedSats = balancePrefs.savedBalanceSats
val savedAt = balancePrefs.balanceSavedAt
val ageMs = System.currentTimeMillis() - savedAt
if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) {
@@ -197,7 +166,7 @@ class WalletViewModel(
.onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}")
val staleSats = (_balanceState.value as? BalanceState.Success)?.sats
?: balancePrefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L).takeIf { it >= 0 }
?: balancePrefs.savedBalanceSats.takeIf { it >= 0 }
if (staleSats != null) {
Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats")
} else {
@@ -210,91 +179,38 @@ class WalletViewModel(
}
}
}
private fun persistBalance(sats: Long) {
balancePrefs.edit {
putLong(WalletConstants.BALANCE_PREFS_KEY, sats)
.putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis())
}
balancePrefs.persistBalance(sats)
Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences")
}
// ── Fiat rate fetch + apply ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return
val now = System.currentTimeMillis()
// ── 1. Serve from in-memory cache if still fresh ──────────────────────
val inMemoryFresh = cachedRate != null
&& cachedRateCurrency == currency
&& (now - rateLastFetchedAt) < RATE_TTL_MS
val rate: Double = if (inMemoryFresh) {
Log.d(TAG, "FIAT [RATE CACHE ] $currency — in-memory hit (age ${(now - rateLastFetchedAt) / 1000}s)")
cachedRate!!
} else {
// ── 2. Fetch from network ─────────────────────────────────────────
runCatching { repo.getFiatRate(currency) }
.getOrElse { e ->
Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
// ── 3. Fall back to last persisted rate ───────────────────
val persisted = balancePrefs.getFloat("fiat_rate_$currency", 0f).toDouble()
if (persisted > 0.0) {
Log.w(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted")
persisted
} else {
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run {
// Cache miss — fetch from network
runCatching { repo.getFiatRate(currency) }
.getOrElse { e ->
Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display")
return // nothing to show; leave fiatAmount = null
}
}
.also { fresh ->
// Update in-memory cache
cachedRate = fresh
rateLastFetchedAt = now
cachedRateCurrency = currency
// Persist for next cold start
balancePrefs.edit {
putFloat("fiat_rate_$currency", fresh.toFloat())
.putLong("fiat_rate_fetched_at_$currency", now)
}
Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $fresh sats — cached + persisted")
}
}
// ── 4. Apply to current balance state ─────────────────────────────────
.also { fresh -> fiatCache.putRate(currency, fresh) }
}
val current = _balanceState.value as? BalanceState.Success ?: return
val fiat = satsToFiat(current.sats, rate)
Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency")
_balanceState.value = current.copy(
fiatAmount = fiat,
fiatCurrency = currency
)
_balanceState.value = current.copy(fiatAmount = fiat, fiatCurrency = currency)
}
// ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> {
// 1. In-memory
cachedCurrencies?.let {
Log.d(TAG, "FIAT [CURRENCIES] in-memory hit (${it.size} currencies)")
return it
}
// 2. SharedPreferences
val json = balancePrefs.getString("fiat_currencies_json", null)
if (json != null) {
return try {
val fromPrefs = json
.removeSurrounding("[", "]")
.split(",")
.map { it.trim().removeSurrounding("\"") }
.filter { it.isNotBlank() }
Log.d(TAG, "FIAT [CURRENCIES] prefs hit (${fromPrefs.size} currencies)")
cachedCurrencies = fromPrefs
fromPrefs
} catch (e: Exception) {
Log.w(TAG, "FIAT [CURRENCIES] prefs parse failed — fetching from network")
fetchAndCacheCurrencies()
}
}
// 3. Network
fiatCache.getCurrencies()?.let { return it }
// Cache miss (memory + disk) — fetch from network
return fetchAndCacheCurrencies()
}
@@ -304,15 +220,7 @@ class WalletViewModel(
Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}")
emptyList()
}
.also { list ->
if (list.isNotEmpty()) {
cachedCurrencies = list
// Persist as a simple JSON array string
val json = "[${list.joinToString(",") { "\"$it\"" }}]"
balancePrefs.edit { putString("fiat_currencies_json", json) }
Log.d(TAG, "FIAT [CURRENCIES] fetched + cached ${list.size} currencies")
}
}
.also { list -> fiatCache.putCurrencies(list) }
}
// ── WebSocket event collection ────────────────────────────────────────────
@@ -361,7 +269,7 @@ class WalletViewModel(
if (address != null) {
Log.d(TAG, "LNADDR [FETCH OK ] $address")
_lightningAddress.value = address
balancePrefs.edit { putString("lightning_address", address) }
balancePrefs.setLightningAddress(address)
} else {
Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found")
}