From 436c3ed4b49ed41cc86b5bb7c3a71deb4465e748 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 08:37:53 -0300 Subject: [PATCH 01/18] feat: move conversion method from the CurrencyService.kt to CurrencyRepo.kt --- .../to/bitkit/repositories/CurrencyRepo.kt | 92 +++++++++++++++++-- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index b45e97a42..108274761 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -26,13 +26,18 @@ import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.Toast import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.Logger +import java.math.BigDecimal +import java.math.RoundingMode import java.util.Date import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.roundToLong @Singleton class CurrencyRepo @Inject constructor( @@ -42,7 +47,6 @@ class CurrencyRepo @Inject constructor( private val cacheStore: CacheStore ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) - private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() @@ -137,7 +141,8 @@ class CurrencyRepo @Inject constructor( suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) { currencyState.value.primaryDisplay.let { - val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN + val newDisplay = + if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN settingsStore.update { it.copy(primaryDisplay = newDisplay) } } } @@ -157,19 +162,88 @@ class CurrencyRepo @Inject constructor( fun getCurrencySymbol(): String { val currentState = currencyState.value - return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: "" + return currentState.rates.firstOrNull { + it.quote == currentState.selectedCurrency + }?.currencySymbol ?: "" } // Conversion helpers - fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? { + fun getCurrentRate(currency: String): FxRate? { + return _currencyState.value.rates.firstOrNull { it.quote == currency } + } + + fun convertSatsToFiat( + sats: Long, + currency: String? = null, + ): Result = runCatching { + val targetCurrency = currency ?: currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) + + if (rate == null) { + val exception = Exception("Rate not found for targetCurrency: $targetCurrency") + Logger.error("Rate not found", exception, context = TAG) + return Result.failure(exception) + } + + val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) + val value: BigDecimal = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) + val formatted = value.formatCurrency() + + if (formatted == null) { + val exception = Exception("Error formatting currency: $value") + Logger.error("Error formatting currency", exception, context = TAG) + return Result.failure(exception) + } + + return Result.success( + ConvertedAmount( + value = value, + formatted = formatted, + symbol = rate.currencySymbol, + currency = rate.quote, + flag = rate.currencyFlag, + sats = sats, + ) + ) + } + + fun convertFiatToSats( + fiatValue: BigDecimal, + currency: String? = null, + ): Result = runCatching { val targetCurrency = currency ?: currencyState.value.selectedCurrency - val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates) - return rate?.let { currencyService.convert(sats = sats, rate = it) } + val rate = getCurrentRate(targetCurrency) + + if (rate == null) { + val exception = Exception("Rate not found for targetCurrency: $targetCurrency") + Logger.error("Rate not found", exception, context = TAG) + return Result.failure(exception) + } + + val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) + val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) + val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) + return Result.success(roundedNumber.toLong().toULong()) } - fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { - val sourceCurrency = currency ?: currencyState.value.selectedCurrency - return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates) + fun convertFiatToSats( + fiatAmount: Double, + currency: String? + ): Result { + val targetCurrency = currency ?: currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) + + if (rate == null) { + val exception = Exception("Rate not found for targetCurrency: $targetCurrency") + Logger.error("Rate not found", exception, context = TAG) + return Result.failure(exception) + } + + // Convert the fiat amount to BTC, then to sats + val btc = fiatAmount / rate.rate + val sats = (btc * SATS_IN_BTC).roundToLong() + + return Result.success(sats) } companion object { From 64a328d448e51b6f2509fcc991efa3879d1c2861 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 08:57:45 -0300 Subject: [PATCH 02/18] feat: move conversion method from the CurrencyService.kt to CurrencyRepo.kt --- .../to/bitkit/repositories/CurrencyRepo.kt | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 108274761..e5f05db95 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -229,21 +229,11 @@ class CurrencyRepo @Inject constructor( fun convertFiatToSats( fiatAmount: Double, currency: String? - ): Result { - val targetCurrency = currency ?: currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) - - if (rate == null) { - val exception = Exception("Rate not found for targetCurrency: $targetCurrency") - Logger.error("Rate not found", exception, context = TAG) - return Result.failure(exception) - } - - // Convert the fiat amount to BTC, then to sats - val btc = fiatAmount / rate.rate - val sats = (btc * SATS_IN_BTC).roundToLong() - - return Result.success(sats) + ): Result { + return convertFiatToSats( + fiatValue = BigDecimal.valueOf(fiatAmount), + currency = currency + ) } companion object { From fb0994a3ce17632dd9cd2f1fe3eaccee4fc57a66 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 08:58:02 -0300 Subject: [PATCH 03/18] refactor: adapt CurrencyViewModel.kt --- app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt index ebaba6d06..17ae39f11 100644 --- a/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/CurrencyViewModel.kt @@ -53,11 +53,12 @@ class CurrencyViewModel @Inject constructor( // UI Helpers fun convert(sats: Long, currency: String? = null): ConvertedAmount? { - return currencyRepo.convertSatsToFiat(sats, currency) + return currencyRepo.convertSatsToFiat(sats, currency).getOrNull() } fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long { - return currencyRepo.convertFiatToSats(fiatAmount, currency) + val uLongSats = currencyRepo.convertFiatToSats(fiatAmount, currency).getOrNull() ?: 0UL + return uLongSats.toLong() } } From a8a710720e4629c0c4dde265722f4bf5ac78f258 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:10:17 -0300 Subject: [PATCH 04/18] refactor: adapt CurrencyService.kt --- .../to/bitkit/services/CurrencyService.kt | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index ed31c8b2d..83fef334a 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -48,10 +48,10 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur throw lastError ?: CurrencyError.Unknown } - fun loadCachedRates(): List? { - // TODO load from disk - return cachedRates - } +// fun loadCachedRates(): List? { +// // TODO load from disk +// return cachedRates +// } fun convert(sats: Long, rate: FxRate): ConvertedAmount? { val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) @@ -69,40 +69,40 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur ) } - suspend fun convertSatsToFiat(satsAmount: Long, currency: String): Double { - val rates = cachedRates ?: fetchLatestRates() - val rate = getCurrentRate(currency, rates) ?: return 0.0 - - return convert(satsAmount.toLong(), rate)?.value?.toDouble() ?: 0.0 - } - - fun convertFiatToSats(fiatValue: BigDecimal, rate: FxRate): ULong { - val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) - val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) - - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - - return roundedNumber.toLong().toULong() - } - - fun convertFiatToSats(fiatAmount: Double, currency: String, rates: List): Long { - val rate = getCurrentRate(currency, rates) ?: return 0 - - // Convert the fiat amount to BTC, then to sats - val btc = fiatAmount / rate.rate - val sats = (btc * SATS_IN_BTC).roundToLong() - - return sats - } - - suspend fun convertFiatToSats(fiatAmount: Double, currency: String): Long { - val rates = cachedRates ?: fetchLatestRates() - return convertFiatToSats(fiatAmount, currency, rates) - } - - fun getCurrentRate(currency: String, rates: List): FxRate? { - return rates.firstOrNull { it.quote == currency } - } +// suspend fun convertSatsToFiat(satsAmount: Long, currency: String): Double { +// val rates = cachedRates ?: fetchLatestRates() +// val rate = getCurrentRate(currency, rates) ?: return 0.0 +// +// return convert(satsAmount.toLong(), rate)?.value?.toDouble() ?: 0.0 +// } + +// fun convertFiatToSats(fiatValue: BigDecimal, rate: FxRate): ULong { +// val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) +// val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) +// +// val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) +// +// return roundedNumber.toLong().toULong() +// } + +// fun convertFiatToSats(fiatAmount: Double, currency: String, rates: List): Long { +// val rate = getCurrentRate(currency, rates) ?: return 0 +// +// // Convert the fiat amount to BTC, then to sats +// val btc = fiatAmount / rate.rate +// val sats = (btc * SATS_IN_BTC).roundToLong() +// +// return sats +// } + +// suspend fun convertFiatToSats(fiatAmount: Double, currency: String): Long { +// val rates = cachedRates ?: fetchLatestRates() +// return convertFiatToSats(fiatAmount, currency, rates) +// } + +// fun getCurrentRate(currency: String, rates: List): FxRate? { +// return rates.firstOrNull { it.quote == currency } +// } } sealed class CurrencyError(message: String) : AppError(message) { From 205ad19bea5eae08fd732ba487dcac66f4b11305 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:10:41 -0300 Subject: [PATCH 05/18] refactor: adapt AppViewModel.kt --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 76773a593..934165671 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -41,6 +41,7 @@ import to.bitkit.models.Suggestion import to.bitkit.models.Toast import to.bitkit.models.toActivityFilter import to.bitkit.models.toTxType +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.CoreService @@ -59,6 +60,7 @@ import uniffi.bitkitcore.LightningInvoice import uniffi.bitkitcore.OnChainInvoice import uniffi.bitkitcore.PaymentType import uniffi.bitkitcore.Scanner +import java.math.BigDecimal import javax.inject.Inject @@ -76,8 +78,7 @@ class AppViewModel @Inject constructor( private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, private val resourceProvider: ResourceProvider, - private val appStorage: AppStorage, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo ) : ViewModel() { var splashVisible by mutableStateOf(true) private set @@ -511,9 +512,9 @@ class AppViewModel @Inject constructor( return false } - val quickPayAmountSats = currencyService.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD") + val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false - if (amountSats <= quickPayAmountSats.toULong()) { + if (amountSats <= quickPayAmountSats) { Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold") if (isMainScanner) { showSheet(BottomSheetType.Send(SendRoute.QuickPay(invoice, amountSats.toLong()))) @@ -551,8 +552,8 @@ class AppViewModel @Inject constructor( val settings = settingsStore.data.first() if (!settings.enableSendAmountWarning || _sendUiState.value.showAmountWarningDialog) return false - val amountInUsd = currencyService.convertSatsToFiat(amountSats.toLong(), "USD") - if (amountInUsd <= SEND_AMOUNT_WARNING_THRESHOLD) return false + val amountInUsd = currencyRepo.convertSatsToFiat(amountSats.toLong(), "USD").getOrNull() ?: return false + if (amountInUsd.value <= BigDecimal(SEND_AMOUNT_WARNING_THRESHOLD)) return false Logger.debug("Showing send amount warning for $amountSats sats = $$amountInUsd USD") From df5a3339228b135bea928497db51e91c216dc96f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:16:22 -0300 Subject: [PATCH 06/18] refactor: adapt BlocktankViewModel.kt --- .../bitkit/viewmodels/BlocktankViewModel.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt index 8a6e3a714..1a772e4fa 100644 --- a/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/BlocktankViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.nowTimestamp +import to.bitkit.repositories.CurrencyRepo import to.bitkit.services.CoreService import to.bitkit.services.CurrencyService import to.bitkit.services.LightningService @@ -33,12 +34,13 @@ import javax.inject.Inject import kotlin.math.ceil import kotlin.math.min +private const val EUR_CURRENCY = "EUR" @HiltViewModel class BlocktankViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningService: LightningService, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, ) : ViewModel() { var orders = mutableListOf() private set @@ -199,23 +201,20 @@ class BlocktankViewModel @Inject constructor( } val maxLspBalance = info?.options?.maxChannelSizeSat ?: 0uL - // Get current fx rates - val rates = currencyService.loadCachedRates() - val eurRate = rates?.let { currencyService.getCurrentRate("EUR", it) } - if (eurRate == null) { - Logger.error("Failed to get rates for lspBalance calculation", context = "BlocktankViewModel") - throw ServiceError.CurrencyRateUnavailable - } - // Calculate thresholds in sats - val threshold1 = currencyService.convertFiatToSats(BigDecimal("225"), eurRate) - val threshold2 = currencyService.convertFiatToSats(BigDecimal("495"), eurRate) - val defaultLspBalanceSats = currencyService.convertFiatToSats(BigDecimal("450"), eurRate) + val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull() + val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull() + val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull() Logger.debug("getDefaultLspBalance - clientBalance: $clientBalance") Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance") Logger.debug("getDefaultLspBalance - defaultLspBalance: $defaultLspBalanceSats") + if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) { + Logger.error("Failed to get rates for lspBalance calculation", context = "BlocktankViewModel") + throw ServiceError.CurrencyRateUnavailable + } + // Safely calculate lspBalance to avoid arithmetic overflow var lspBalance: ULong = 0u if (defaultLspBalanceSats > clientBalance) { From 9d4e60d6ffc2cc14512b1633342b39985d6cebbb Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:21:20 -0300 Subject: [PATCH 07/18] refactor: adapt TransferViewModel.kt --- .../to/bitkit/viewmodels/TransferViewModel.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index b5b26d505..4edc7fbcb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -20,11 +20,13 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.SettingsStore import to.bitkit.models.TransactionSpeed +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.services.CoreService import to.bitkit.services.CurrencyService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError import uniffi.bitkitcore.BtOrderState2 import uniffi.bitkitcore.IBtInfo import uniffi.bitkitcore.IBtOrder @@ -36,12 +38,13 @@ import kotlin.math.roundToLong const val RETRY_INTERVAL_MS = 1 * 60 * 1000L // 1 minutes in ms const val GIVE_UP_MS = 30 * 60 * 1000L // 30 minutes in ms +private const val EUR_CURRENCY = "EUR" @HiltViewModel class TransferViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val coreService: CoreService, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, private val settingsStore: SettingsStore, ) : ViewModel() { private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState()) @@ -207,22 +210,21 @@ class TransferViewModel @Inject constructor( } private fun getDefaultLspBalance(clientBalanceSat: ULong, maxLspBalance: ULong): ULong { - val rates = currencyService.loadCachedRates() - val eurRate = rates?.let { currencyService.getCurrentRate("EUR", it) } - if (eurRate == null) { - Logger.error("Failed to get rates for getDefaultLspBalance", context = "TransferViewModel") - return 0u - } // Calculate thresholds in sats - val threshold1 = currencyService.convertFiatToSats(BigDecimal("225"), eurRate) - val threshold2 = currencyService.convertFiatToSats(BigDecimal("495"), eurRate) - val defaultLspBalanceSats = currencyService.convertFiatToSats(BigDecimal("450"), eurRate) + val threshold1 = currencyRepo.convertFiatToSats(BigDecimal(225), EUR_CURRENCY).getOrNull() + val threshold2 = currencyRepo.convertFiatToSats(BigDecimal(495), EUR_CURRENCY).getOrNull() + val defaultLspBalanceSats = currencyRepo.convertFiatToSats(BigDecimal(450), EUR_CURRENCY).getOrNull() Logger.debug("getDefaultLspBalance - clientBalanceSat: $clientBalanceSat") Logger.debug("getDefaultLspBalance - maxLspBalance: $maxLspBalance") Logger.debug("getDefaultLspBalance - defaultLspBalanceSats: $defaultLspBalanceSats") + if (threshold1 == null || threshold2 == null || defaultLspBalanceSats == null) { + Logger.error("Failed to get rates for lspBalance calculation", context = "TransferViewModel") + throw ServiceError.CurrencyRateUnavailable + } + // Safely calculate lspBalance to avoid arithmetic overflow var lspBalance: ULong = 0u if (defaultLspBalanceSats > clientBalanceSat) { From 161051a93cd2ae5b763c422a67f84edcf61a62a2 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:28:33 -0300 Subject: [PATCH 08/18] refactor: adapt WeatherService.kt --- .../to/bitkit/data/widgets/WeatherService.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index 83604f4bd..6f9914842 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates import to.bitkit.data.dto.WeatherDTO import to.bitkit.env.Env import to.bitkit.models.WidgetType +import to.bitkit.repositories.CurrencyRepo import to.bitkit.services.CurrencyService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton import kotlin.math.floor @@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes @Singleton class WeatherService @Inject constructor( private val client: HttpClient, - private val currencyService: CurrencyService, + private val currencyRepo: CurrencyRepo, ) : WidgetService { override val widgetType = WidgetType.WEATHER @@ -92,9 +94,9 @@ class WeatherService @Inject constructor( // Check USD threshold first val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE - val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY) + val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE - if (avgFeeUsd <= USD_GOOD_THRESHOLD) { + if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) { return FeeCondition.GOOD } @@ -107,13 +109,8 @@ class WeatherService @Inject constructor( } private suspend fun formatFeeForDisplay(satoshis: Int): String { - val usdValue = convertSatsToUsd(satoshis) - return "$ ${String.format("%.2f", usdValue)}" - } - - private suspend fun convertSatsToUsd(satoshis: Int): Double { - val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY) - return amountInUsd + val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull() + return usdValue?.formatted.orEmpty() } } /** From bac97bdb5bed02c046200d1c2c2eaccc1d4fe152 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:30:13 -0300 Subject: [PATCH 09/18] refactor: adapt CurrencyService.kt --- .../to/bitkit/services/CurrencyService.kt | 70 +------------------ 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CurrencyService.kt b/app/src/main/java/to/bitkit/services/CurrencyService.kt index 83fef334a..463db9bd6 100644 --- a/app/src/main/java/to/bitkit/services/CurrencyService.kt +++ b/app/src/main/java/to/bitkit/services/CurrencyService.kt @@ -3,24 +3,16 @@ package to.bitkit.services import kotlinx.coroutines.delay import to.bitkit.async.ServiceQueue import to.bitkit.data.BlocktankHttpClient -import to.bitkit.models.ConvertedAmount import to.bitkit.models.FxRate -import to.bitkit.models.SATS_IN_BTC -import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.AppError -import java.math.BigDecimal -import java.math.RoundingMode import javax.inject.Inject import javax.inject.Singleton import kotlin.math.pow -import kotlin.math.roundToLong @Singleton -class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH CurrencyRepo +class CurrencyService @Inject constructor( private val blocktankHttpClient: BlocktankHttpClient, ) { - private var cachedRates: List? = null - private val maxRetries = 3 suspend fun fetchLatestRates(): List { @@ -30,10 +22,6 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur try { val response = ServiceQueue.FOREX.background { blocktankHttpClient.fetchLatestRates() } val rates = response.tickers - - // TODO Cache to disk - cachedRates = rates - return rates } catch (e: Exception) { lastError = e @@ -47,62 +35,6 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur throw lastError ?: CurrencyError.Unknown } - -// fun loadCachedRates(): List? { -// // TODO load from disk -// return cachedRates -// } - - fun convert(sats: Long, rate: FxRate): ConvertedAmount? { - val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) - val value: BigDecimal = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - - val formatted = value.formatCurrency() ?: return null - - return ConvertedAmount( - value = value, - formatted = formatted, - symbol = rate.currencySymbol, - currency = rate.quote, - flag = rate.currencyFlag, - sats = sats, - ) - } - -// suspend fun convertSatsToFiat(satsAmount: Long, currency: String): Double { -// val rates = cachedRates ?: fetchLatestRates() -// val rate = getCurrentRate(currency, rates) ?: return 0.0 -// -// return convert(satsAmount.toLong(), rate)?.value?.toDouble() ?: 0.0 -// } - -// fun convertFiatToSats(fiatValue: BigDecimal, rate: FxRate): ULong { -// val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) -// val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) -// -// val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) -// -// return roundedNumber.toLong().toULong() -// } - -// fun convertFiatToSats(fiatAmount: Double, currency: String, rates: List): Long { -// val rate = getCurrentRate(currency, rates) ?: return 0 -// -// // Convert the fiat amount to BTC, then to sats -// val btc = fiatAmount / rate.rate -// val sats = (btc * SATS_IN_BTC).roundToLong() -// -// return sats -// } - -// suspend fun convertFiatToSats(fiatAmount: Double, currency: String): Long { -// val rates = cachedRates ?: fetchLatestRates() -// return convertFiatToSats(fiatAmount, currency, rates) -// } - -// fun getCurrentRate(currency: String, rates: List): FxRate? { -// return rates.firstOrNull { it.quote == currency } -// } } sealed class CurrencyError(message: String) : AppError(message) { From 849e67dd4c55bc10ada23452056ada6a908dca59 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:32:06 -0300 Subject: [PATCH 10/18] refactor: adapt HomeViewModel.kt --- app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 7e71fffb3..faf16a09a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -163,7 +163,7 @@ class HomeViewModel @Inject constructor( } private fun satsToUsd(sats: ULong): BigDecimal? { - val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD") + val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD").getOrNull() return converted?.value } From cc94b77ab15d320234b19dca90ac7c8255bb83b0 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 09:53:35 -0300 Subject: [PATCH 11/18] refactor: improve logs --- .../to/bitkit/repositories/CurrencyRepo.kt | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index e5f05db95..942ffc872 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -37,7 +37,6 @@ import java.math.RoundingMode import java.util.Date import javax.inject.Inject import javax.inject.Singleton -import kotlin.math.roundToLong @Singleton class CurrencyRepo @Inject constructor( @@ -50,10 +49,13 @@ class CurrencyRepo @Inject constructor( private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() + @Volatile private var lastSuccessfulRefresh: Date? = null + + @Volatile private var isRefreshing = false - private val pollingFlow: Flow + private val fxRatePollingFlow: Flow get() = flow { while (currentCoroutineContext().isActive) { emit(Unit) @@ -69,7 +71,7 @@ class CurrencyRepo @Inject constructor( private fun startPolling() { repoScope.launch { - pollingFlow.collect { + fxRatePollingFlow.collect { refresh() } } @@ -77,29 +79,38 @@ class CurrencyRepo @Inject constructor( 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." - ) + 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 -> + combine( + settingsStore.data.distinctUntilChanged(), + cacheStore.data.distinctUntilChanged() + ) { settings, cachedData -> + val selectedRate = cachedData.cachedRates.firstOrNull { rate -> + rate.quote == settings.selectedCurrency + } _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 ?: "$" + currencySymbol = selectedRate?.currencySymbol ?: "$", + error = null, + hasStaleData = false ) }.collect { newState -> _currencyState.update { newState } @@ -126,13 +137,12 @@ class CurrencyRepo @Inject constructor( 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) + _currencyState.update { it.copy(error = e) } lastSuccessfulRefresh?.let { last -> - _currencyState.update { - it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold) - } + val isStale = Date().time - last.time > Env.fxRateStaleThreshold + _currencyState.update { it.copy(hasStaleData = isStale) } } } finally { isRefreshing = false @@ -140,10 +150,13 @@ class CurrencyRepo @Inject constructor( } 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) } + settingsStore.update { settings -> + val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) { + PrimaryDisplay.FIAT + } else { + PrimaryDisplay.BITCOIN + } + settings.copy(primaryDisplay = newDisplay) } } @@ -161,13 +174,9 @@ class CurrencyRepo @Inject constructor( } fun getCurrencySymbol(): String { - val currentState = currencyState.value - return currentState.rates.firstOrNull { - it.quote == currentState.selectedCurrency - }?.currencySymbol ?: "" + return _currencyState.value.currencySymbol } - // Conversion helpers fun getCurrentRate(currency: String): FxRate? { return _currencyState.value.rates.firstOrNull { it.quote == currency } } @@ -176,34 +185,26 @@ class CurrencyRepo @Inject constructor( sats: Long, currency: String? = null, ): Result = runCatching { - val targetCurrency = currency ?: currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) - - if (rate == null) { - val exception = Exception("Rate not found for targetCurrency: $targetCurrency") - Logger.error("Rate not found", exception, context = TAG) - return Result.failure(exception) - } - - val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC)) - val value: BigDecimal = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - val formatted = value.formatCurrency() + val targetCurrency = currency ?: _currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException( + "Rate not found for currency: $targetCurrency. Available currencies: ${ + _currencyState.value.rates.joinToString { it.quote } + }" + ) - if (formatted == null) { - val exception = Exception("Error formatting currency: $value") - Logger.error("Error formatting currency", exception, context = TAG) - return Result.failure(exception) - } + val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) + val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) + val formatted = value.formatCurrency() ?: throw IllegalStateException( + "Failed to format value: $value for currency: $targetCurrency" + ) - return Result.success( - ConvertedAmount( - value = value, - formatted = formatted, - symbol = rate.currencySymbol, - currency = rate.quote, - flag = rate.currencyFlag, - sats = sats, - ) + ConvertedAmount( + value = value, + formatted = formatted, + symbol = rate.currencySymbol, + currency = rate.quote, + flag = rate.currencyFlag, + sats = sats, ) } @@ -211,19 +212,17 @@ class CurrencyRepo @Inject constructor( fiatValue: BigDecimal, currency: String? = null, ): Result = runCatching { - val targetCurrency = currency ?: currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) - - if (rate == null) { - val exception = Exception("Rate not found for targetCurrency: $targetCurrency") - Logger.error("Rate not found", exception, context = TAG) - return Result.failure(exception) - } + val targetCurrency = currency ?: _currencyState.value.selectedCurrency + val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException( + "Rate not found for currency: $targetCurrency. Available currencies: ${ + _currencyState.value.rates.joinToString { it.quote } + }" + ) - val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), 8, RoundingMode.HALF_UP) + val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP) val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC)) - val roundedNumber = satsDecimal.setScale(0, RoundingMode.HALF_UP) - return Result.success(roundedNumber.toLong().toULong()) + val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP) + roundedSats.toLong().toULong() } fun convertFiatToSats( @@ -238,6 +237,7 @@ class CurrencyRepo @Inject constructor( companion object { private const val TAG = "CurrencyRepo" + private const val BTC_SCALE = 8 } } From 01a805858ca50a3b24c6a185f52363dfa0190dd6 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 11:36:30 -0300 Subject: [PATCH 12/18] feat: set pooling as optional --- app/src/main/java/to/bitkit/di/EnablePolling.kt | 17 +++++++++++++++++ .../java/to/bitkit/repositories/CurrencyRepo.kt | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/to/bitkit/di/EnablePolling.kt diff --git a/app/src/main/java/to/bitkit/di/EnablePolling.kt b/app/src/main/java/to/bitkit/di/EnablePolling.kt new file mode 100644 index 000000000..f1d65fb49 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/EnablePolling.kt @@ -0,0 +1,17 @@ +package to.bitkit.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named + + +@Module +@InstallIn(SingletonComponent::class) +object CurrencyModule { + + @Provides + @Named("enablePolling") + fun provideEnablePolling(): Boolean = true +} diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 942ffc872..abba871b9 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -36,6 +36,7 @@ import java.math.BigDecimal import java.math.RoundingMode import java.util.Date import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton @Singleton @@ -43,7 +44,8 @@ class CurrencyRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val currencyService: CurrencyService, private val settingsStore: SettingsStore, - private val cacheStore: CacheStore + private val cacheStore: CacheStore, + @Named("enablePolling") private val enablePolling: Boolean, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _currencyState = MutableStateFlow(CurrencyState()) @@ -64,7 +66,9 @@ class CurrencyRepo @Inject constructor( }.flowOn(bgDispatcher) init { - startPolling() + if (enablePolling) { + startPolling() + } observeStaleData() collectCachedData() } From 8ccc91107143cde579385aaadc2cfe44f5e9df00 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 13:21:25 -0300 Subject: [PATCH 13/18] test: CurrencyRepoTest.kt --- .../to/bitkit/repositories/CurrencyRepo.kt | 24 +-- .../bitkit/repositories/CurrencyRepoTest.kt | 198 ++++++++++++++++++ 2 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index abba871b9..60f26c1a4 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -51,9 +51,6 @@ class CurrencyRepo @Inject constructor( private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() - @Volatile - private var lastSuccessfulRefresh: Date? = null - @Volatile private var isRefreshing = false @@ -138,14 +135,13 @@ class CurrencyRepo @Inject constructor( hasStaleData = false ) } - lastSuccessfulRefresh = Date() Logger.debug("Currency rates refreshed successfully", context = TAG) } catch (e: Exception) { Logger.error("Currency rates refresh failed", e, context = TAG) _currencyState.update { it.copy(error = e) } - lastSuccessfulRefresh?.let { last -> - val isStale = Date().time - last.time > Env.fxRateStaleThreshold + _currencyState.value.rates.firstOrNull()?.lastUpdatedAt?.let { lastUpdatedAt -> + val isStale = Date().time - lastUpdatedAt > Env.fxRateStaleThreshold _currencyState.update { it.copy(hasStaleData = isStale) } } } finally { @@ -190,16 +186,20 @@ class CurrencyRepo @Inject constructor( currency: String? = null, ): Result = runCatching { val targetCurrency = currency ?: _currencyState.value.selectedCurrency - val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException( - "Rate not found for currency: $targetCurrency. Available currencies: ${ - _currencyState.value.rates.joinToString { it.quote } - }" + val rate = getCurrentRate(targetCurrency) ?: return Result.failure( + IllegalStateException( + "Rate not found for currency: $targetCurrency. Available currencies: ${ + _currencyState.value.rates.joinToString { it.quote } + }" + ) ) val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP) val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate)) - val formatted = value.formatCurrency() ?: throw IllegalStateException( - "Failed to format value: $value for currency: $targetCurrency" + val formatted = value.formatCurrency() ?: return Result.failure( + IllegalStateException( + "Failed to format value: $value for currency: $targetCurrency" + ) ) ConvertedAmount( diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt new file mode 100644 index 000000000..e289fb121 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -0,0 +1,198 @@ +package to.bitkit.repositories + +import app.cash.turbine.test +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.PrimaryDisplay +import to.bitkit.services.CurrencyService +import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.ToastEventBus +import java.math.BigDecimal +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class CurrencyRepoTest : BaseUnitTest() { + private val currencyService: CurrencyService = mock() + private val settingsStore: SettingsStore = mock() + private val cacheStore: CacheStore = mock() + private val toastEventBus: ToastEventBus = mock() + + private lateinit var sut: CurrencyRepo + + private val testRates = listOf( + FxRate( + symbol = "BTCUSD", + lastPrice = "50000.00", + base = "BTC", + baseName = "Bitcoin", + quote = "USD", + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "πŸ‡ΊπŸ‡Έ", + lastUpdatedAt = System.currentTimeMillis() + ), + FxRate( + symbol = "BTCEUR", + lastPrice = "45000.00", + base = "BTC", + baseName = "Bitcoin", + quote = "EUR", + quoteName = "Euro", + currencySymbol = "€", + currencyFlag = "πŸ‡ͺπŸ‡Ί", + lastUpdatedAt = System.currentTimeMillis() + ) + ) + + @Before + fun setUp() { + // Set up default mocks + whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData())) + } + + private fun createSut(): CurrencyRepo { + return CurrencyRepo( + bgDispatcher = testDispatcher, + currencyService = currencyService, + settingsStore = settingsStore, + cacheStore = cacheStore, + enablePolling = false, + ) + } + + @Test + fun `initial state should have default values`() = test { + sut = createSut() + + // Use timeout to prevent hanging + sut.currencyState.test(timeout = 1000.milliseconds) { + val initialState = awaitItem() + assertEquals(emptyList(), initialState.rates) + assertEquals("USD", initialState.selectedCurrency) + assertEquals("$", initialState.currencySymbol) + assertEquals(BitcoinDisplayUnit.MODERN, initialState.displayUnit) + assertEquals(PrimaryDisplay.BITCOIN, initialState.primaryDisplay) + assertFalse(initialState.hasStaleData) + assertNull(initialState.error) + } + } + + @Test + fun `convertSatsToFiat should handle rate properties correctly`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + + sut = createSut() + val result = sut.convertSatsToFiat(100_000L) // 100k sats = 0.001 BTC + + // Wait for initial state to be set up, then test conversion + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + assertTrue(result.isSuccess) + val converted = result.getOrThrow() + assertEquals(BigDecimal("50.000000000"), converted.value) // 0.001 * 50000 + assertEquals("50.00", converted.formatted) + assertEquals("$", converted.symbol) + assertEquals("USD", converted.currency) + assertEquals("πŸ‡ΊπŸ‡Έ", converted.flag) + assertEquals(100_000L, converted.sats) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `convertFiatToSats should use correct rate and precision`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + + sut = createSut() + + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + val result = sut.convertFiatToSats(BigDecimal("45.00")) // 45 EUR / 45000 = 0.001 BTC + assertTrue(result.isSuccess) + assertEquals(100_000uL, result.getOrThrow()) // 0.001 BTC in sats + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `should detect stale data based on lastUpdatedAt`() = test { + val oldRates = listOf( + testRates[0].copy( + lastUpdatedAt = System.currentTimeMillis() - 1000 * 60 * 60 * 3 // 3 hours ago + ) + ) + + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = oldRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + wheneverBlocking { currencyService.fetchLatestRates() }.thenThrow(RuntimeException("API error")) + + sut = createSut() + sut.triggerRefresh() + + sut.currencyState.test(timeout = 2000.milliseconds) { + val staleState = awaitItem() + assertTrue(staleState.hasStaleData) + assertEquals(oldRates, staleState.rates) + } + } + + @Test + fun `getCurrentRate should match by quote currency`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "EUR"))) + + sut = createSut() + val rate = sut.getCurrentRate("EUR") + + sut.currencyState.take(1).test(timeout = 1000.milliseconds) { + awaitItem() // Wait for state to be initialized + + assertEquals(testRates[1], rate) + assertEquals(45000.0, rate?.rate) + assertEquals("€", rate?.currencySymbol) + assertEquals("πŸ‡ͺπŸ‡Ί", rate?.currencyFlag) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `state should update when settings change`() = test { + val testSettings = SettingsData( + selectedCurrency = "EUR", + displayUnit = BitcoinDisplayUnit.CLASSIC, + primaryDisplay = PrimaryDisplay.FIAT + ) + + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(testSettings)) + + sut = createSut() + + sut.currencyState.test(timeout = 1000.milliseconds) { + val updatedState = awaitItem() + assertEquals("EUR", updatedState.selectedCurrency) + assertEquals("€", updatedState.currencySymbol) + assertEquals(BitcoinDisplayUnit.CLASSIC, updatedState.displayUnit) + assertEquals(PrimaryDisplay.FIAT, updatedState.primaryDisplay) + } + } +} From 9638e784a51287beead99124591045943ebd9a7b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Thu, 19 Jun 2025 13:37:21 -0300 Subject: [PATCH 14/18] refactor: remove unused parameter --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e10cdb88b..829ccfd1f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -53,7 +53,6 @@ import to.bitkit.ui.components.BottomSheetType import to.bitkit.ui.screens.wallets.send.SendRoute import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger -import to.bitkit.utils.ResourceProvider import uniffi.bitkitcore.ActivityFilter import uniffi.bitkitcore.LightningInvoice import uniffi.bitkitcore.OnChainInvoice @@ -75,7 +74,6 @@ class AppViewModel @Inject constructor( private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val settingsStore: SettingsStore, - private val resourceProvider: ResourceProvider, private val currencyRepo: CurrencyRepo, ) : ViewModel() { var splashVisible by mutableStateOf(true) @@ -523,7 +521,8 @@ class AppViewModel @Inject constructor( return false } - val quickPayAmountSats = currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false + val quickPayAmountSats = + currencyRepo.convertFiatToSats(settings.quickPayAmount.toDouble(), "USD").getOrNull() ?: return false if (amountSats <= quickPayAmountSats) { Logger.info("Using QuickPay: $amountSats sats <= $quickPayAmountSats sats threshold") @@ -676,7 +675,8 @@ class AppViewModel @Inject constructor( bolt11: String, amount: ULong? = null, ): Result { - val hash = lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() // TODO HANDLE FAILURE IN OTHER PR + val hash = + lightningService.payInvoice(bolt11 = bolt11, sats = amount).getOrNull() // TODO HANDLE FAILURE IN OTHER PR // Wait until matching payment event is received val result = ldkNodeEventBus.events.watchUntil { event -> From eb37e48e45d3e9579a8e5244ba6dfffa3f51d4ca Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 20 Jun 2025 07:14:29 -0300 Subject: [PATCH 15/18] fix: use lastSuccessfulRefresh instead of the date from the rates --- .../to/bitkit/repositories/CurrencyRepo.kt | 9 +++++--- .../bitkit/repositories/CurrencyRepoTest.kt | 22 ------------------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 60f26c1a4..63d05504b 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -132,7 +133,8 @@ class CurrencyRepo @Inject constructor( _currencyState.update { it.copy( error = null, - hasStaleData = false + hasStaleData = false, + lastSuccessfulRefresh = Clock.System.now().toEpochMilliseconds(), ) } Logger.debug("Currency rates refreshed successfully", context = TAG) @@ -140,8 +142,8 @@ class CurrencyRepo @Inject constructor( Logger.error("Currency rates refresh failed", e, context = TAG) _currencyState.update { it.copy(error = e) } - _currencyState.value.rates.firstOrNull()?.lastUpdatedAt?.let { lastUpdatedAt -> - val isStale = Date().time - lastUpdatedAt > Env.fxRateStaleThreshold + _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> + val isStale = Clock.System.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold _currencyState.update { it.copy(hasStaleData = isStale) } } } finally { @@ -253,4 +255,5 @@ data class CurrencyState( val currencySymbol: String = "$", val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN, + val lastSuccessfulRefresh: Long? = null ) diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index e289fb121..c95342a9e 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -133,28 +133,6 @@ class CurrencyRepoTest : BaseUnitTest() { } } - @Test - fun `should detect stale data based on lastUpdatedAt`() = test { - val oldRates = listOf( - testRates[0].copy( - lastUpdatedAt = System.currentTimeMillis() - 1000 * 60 * 60 * 3 // 3 hours ago - ) - ) - - whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = oldRates))) - whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) - wheneverBlocking { currencyService.fetchLatestRates() }.thenThrow(RuntimeException("API error")) - - sut = createSut() - sut.triggerRefresh() - - sut.currencyState.test(timeout = 2000.milliseconds) { - val staleState = awaitItem() - assertTrue(staleState.hasStaleData) - assertEquals(oldRates, staleState.rates) - } - } - @Test fun `getCurrentRate should match by quote currency`() = test { whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) From db16ccbe61bda264b1c756aded5d713911d6fae1 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 20 Jun 2025 07:27:55 -0300 Subject: [PATCH 16/18] refactor: remove imports --- app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt | 1 - app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 63d05504b..29a948c87 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -35,7 +35,6 @@ import to.bitkit.ui.utils.formatCurrency import to.bitkit.utils.Logger import java.math.BigDecimal import java.math.RoundingMode -import java.util.Date import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index c95342a9e..e12be2a00 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -7,7 +7,6 @@ import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData From 70c4a70f2f5538f0b3c48821cf0733455f103367 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 20 Jun 2025 07:51:52 -0300 Subject: [PATCH 17/18] test: implement stale tests --- .../main/java/to/bitkit/di/EnablePolling.kt | 6 +++++ .../to/bitkit/repositories/CurrencyRepo.kt | 7 ++--- .../bitkit/repositories/CurrencyRepoTest.kt | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/di/EnablePolling.kt b/app/src/main/java/to/bitkit/di/EnablePolling.kt index f1d65fb49..f96640464 100644 --- a/app/src/main/java/to/bitkit/di/EnablePolling.kt +++ b/app/src/main/java/to/bitkit/di/EnablePolling.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock import javax.inject.Named @@ -14,4 +15,9 @@ object CurrencyModule { @Provides @Named("enablePolling") fun provideEnablePolling(): Boolean = true + + @Provides + fun provideClock(): Clock { + return Clock.System + } } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 29a948c87..6b0c520af 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -46,7 +46,8 @@ class CurrencyRepo @Inject constructor( private val settingsStore: SettingsStore, private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, -) { + private val clock: Clock, + ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() @@ -133,7 +134,7 @@ class CurrencyRepo @Inject constructor( it.copy( error = null, hasStaleData = false, - lastSuccessfulRefresh = Clock.System.now().toEpochMilliseconds(), + lastSuccessfulRefresh = clock.now().toEpochMilliseconds(), ) } Logger.debug("Currency rates refreshed successfully", context = TAG) @@ -142,7 +143,7 @@ class CurrencyRepo @Inject constructor( _currencyState.update { it.copy(error = e) } _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> - val isStale = Clock.System.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold + val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold _currencyState.update { it.copy(hasStaleData = isStale) } } } finally { diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index e12be2a00..d9bd60554 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -3,10 +3,12 @@ package to.bitkit.repositories import app.cash.turbine.test import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.take +import kotlinx.datetime.Clock import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.AppCacheData import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData @@ -22,13 +24,16 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes class CurrencyRepoTest : BaseUnitTest() { private val currencyService: CurrencyService = mock() private val settingsStore: SettingsStore = mock() private val cacheStore: CacheStore = mock() private val toastEventBus: ToastEventBus = mock() + private val clock: Clock = mock() private lateinit var sut: CurrencyRepo @@ -71,6 +76,7 @@ class CurrencyRepoTest : BaseUnitTest() { settingsStore = settingsStore, cacheStore = cacheStore, enablePolling = false, + clock = clock ) } @@ -172,4 +178,24 @@ class CurrencyRepoTest : BaseUnitTest() { assertEquals(PrimaryDisplay.FIAT, updatedState.primaryDisplay) } } + + @Test + fun `should detect stale data based on lastUpdatedAt`() = test { + whenever(cacheStore.data).thenReturn(flowOf(AppCacheData(cachedRates = testRates))) + whenever(settingsStore.data).thenReturn(flowOf(SettingsData(selectedCurrency = "USD"))) + + sut = createSut() + whenever(clock.now()).thenReturn(Clock.System.now().minus(10.minutes)) + sut.triggerRefresh() + + wheneverBlocking { currencyService.fetchLatestRates() }.thenThrow(RuntimeException("API error")) + whenever(clock.now()).thenReturn(Clock.System.now()) + sut.triggerRefresh() + + sut.currencyState.test(timeout = 2000.milliseconds) { + val staleState = awaitItem() + assertTrue(staleState.hasStaleData) + assertEquals(testRates, staleState.rates) + } + } } From 55ac6582f05a3bce586a617bc808991a65a793a4 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Fri, 20 Jun 2025 08:07:53 -0300 Subject: [PATCH 18/18] refactor: move clock to env module --- app/src/main/java/to/bitkit/di/EnablePolling.kt | 6 ------ app/src/main/java/to/bitkit/di/EnvModule.kt | 6 ++++++ app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt | 4 ++-- .../test/java/to/bitkit/repositories/CurrencyRepoTest.kt | 1 - 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/di/EnablePolling.kt b/app/src/main/java/to/bitkit/di/EnablePolling.kt index f96640464..f1d65fb49 100644 --- a/app/src/main/java/to/bitkit/di/EnablePolling.kt +++ b/app/src/main/java/to/bitkit/di/EnablePolling.kt @@ -4,7 +4,6 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.datetime.Clock import javax.inject.Named @@ -15,9 +14,4 @@ object CurrencyModule { @Provides @Named("enablePolling") fun provideEnablePolling(): Boolean = true - - @Provides - fun provideClock(): Clock { - return Clock.System - } } diff --git a/app/src/main/java/to/bitkit/di/EnvModule.kt b/app/src/main/java/to/bitkit/di/EnvModule.kt index eff7b6de4..f7b3c187c 100644 --- a/app/src/main/java/to/bitkit/di/EnvModule.kt +++ b/app/src/main/java/to/bitkit/di/EnvModule.kt @@ -6,6 +6,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.datetime.Clock import org.lightningdevkit.ldknode.Network import to.bitkit.env.Env @@ -17,4 +18,9 @@ object EnvModule { fun provideNetwork(): Network { return Env.network } + + @Provides + fun provideClock(): Clock { + return Clock.System + } } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 6b0c520af..85aa8af91 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -47,7 +47,7 @@ class CurrencyRepo @Inject constructor( private val cacheStore: CacheStore, @Named("enablePolling") private val enablePolling: Boolean, private val clock: Clock, - ) { +) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _currencyState = MutableStateFlow(CurrencyState()) val currencyState: StateFlow = _currencyState.asStateFlow() @@ -142,7 +142,7 @@ class CurrencyRepo @Inject constructor( Logger.error("Currency rates refresh failed", e, context = TAG) _currencyState.update { it.copy(error = e) } - _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> + _currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt -> val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold _currencyState.update { it.copy(hasStaleData = isStale) } } diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index d9bd60554..2faadc35b 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -24,7 +24,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes