refactor: WalletViewModel BalancePrefs
This commit is contained in:
@@ -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
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.bitcointxoko.gudariwallet.api.LNbitsClient
|
||||
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
|
||||
import com.bitcointxoko.gudariwallet.data.LNbitsNodeAliasRepository
|
||||
import com.bitcointxoko.gudariwallet.data.LNbitsWalletRepository
|
||||
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 aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
|
||||
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
|
||||
val balancePrefs = BalancePrefsStore(app)
|
||||
val lnurlCache = LnurlCacheRepository(app)
|
||||
val paymentCache = PaymentCacheRepository(app)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return WalletViewModel(
|
||||
context = app,
|
||||
balancePrefs = balancePrefs,
|
||||
repo = repo,
|
||||
aliasRepo = aliasRepo,
|
||||
lnurlCache = lnurlCache,
|
||||
|
||||
Reference in New Issue
Block a user