diff --git a/app/src/main/java/to/bitkit/data/AppStorage.kt b/app/src/main/java/to/bitkit/data/AppStorage.kt index 178ff5a54..06382e9f2 100644 --- a/app/src/main/java/to/bitkit/data/AppStorage.kt +++ b/app/src/main/java/to/bitkit/data/AppStorage.kt @@ -20,7 +20,7 @@ import kotlin.reflect.KProperty const val APP_PREFS = "bitkit_prefs" -// TODO refactor to dataStore (named 'CacheStore'?!) +@Deprecated("Replace with CacheStore") @Singleton class AppStorage @Inject constructor( @ApplicationContext private val appContext: Context, diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt new file mode 100644 index 000000000..43bc1ddb9 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -0,0 +1,46 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import to.bitkit.data.serializers.AppCacheSerializer +import to.bitkit.data.serializers.SettingsSerializer +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.Suggestion +import to.bitkit.models.TransactionSpeed +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CacheStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val store: DataStore = DataStoreFactory.create( + serializer = AppCacheSerializer, + produceFile = { context.dataStoreFile("app_cache.json") }, + ) + + val data: Flow = store.data + + suspend fun update(transform: (AppCacheData) -> AppCacheData) { + store.updateData(transform) + } + + + suspend fun reset() { + store.updateData { AppCacheData() } + Logger.info("Deleted all app cached data.") + } +} + +@Serializable +data class AppCacheData( + val cachedRates : List = listOf() +) diff --git a/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt new file mode 100644 index 000000000..1c48c78f5 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/serializers/AppCacheSerializer.kt @@ -0,0 +1,27 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.Serializer +import kotlinx.serialization.SerializationException +import to.bitkit.data.AppCacheData +import to.bitkit.data.SettingsData +import to.bitkit.di.json +import to.bitkit.utils.Logger +import java.io.InputStream +import java.io.OutputStream + +object AppCacheSerializer : Serializer { + override val defaultValue: AppCacheData = AppCacheData() + + override suspend fun readFrom(input: InputStream): AppCacheData { + return try { + json.decodeFromString(input.readBytes().decodeToString()) + } catch (e: SerializationException) { + Logger.error("Failed to deserialize: $e") + defaultValue + } + } + + override suspend fun writeTo(t: AppCacheData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt new file mode 100644 index 000000000..b45e97a42 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -0,0 +1,188 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.ConvertedAmount +import to.bitkit.models.FxRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.Toast +import to.bitkit.services.CurrencyService +import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.utils.Logger +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CurrencyRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val currencyService: CurrencyService, + private val settingsStore: SettingsStore, + private val cacheStore: CacheStore +) { + private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) + + private val _currencyState = MutableStateFlow(CurrencyState()) + val currencyState: StateFlow = _currencyState.asStateFlow() + + private var lastSuccessfulRefresh: Date? = null + private var isRefreshing = false + + private val pollingFlow: Flow + get() = flow { + while (currentCoroutineContext().isActive) { + emit(Unit) + delay(Env.fxRateRefreshInterval) + } + }.flowOn(bgDispatcher) + + init { + startPolling() + observeStaleData() + collectCachedData() + } + + private fun startPolling() { + repoScope.launch { + pollingFlow.collect { + refresh() + } + } + } + + private fun observeStaleData() { + repoScope.launch { + currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale -> + if (isStale) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = "Rates currently unavailable", + description = "An error has occurred. Please try again later." + ) + } + } + } + } + + private fun collectCachedData() { + repoScope.launch { + combine(settingsStore.data, cacheStore.data) { settings, cachedData -> + _currencyState.value.copy( + rates = cachedData.cachedRates, + selectedCurrency = settings.selectedCurrency, + displayUnit = settings.displayUnit, + primaryDisplay = settings.primaryDisplay, + currencySymbol = cachedData.cachedRates.firstOrNull { rate -> + rate.quote == settings.selectedCurrency + }?.currencySymbol ?: "$" + ) + }.collect { newState -> + _currencyState.update { newState } + } + } + } + + suspend fun triggerRefresh() = withContext(bgDispatcher) { + refresh() + } + + private suspend fun refresh() { + if (isRefreshing) return + isRefreshing = true + try { + val fetchedRates = currencyService.fetchLatestRates() + cacheStore.update { it.copy(cachedRates = fetchedRates) } + _currencyState.update { + it.copy( + error = null, + hasStaleData = false + ) + } + lastSuccessfulRefresh = Date() + Logger.debug("Currency rates refreshed successfully", context = TAG) + } catch (e: Exception) { + _currencyState.update { it.copy(error = e) } + Logger.error("Currency rates refresh failed", e, context = TAG) + + lastSuccessfulRefresh?.let { last -> + _currencyState.update { + it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold) + } + } + } finally { + isRefreshing = false + } + } + + suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) { + currencyState.value.primaryDisplay.let { + val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN + settingsStore.update { it.copy(primaryDisplay = newDisplay) } + } + } + + suspend fun setPrimaryDisplayUnit(unit: PrimaryDisplay) = withContext(bgDispatcher) { + settingsStore.update { it.copy(primaryDisplay = unit) } + } + + suspend fun setBtcDisplayUnit(unit: BitcoinDisplayUnit) = withContext(bgDispatcher) { + settingsStore.update { it.copy(displayUnit = unit) } + } + + suspend fun setSelectedCurrency(currency: String) = withContext(bgDispatcher) { + settingsStore.update { it.copy(selectedCurrency = currency) } + refresh() + } + + fun getCurrencySymbol(): String { + val currentState = currencyState.value + return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: "" + } + + // Conversion helpers + fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? { + val targetCurrency = currency ?: currencyState.value.selectedCurrency + val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates) + return rate?.let { currencyService.convert(sats = sats, rate = it) } + } + + fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { + val sourceCurrency = currency ?: currencyState.value.selectedCurrency + return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates) + } + + companion object { + private const val TAG = "CurrencyRepo" + } +} + +data class CurrencyState( + val rates: List = emptyList(), + val error: Throwable? = null, + val hasStaleData: Boolean = false, + val selectedCurrency: String = "USD", + val currencySymbol: String = "$", + val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, + val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, +) diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index d76718cb2..06cd6916c 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -13,6 +13,7 @@ import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.Txid import to.bitkit.data.AppDb import to.bitkit.data.AppStorage +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.entities.InvoiceTagEntity import to.bitkit.data.keychain.Keychain @@ -47,6 +48,7 @@ class WalletRepo @Inject constructor( private val settingsStore: SettingsStore, private val addressChecker: AddressChecker, private val lightningRepo: LightningRepo, + private val cacheStore: CacheStore ) { private val _walletState = MutableStateFlow( @@ -201,6 +203,7 @@ class WalletRepo @Inject constructor( keychain.wipe() appStorage.clear() settingsStore.reset() + cacheStore.reset() coreService.activity.removeAll() deleteAllInvoices() _walletState.update { WalletState() } diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index e764f4e39..ed31c8b2d 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -16,7 +16,7 @@ import kotlin.math.pow import kotlin.math.roundToLong @Singleton -class CurrencyService @Inject constructor( +class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH CurrencyRepo private val blocktankHttpClient: BlocktankHttpClient, ) { private var cachedRates: List? = null diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index 0644b31a6..ebaba6d06 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -3,182 +3,63 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import to.bitkit.data.SettingsStore -import to.bitkit.di.BgDispatcher -import to.bitkit.env.Env import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount -import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay -import to.bitkit.models.Toast -import to.bitkit.services.CurrencyService -import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.utils.Logger -import java.util.Date +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.CurrencyState import javax.inject.Inject - @HiltViewModel class CurrencyViewModel @Inject constructor( - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val currencyService: CurrencyService, - private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { - private val _uiState = MutableStateFlow(CurrencyUiState()) - val uiState = _uiState.asStateFlow() - - private var lastSuccessfulRefresh: Date? = null - - private var isRefreshing = false - - private val pollingFlow: Flow - get() = flow { - while (currentCoroutineContext().isActive) { - emit(Unit) - delay(Env.fxRateRefreshInterval) - } - }.flowOn(bgDispatcher) - - init { - startPolling() - observeStaleData() - collectSettingsData() - } - private fun observeStaleData() { - viewModelScope.launch { - uiState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale -> - if (isStale) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Rates currently unavailable", - description = "An error has occurred. Please try again later." - ) - } - } - } - } - - private fun startPolling() { - viewModelScope.launch { - pollingFlow.collect { - refresh() - } - } - } + val uiState: StateFlow = currencyRepo.currencyState fun triggerRefresh() { viewModelScope.launch { - refresh() - } - } - - private suspend fun refresh() { - if (isRefreshing) return - isRefreshing = true - try { - val fetchedRates = currencyService.fetchLatestRates() - _uiState.update { - it.copy( - rates = fetchedRates, - error = null, - hasStaleData = false - ) - } - lastSuccessfulRefresh = Date() - } catch (e: Exception) { - _uiState.update { it.copy(error = e) } - Logger.error("Currency rates refresh failed", e) - - lastSuccessfulRefresh?.let { last -> - _uiState.update { it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold) } - } - } finally { - isRefreshing = false - } - } - - private fun collectSettingsData() { - viewModelScope.launch { - settingsStore.data.collect { settings -> - _uiState.update { currentState -> - currentState.copy( - selectedCurrency = settings.selectedCurrency, - displayUnit = settings.displayUnit, - primaryDisplay = settings.primaryDisplay, - currencySymbol = currentState.rates.firstOrNull { rate -> rate.quote == settings.selectedCurrency }?.currencySymbol ?: "$" - ) - } - } + currencyRepo.triggerRefresh() } } fun togglePrimaryDisplay() { viewModelScope.launch { - uiState.value.primaryDisplay.let { - val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN - settingsStore.update { it.copy(primaryDisplay = newDisplay) } - } + currencyRepo.togglePrimaryDisplay() } } fun setPrimaryDisplayUnit(unit: PrimaryDisplay) { viewModelScope.launch { - settingsStore.update { it.copy(primaryDisplay = unit) } + currencyRepo.setPrimaryDisplayUnit(unit) } } fun setBtcDisplayUnit(unit: BitcoinDisplayUnit) { viewModelScope.launch { - settingsStore.update { it.copy(displayUnit = unit) } + currencyRepo.setBtcDisplayUnit(unit) } } fun setSelectedCurrency(currency: String) { viewModelScope.launch { - settingsStore.update { it.copy(selectedCurrency = currency) } - refresh() + currencyRepo.setSelectedCurrency(currency) } } - fun getCurrencySymbol(): String { - val currentState = uiState.value - return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: "" - } - + fun getCurrencySymbol(): String = currencyRepo.getCurrencySymbol() // UI Helpers fun convert(sats: Long, currency: String? = null): ConvertedAmount? { - val targetCurrency = currency ?: uiState.value.selectedCurrency - val rate = currencyService.getCurrentRate(targetCurrency, uiState.value.rates) - return rate?.let { currencyService.convert(sats = sats, rate = it) } + return currencyRepo.convertSatsToFiat(sats, currency) } fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { - val sourceCurrency = currency ?: uiState.value.selectedCurrency - return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, uiState.value.rates) + return currencyRepo.convertFiatToSats(fiatAmount, currency) } } -data class CurrencyUiState( - val rates: List = emptyList(), - val error: Throwable? = null, - val hasStaleData: Boolean = false, - val selectedCurrency: String = "USD", - val currencySymbol: String = "$", - val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, - val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, -) +// For backward compatibility, keeping the original data class name +typealias CurrencyUiState = CurrencyState diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index e2aea97d6..469cfc599 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -17,6 +17,7 @@ import org.mockito.kotlin.whenever import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppDb import to.bitkit.data.AppStorage +import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.services.CoreService @@ -42,6 +43,7 @@ class WalletRepoTest : BaseUnitTest() { private val addressChecker: AddressChecker = mock() private val lightningRepo: LightningRepo = mock() + private val cacheStore: CacheStore = mock() @Before fun setUp() { wheneverBlocking { coreService.shouldBlockLightning() }.thenReturn(false) @@ -61,6 +63,7 @@ class WalletRepoTest : BaseUnitTest() { settingsStore = settingsStore, addressChecker = addressChecker, lightningRepo = lightningRepo, + cacheStore = cacheStore ) } @@ -130,6 +133,7 @@ class WalletRepoTest : BaseUnitTest() { settingsStore = settingsStore, addressChecker = addressChecker, lightningRepo = lightningRepo, + cacheStore = cacheStore ) val result = nonRegtestRepo.wipeWallet()