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
@@ -0,0 +1,67 @@
package com.bitcointxoko.gudariwallet.data
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.bitcointxoko.gudariwallet.util.WalletConstants
/**
* 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
)
// ── Balance ───────────────────────────────────────────────────────────────
val savedBalanceSats: Long
get() = prefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L)
val balanceSavedAt: Long
get() = prefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L)
fun persistBalance(sats: Long) {
prefs.edit {
putLong(WalletConstants.BALANCE_PREFS_KEY, sats)
putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis())
}
}
// ── Balance visibility ────────────────────────────────────────────────────
val isBalanceHidden: Boolean
get() = prefs.getBoolean("balance_hidden", false)
fun setBalanceHidden(hidden: Boolean) {
prefs.edit { putBoolean("balance_hidden", hidden) }
}
// ── Fiat currency selection ───────────────────────────────────────────────
val selectedCurrency: String?
get() = prefs.getString("fiat_currency", null)
fun setSelectedCurrency(currency: String?) {
prefs.edit {
if (currency != null) putString("fiat_currency", currency)
else remove("fiat_currency")
}
}
// ── Lightning address ─────────────────────────────────────────────────────
val lightningAddress: String?
get() = prefs.getString("lightning_address", null)
fun setLightningAddress(address: String) {
prefs.edit { putString("lightning_address", address) }
}
}
@@ -0,0 +1,109 @@
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
* and the supported-currency list.
*
* Receives the already-open [prefs] instance from [BalancePrefsStore] so that
* all wallet prefs stay in a single file.
*/
class FiatRateCache(store: BalancePrefsStore) {
private val prefs = store.prefs
// ── Rate ─────────────────────────────────────────────────────────────────
private var cachedRate : Double? = null
private var rateLastFetchedAt : Long = 0L
private var cachedRateCurrency : String = ""
/**
* 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.
*/
fun getRate(currency: String, ttlMs: Long): Double? {
val now = System.currentTimeMillis()
val age = now - rateLastFetchedAt
if (cachedRate != null && cachedRateCurrency == currency && age < ttlMs) {
Log.d(TAG, "FIAT [RATE CACHE ] $currency — in-memory hit (age ${age / 1000}s)")
return cachedRate
}
// Cold-start fallback: last persisted rate
val persisted = prefs.getFloat("fiat_rate_$currency", 0f).toDouble()
if (persisted > 0.0) {
Log.d(TAG, "FIAT [RATE STALE ] $currency — using persisted rate $persisted")
return persisted
}
return null
}
/**
* Stores a freshly fetched [rate] for [currency] in memory and on disk.
*/
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)
}
Log.d(TAG, "FIAT [RATE FRESH ] 1 $currency = $rate sats — cached + persisted")
}
/** Clears the in-memory rate (e.g. when the user switches currency). */
fun invalidateRate() {
cachedRate = null
rateLastFetchedAt = 0L
cachedRateCurrency = ""
}
// ── Currency list ─────────────────────────────────────────────────────────
private var cachedCurrencies: List<String>? = null
private val gson = Gson()
/**
* Returns the cached currency list (memory → disk → null).
* Callers are responsible for fetching from the network on a null result.
*/
fun getCurrencies(): List<String>? {
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<List<String>>() {}.type
val list: List<String> = 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
}
}
/** Stores [currencies] in memory and on disk. */
fun putCurrencies(currencies: List<String>) {
if (currencies.isEmpty()) return
cachedCurrencies = currencies
prefs.edit { putString("fiat_currencies_json", gson.toJson(currencies)) }
Log.d(TAG, "FIAT [CURRENCIES] cached ${currencies.size} currencies")
}
val currentRate: Double?
get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
}
@@ -1,7 +1,5 @@
package com.bitcointxoko.gudariwallet.ui package com.bitcointxoko.gudariwallet.ui
import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -23,55 +21,42 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.core.content.edit
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
class WalletViewModel( class WalletViewModel(
val repo : WalletRepository, val repo : WalletRepository,
context : Context,
val aliasRepo : NodeAliasRepository, val aliasRepo : NodeAliasRepository,
private val lnurlCache: LnurlCacheRepository, val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore,
) : ViewModel() { ) : ViewModel() {
// ── Balance prefs ─────────────────────────────────────────────────────────
private val balancePrefs: SharedPreferences = context.getSharedPreferences(
WalletConstants.BALANCE_PREFS_NAME, Context.MODE_PRIVATE
)
// ── Balance state ───────────────────────────────────────────────────────── // ── Balance state ─────────────────────────────────────────────────────────
private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading) private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading)
val balanceState: StateFlow<BalanceState> = _balanceState val balanceState: StateFlow<BalanceState> = _balanceState
// ── Balance visible state ───────────────────────────────────────────────── // ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
private val _balanceHidden = MutableStateFlow(
balancePrefs.getBoolean("balance_hidden", false)
)
val balanceHidden: StateFlow<Boolean> = _balanceHidden val balanceHidden: StateFlow<Boolean> = _balanceHidden
fun toggleBalanceVisibility() { fun toggleBalanceVisibility() {
val next = !_balanceHidden.value val next = !_balanceHidden.value
_balanceHidden.value = next _balanceHidden.value = next
balancePrefs.edit { putBoolean("balance_hidden", next) } balancePrefs.setBalanceHidden(next)
} }
// ── Fiat cache ──────────────────────────────────────────────────────────── // ── Fiat cache ────────────────────────────────────────────────────────────
private var cachedRate : Double? = null // in-memory rate value private val fiatCache = FiatRateCache(balancePrefs)
private var rateLastFetchedAt : Long = 0L // epoch ms of last fetch val fiatRate: Double?
private var cachedRateCurrency : String = "" // currency the cached rate is for get() = fiatCache.currentRate
private var cachedCurrencies : List<String>? = null
val fiatRate: Double? get() = cachedRate.takeIf { cachedRateCurrency.isNotBlank() }
val fiatCurrency: String? get() = _selectedCurrency.value val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceState val fiatSatsPerUnit: StateFlow<Double?> = balanceState
.mapNotNull { state -> .mapNotNull { state ->
@@ -86,29 +71,18 @@ class WalletViewModel(
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = null initialValue = null
) )
private val _selectedCurrency = MutableStateFlow( private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency)
balancePrefs.getString("fiat_currency", null)
)
val selectedCurrency: StateFlow<String?> = _selectedCurrency val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) { fun setSelectedCurrency(currency: String?) {
if (currency == _selectedCurrency.value) return if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency _selectedCurrency.value = currency
if (currency != null) { balancePrefs.setSelectedCurrency(currency)
balancePrefs.edit { putString("fiat_currency", currency) } fiatCache.invalidateRate() // ← replaces the three raw-var resets
} 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 = ""
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) { if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() } viewModelScope.launch { fetchAndApplyFiatRate() }
} else { } else {
// Clear fiat from balance state immediately
val current = _balanceState.value as? BalanceState.Success ?: return val current = _balanceState.value as? BalanceState.Success ?: return
_balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null) _balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null)
} }
@@ -123,16 +97,11 @@ class WalletViewModel(
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle) private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle) private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
val sendState: StateFlow<SendState> = _sendState val sendState: StateFlow<SendState> = _sendState
private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _navigateToReceive = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToReceive: SharedFlow<Unit> = _navigateToReceive val navigateToReceive: SharedFlow<Unit> = _navigateToReceive
private val _lightningAddress = MutableStateFlow<String?>(balancePrefs.lightningAddress)
private val _lightningAddress = MutableStateFlow<String?>(
balancePrefs.getString("lightning_address", null)
)
val lightningAddress: StateFlow<String?> = _lightningAddress val lightningAddress: StateFlow<String?> = _lightningAddress
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
@@ -146,8 +115,8 @@ class WalletViewModel(
// ── Balance loading ─────────────────────────────────────────────────────── // ── Balance loading ───────────────────────────────────────────────────────
private fun loadBalanceWithCache() { private fun loadBalanceWithCache() {
val savedSats = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_KEY, -1L) val savedSats = balancePrefs.savedBalanceSats
val savedAt = balancePrefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L) val savedAt = balancePrefs.balanceSavedAt
val ageMs = System.currentTimeMillis() - savedAt val ageMs = System.currentTimeMillis() - savedAt
if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) { if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) {
@@ -197,7 +166,7 @@ class WalletViewModel(
.onFailure { e -> .onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}") Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}")
val staleSats = (_balanceState.value as? BalanceState.Success)?.sats 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) { if (staleSats != null) {
Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats") Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats")
} else { } else {
@@ -210,91 +179,38 @@ class WalletViewModel(
} }
} }
} }
private fun persistBalance(sats: Long) { private fun persistBalance(sats: Long) {
balancePrefs.edit { balancePrefs.persistBalance(sats)
putLong(WalletConstants.BALANCE_PREFS_KEY, sats)
.putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis())
}
Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences") Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences")
} }
// ── Fiat rate fetch + apply ─────────────────────────────────────────────── // ── Fiat rate fetch + apply ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() { private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return val currency = _selectedCurrency.value ?: return
val now = System.currentTimeMillis()
// ── 1. Serve from in-memory cache if still fresh ────────────────────── val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
val inMemoryFresh = cachedRate != null ?: run {
&& cachedRateCurrency == currency // Cache miss — fetch from network
&& (now - rateLastFetchedAt) < RATE_TTL_MS runCatching { repo.getFiatRate(currency) }
.getOrElse { e ->
val rate: Double = if (inMemoryFresh) { Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
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 {
Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display") Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display")
return // nothing to show; leave fiatAmount = null return // nothing to show; leave fiatAmount = null
} }
} .also { fresh -> fiatCache.putRate(currency, fresh) }
.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 ─────────────────────────────────
val current = _balanceState.value as? BalanceState.Success ?: return val current = _balanceState.value as? BalanceState.Success ?: return
val fiat = satsToFiat(current.sats, rate) val fiat = satsToFiat(current.sats, rate)
Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency") Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency")
_balanceState.value = current.copy( _balanceState.value = current.copy(fiatAmount = fiat, fiatCurrency = currency)
fiatAmount = fiat,
fiatCurrency = currency
)
} }
// ── Currency list ──────────────────────────────────────────────────────── // ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> { suspend fun getSupportedCurrencies(): List<String> {
// 1. In-memory fiatCache.getCurrencies()?.let { return it }
cachedCurrencies?.let { // Cache miss (memory + disk) — fetch from network
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
return fetchAndCacheCurrencies() return fetchAndCacheCurrencies()
} }
@@ -304,15 +220,7 @@ class WalletViewModel(
Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}") Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}")
emptyList() emptyList()
} }
.also { list -> .also { list -> fiatCache.putCurrencies(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")
}
}
} }
// ── WebSocket event collection ──────────────────────────────────────────── // ── WebSocket event collection ────────────────────────────────────────────
@@ -361,7 +269,7 @@ class WalletViewModel(
if (address != null) { if (address != null) {
Log.d(TAG, "LNADDR [FETCH OK ] $address") Log.d(TAG, "LNADDR [FETCH OK ] $address")
_lightningAddress.value = address _lightningAddress.value = address
balancePrefs.edit { putString("lightning_address", address) } balancePrefs.setLightningAddress(address)
} else { } else {
Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found") Log.d(TAG, "LNADDR [FETCH OK ] no pay link / username found")
} }
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.bitcointxoko.gudariwallet.api.LNbitsClient import com.bitcointxoko.gudariwallet.api.LNbitsClient
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
@@ -19,11 +20,12 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app) val nodeAliasSvc = NodeAliasService(httpClient = LNbitsClient.httpClient, context = app)
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app) val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val balancePrefs = BalancePrefsStore(app)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return WalletViewModel( return WalletViewModel(
context = app, balancePrefs = balancePrefs,
repo = repo, repo = repo,
aliasRepo = aliasRepo, aliasRepo = aliasRepo,
lnurlCache = lnurlCache, lnurlCache = lnurlCache,