Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand Down Expand Up @@ -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
}

Expand All @@ -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()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line number Diff line number Diff line change
@@ -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
}
142 changes: 105 additions & 37 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,35 @@ 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.Named
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 cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All @@ -58,44 +63,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.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."
)
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 }
Expand All @@ -119,26 +135,28 @@ class CurrencyRepo @Inject constructor(
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)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = 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 {
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) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All @@ -156,24 +174,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
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() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

// 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(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = 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 btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.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<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand Down
70 changes: 1 addition & 69 deletions app/src/main/java/to/bitkit/services/CurrencyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<FxRate>? = null

private val maxRetries = 3

suspend fun fetchLatestRates(): List<FxRate> {
Expand All @@ -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
Expand All @@ -47,62 +35,6 @@ class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH Cur

throw lastError ?: CurrencyError.Unknown
}

fun loadCachedRates(): List<FxRate>? {
// 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<FxRate>): 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>): FxRate? {
return rates.firstOrNull { it.quote == currency }
}
}

sealed class CurrencyError(message: String) : AppError(message) {
Expand Down
Loading