diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt new file mode 100644 index 0000000..e7c82a7 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/BalancePrefsStore.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt new file mode 100644 index 0000000..2ecca81 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/FiatRateCache.kt @@ -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? = 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? { + 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>() {}.type + val list: List = 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) { + 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() } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index b7ab3f9..e074bc6 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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.Loading) val balanceState: StateFlow = _balanceState // ── Balance visible state ───────────────────────────────────────────────── - - private val _balanceHidden = MutableStateFlow( - balancePrefs.getBoolean("balance_hidden", false) - ) + private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) val balanceHidden: StateFlow = _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? = 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 = 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 = _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.Idle) val receiveState: StateFlow = _receiveState - private val _sendState = MutableStateFlow(SendState.Idle) val sendState: StateFlow = _sendState - private val _navigateToReceive = MutableSharedFlow(extraBufferCapacity = 1) val navigateToReceive: SharedFlow = _navigateToReceive - - private val _lightningAddress = MutableStateFlow( - balancePrefs.getString("lightning_address", null) - ) + private val _lightningAddress = MutableStateFlow(balancePrefs.lightningAddress) val lightningAddress: StateFlow = _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 { - // 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") } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index c86dfb7..92b6e55 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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,