feat: migrate BalancePrefs to DataStore
This commit is contained in:
@@ -1,58 +1,135 @@
|
|||||||
package com.bitcointxoko.gudariwallet.data
|
package com.bitcointxoko.gudariwallet.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.util.Log
|
||||||
import androidx.core.content.edit
|
import androidx.datastore.core.DataStore
|
||||||
import com.bitcointxoko.gudariwallet.util.WalletConstants
|
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) {
|
class BalancePrefsStore(context: Context) {
|
||||||
|
|
||||||
// Exposed to FiatRateCache so both share the same prefs file
|
private val dataStore = context.applicationContext.balanceDataStore
|
||||||
internal val prefs: SharedPreferences = context.getSharedPreferences(
|
|
||||||
WalletConstants.BALANCE_PREFS_NAME, Context.MODE_PRIVATE
|
// ── 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 ───────────────────────────────────────────────────────────────
|
// ── 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.
|
||||||
val balanceSavedAt: Long
|
*/
|
||||||
get() = prefs.getLong(WalletConstants.BALANCE_PREFS_SAVED_AT, 0L)
|
suspend fun getSavedBalanceSats(): Long {
|
||||||
|
val prefs = dataStore.data
|
||||||
fun persistBalance(sats: Long) {
|
.catch { e ->
|
||||||
prefs.edit {
|
if (e is IOException) {
|
||||||
putLong(WalletConstants.BALANCE_PREFS_KEY, sats)
|
Log.w(TAG, "BALANCE [SATS ] read error — ${e.message}")
|
||||||
putLong(WalletConstants.BALANCE_PREFS_SAVED_AT, System.currentTimeMillis())
|
emit(emptyPreferences())
|
||||||
|
} else throw e
|
||||||
}
|
}
|
||||||
|
.first()
|
||||||
|
return prefs[BALANCE_SATS] ?: -1L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ────────────────────────────────────────────────────
|
// ── 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) {
|
suspend fun setBalanceHidden(hidden: Boolean) {
|
||||||
prefs.edit { putBoolean("balance_hidden", hidden) }
|
runCatching {
|
||||||
|
dataStore.edit { prefs -> prefs[BALANCE_HIDDEN] = hidden }
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "BALANCE [HIDDEN ] persist failed — ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fiat currency selection ───────────────────────────────────────────────
|
// ── Fiat currency selection ───────────────────────────────────────────────
|
||||||
|
|
||||||
val selectedCurrency: String?
|
suspend fun getSelectedCurrency(): String? {
|
||||||
get() = prefs.getString("fiat_currency", null)
|
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?) {
|
suspend fun setSelectedCurrency(currency: String?) {
|
||||||
prefs.edit {
|
runCatching {
|
||||||
if (currency != null) putString("fiat_currency", currency)
|
dataStore.edit { prefs ->
|
||||||
else remove("fiat_currency")
|
if (currency != null) prefs[FIAT_CURRENCY] = currency
|
||||||
|
else prefs.remove(FIAT_CURRENCY)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.w(TAG, "BALANCE [CURRENCY] persist failed — ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,28 +40,29 @@ class BalanceViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadBalanceWithCache() {
|
private fun loadBalanceWithCache() {
|
||||||
val savedSats = balancePrefs.savedBalanceSats
|
viewModelScope.launch {
|
||||||
val savedAt = balancePrefs.balanceSavedAt
|
val savedSats = balancePrefs.getSavedBalanceSats()
|
||||||
|
val savedAt = balancePrefs.getBalanceSavedAt()
|
||||||
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) {
|
||||||
Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately")
|
Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — skipping network fetch")
|
||||||
_balanceState.value = BalanceState.Success(
|
_balanceState.value = BalanceState.Success(
|
||||||
sats = savedSats,
|
sats = savedSats,
|
||||||
isRefreshing = true,
|
isRefreshing = false,
|
||||||
lastUpdated = savedAt
|
lastUpdated = savedAt
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
if (savedSats >= 0) {
|
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")
|
Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL — cold load")
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load")
|
Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load")
|
||||||
}
|
}
|
||||||
_balanceState.value = BalanceState.Loading
|
_balanceState.value = BalanceState.Loading
|
||||||
}
|
|
||||||
|
|
||||||
refreshBalance()
|
refreshBalance()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun refreshBalance() {
|
fun refreshBalance() {
|
||||||
val current = _balanceState.value
|
val current = _balanceState.value
|
||||||
@@ -92,7 +93,7 @@ class BalanceViewModel(
|
|||||||
.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.savedBalanceSats.takeIf { it >= 0 }
|
?: balancePrefs.getSavedBalanceSats().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 {
|
||||||
@@ -106,11 +107,11 @@ class BalanceViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistBalance(sats: Long) {
|
private suspend fun persistBalance(sats: Long) {
|
||||||
balancePrefs.persistBalance(sats)
|
balancePrefs.persistBalance(sats)
|
||||||
Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun observePaymentEvents() {
|
private fun observePaymentEvents() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
WalletNotificationService.paymentEvents.collect { event ->
|
WalletNotificationService.paymentEvents.collect { event ->
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.bitcointxoko.gudariwallet.util.satsToFiat
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -36,23 +37,39 @@ class FiatViewModel(
|
|||||||
get() = _selectedCurrency.value
|
get() = _selectedCurrency.value
|
||||||
|
|
||||||
// ── Balance hidden ────────────────────────────────────────────────────────
|
// ── Balance hidden ────────────────────────────────────────────────────────
|
||||||
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
|
val balanceHidden: StateFlow<Boolean> = balancePrefs.isBalanceHiddenFlow
|
||||||
val balanceHidden: StateFlow<Boolean> = _balanceHidden
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = false
|
||||||
|
)
|
||||||
|
|
||||||
fun toggleBalanceVisibility() {
|
fun toggleBalanceVisibility() {
|
||||||
val next = !_balanceHidden.value
|
viewModelScope.launch {
|
||||||
_balanceHidden.value = next
|
balancePrefs.setBalanceHidden(!balanceHidden.value)
|
||||||
balancePrefs.setBalanceHidden(next)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Selected currency ─────────────────────────────────────────────────────
|
// ── Selected currency ─────────────────────────────────────────────────────
|
||||||
private val _selectedCurrency =
|
private val _selectedCurrency = MutableStateFlow<String?>(null)
|
||||||
MutableStateFlow(balancePrefs.selectedCurrency)
|
|
||||||
val selectedCurrency: StateFlow<String?> = _selectedCurrency
|
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?) {
|
fun setSelectedCurrency(currency: String?) {
|
||||||
if (currency == _selectedCurrency.value) return
|
if (currency == _selectedCurrency.value) return
|
||||||
_selectedCurrency.value = currency
|
_selectedCurrency.value = currency
|
||||||
|
viewModelScope.launch {
|
||||||
balancePrefs.setSelectedCurrency(currency)
|
balancePrefs.setSelectedCurrency(currency)
|
||||||
fiatCache.invalidateRate()
|
fiatCache.invalidateRate()
|
||||||
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
|
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
|
||||||
@@ -62,6 +79,7 @@ class FiatViewModel(
|
|||||||
balanceVm.applyFiat(null, null)
|
balanceVm.applyFiat(null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sats-per-fiat-unit derived flow ───────────────────────────────────────
|
// ── Sats-per-fiat-unit derived flow ───────────────────────────────────────
|
||||||
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
|
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ object WalletConstants {
|
|||||||
const val PAYMENTS_SYNC_CHECKPOINT = 100 // max payments fetched on auto-sync at startup
|
const val PAYMENTS_SYNC_CHECKPOINT = 100 // max payments fetched on auto-sync at startup
|
||||||
|
|
||||||
// ── Balance cache ─────────────────────────────────────────────────────────
|
// ── 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
|
const val BALANCE_CACHE_TTL_MS = 3_600_000L // 1 hour
|
||||||
|
|
||||||
// ── Fiat cache ─────────────────────────────────────────────────────────
|
// ── Fiat cache ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user