Skip to content

Commit 93e872b

Browse files
authored
Merge pull request #199 from synonymdev/refactor/currency-repository
Currency repository
2 parents f67cbbe + 340d83e commit 93e872b

File tree

8 files changed

+285
-136
lines changed

8 files changed

+285
-136
lines changed

app/src/main/java/to/bitkit/data/AppStorage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import kotlin.reflect.KProperty
2020

2121
const val APP_PREFS = "bitkit_prefs"
2222

23-
// TODO refactor to dataStore (named 'CacheStore'?!)
23+
@Deprecated("Replace with CacheStore")
2424
@Singleton
2525
class AppStorage @Inject constructor(
2626
@ApplicationContext private val appContext: Context,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package to.bitkit.data
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.core.DataStoreFactory
6+
import androidx.datastore.dataStoreFile
7+
import dagger.hilt.android.qualifiers.ApplicationContext
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.serialization.Serializable
10+
import to.bitkit.data.serializers.AppCacheSerializer
11+
import to.bitkit.data.serializers.SettingsSerializer
12+
import to.bitkit.models.BitcoinDisplayUnit
13+
import to.bitkit.models.FxRate
14+
import to.bitkit.models.PrimaryDisplay
15+
import to.bitkit.models.Suggestion
16+
import to.bitkit.models.TransactionSpeed
17+
import to.bitkit.utils.Logger
18+
import javax.inject.Inject
19+
import javax.inject.Singleton
20+
21+
@Singleton
22+
class CacheStore @Inject constructor(
23+
@ApplicationContext private val context: Context,
24+
) {
25+
private val store: DataStore<AppCacheData> = DataStoreFactory.create(
26+
serializer = AppCacheSerializer,
27+
produceFile = { context.dataStoreFile("app_cache.json") },
28+
)
29+
30+
val data: Flow<AppCacheData> = store.data
31+
32+
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
33+
store.updateData(transform)
34+
}
35+
36+
37+
suspend fun reset() {
38+
store.updateData { AppCacheData() }
39+
Logger.info("Deleted all app cached data.")
40+
}
41+
}
42+
43+
@Serializable
44+
data class AppCacheData(
45+
val cachedRates : List<FxRate> = listOf()
46+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package to.bitkit.data.serializers
2+
3+
import androidx.datastore.core.Serializer
4+
import kotlinx.serialization.SerializationException
5+
import to.bitkit.data.AppCacheData
6+
import to.bitkit.data.SettingsData
7+
import to.bitkit.di.json
8+
import to.bitkit.utils.Logger
9+
import java.io.InputStream
10+
import java.io.OutputStream
11+
12+
object AppCacheSerializer : Serializer<AppCacheData> {
13+
override val defaultValue: AppCacheData = AppCacheData()
14+
15+
override suspend fun readFrom(input: InputStream): AppCacheData {
16+
return try {
17+
json.decodeFromString(input.readBytes().decodeToString())
18+
} catch (e: SerializationException) {
19+
Logger.error("Failed to deserialize: $e")
20+
defaultValue
21+
}
22+
}
23+
24+
override suspend fun writeTo(t: AppCacheData, output: OutputStream) {
25+
output.write(json.encodeToString(t).encodeToByteArray())
26+
}
27+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package to.bitkit.repositories
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.SupervisorJob
6+
import kotlinx.coroutines.currentCoroutineContext
7+
import kotlinx.coroutines.delay
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.MutableStateFlow
10+
import kotlinx.coroutines.flow.StateFlow
11+
import kotlinx.coroutines.flow.asStateFlow
12+
import kotlinx.coroutines.flow.combine
13+
import kotlinx.coroutines.flow.distinctUntilChanged
14+
import kotlinx.coroutines.flow.flow
15+
import kotlinx.coroutines.flow.flowOn
16+
import kotlinx.coroutines.flow.map
17+
import kotlinx.coroutines.flow.update
18+
import kotlinx.coroutines.isActive
19+
import kotlinx.coroutines.launch
20+
import kotlinx.coroutines.withContext
21+
import to.bitkit.data.CacheStore
22+
import to.bitkit.data.SettingsStore
23+
import to.bitkit.di.BgDispatcher
24+
import to.bitkit.env.Env
25+
import to.bitkit.models.BitcoinDisplayUnit
26+
import to.bitkit.models.ConvertedAmount
27+
import to.bitkit.models.FxRate
28+
import to.bitkit.models.PrimaryDisplay
29+
import to.bitkit.models.Toast
30+
import to.bitkit.services.CurrencyService
31+
import to.bitkit.ui.shared.toast.ToastEventBus
32+
import to.bitkit.utils.Logger
33+
import java.util.Date
34+
import javax.inject.Inject
35+
import javax.inject.Singleton
36+
37+
@Singleton
38+
class CurrencyRepo @Inject constructor(
39+
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
40+
private val currencyService: CurrencyService,
41+
private val settingsStore: SettingsStore,
42+
private val cacheStore: CacheStore
43+
) {
44+
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())
45+
46+
private val _currencyState = MutableStateFlow(CurrencyState())
47+
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()
48+
49+
private var lastSuccessfulRefresh: Date? = null
50+
private var isRefreshing = false
51+
52+
private val pollingFlow: Flow<Unit>
53+
get() = flow {
54+
while (currentCoroutineContext().isActive) {
55+
emit(Unit)
56+
delay(Env.fxRateRefreshInterval)
57+
}
58+
}.flowOn(bgDispatcher)
59+
60+
init {
61+
startPolling()
62+
observeStaleData()
63+
collectCachedData()
64+
}
65+
66+
private fun startPolling() {
67+
repoScope.launch {
68+
pollingFlow.collect {
69+
refresh()
70+
}
71+
}
72+
}
73+
74+
private fun observeStaleData() {
75+
repoScope.launch {
76+
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
77+
if (isStale) {
78+
ToastEventBus.send(
79+
type = Toast.ToastType.ERROR,
80+
title = "Rates currently unavailable",
81+
description = "An error has occurred. Please try again later."
82+
)
83+
}
84+
}
85+
}
86+
}
87+
88+
private fun collectCachedData() {
89+
repoScope.launch {
90+
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
91+
_currencyState.value.copy(
92+
rates = cachedData.cachedRates,
93+
selectedCurrency = settings.selectedCurrency,
94+
displayUnit = settings.displayUnit,
95+
primaryDisplay = settings.primaryDisplay,
96+
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
97+
rate.quote == settings.selectedCurrency
98+
}?.currencySymbol ?: "$"
99+
)
100+
}.collect { newState ->
101+
_currencyState.update { newState }
102+
}
103+
}
104+
}
105+
106+
suspend fun triggerRefresh() = withContext(bgDispatcher) {
107+
refresh()
108+
}
109+
110+
private suspend fun refresh() {
111+
if (isRefreshing) return
112+
isRefreshing = true
113+
try {
114+
val fetchedRates = currencyService.fetchLatestRates()
115+
cacheStore.update { it.copy(cachedRates = fetchedRates) }
116+
_currencyState.update {
117+
it.copy(
118+
error = null,
119+
hasStaleData = false
120+
)
121+
}
122+
lastSuccessfulRefresh = Date()
123+
Logger.debug("Currency rates refreshed successfully", context = TAG)
124+
} catch (e: Exception) {
125+
_currencyState.update { it.copy(error = e) }
126+
Logger.error("Currency rates refresh failed", e, context = TAG)
127+
128+
lastSuccessfulRefresh?.let { last ->
129+
_currencyState.update {
130+
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
131+
}
132+
}
133+
} finally {
134+
isRefreshing = false
135+
}
136+
}
137+
138+
suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
139+
currencyState.value.primaryDisplay.let {
140+
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
141+
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
142+
}
143+
}
144+
145+
suspend fun setPrimaryDisplayUnit(unit: PrimaryDisplay) = withContext(bgDispatcher) {
146+
settingsStore.update { it.copy(primaryDisplay = unit) }
147+
}
148+
149+
suspend fun setBtcDisplayUnit(unit: BitcoinDisplayUnit) = withContext(bgDispatcher) {
150+
settingsStore.update { it.copy(displayUnit = unit) }
151+
}
152+
153+
suspend fun setSelectedCurrency(currency: String) = withContext(bgDispatcher) {
154+
settingsStore.update { it.copy(selectedCurrency = currency) }
155+
refresh()
156+
}
157+
158+
fun getCurrencySymbol(): String {
159+
val currentState = currencyState.value
160+
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
161+
}
162+
163+
// Conversion helpers
164+
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
165+
val targetCurrency = currency ?: currencyState.value.selectedCurrency
166+
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
167+
return rate?.let { currencyService.convert(sats = sats, rate = it) }
168+
}
169+
170+
fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
171+
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
172+
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
173+
}
174+
175+
companion object {
176+
private const val TAG = "CurrencyRepo"
177+
}
178+
}
179+
180+
data class CurrencyState(
181+
val rates: List<FxRate> = emptyList(),
182+
val error: Throwable? = null,
183+
val hasStaleData: Boolean = false,
184+
val selectedCurrency: String = "USD",
185+
val currencySymbol: String = "$",
186+
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
187+
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
188+
)

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.lightningdevkit.ldknode.Event
1313
import org.lightningdevkit.ldknode.Txid
1414
import to.bitkit.data.AppDb
1515
import to.bitkit.data.AppStorage
16+
import to.bitkit.data.CacheStore
1617
import to.bitkit.data.SettingsStore
1718
import to.bitkit.data.entities.InvoiceTagEntity
1819
import to.bitkit.data.keychain.Keychain
@@ -47,6 +48,7 @@ class WalletRepo @Inject constructor(
4748
private val settingsStore: SettingsStore,
4849
private val addressChecker: AddressChecker,
4950
private val lightningRepo: LightningRepo,
51+
private val cacheStore: CacheStore
5052
) {
5153

5254
private val _walletState = MutableStateFlow(
@@ -201,6 +203,7 @@ class WalletRepo @Inject constructor(
201203
keychain.wipe()
202204
appStorage.clear()
203205
settingsStore.reset()
206+
cacheStore.reset()
204207
coreService.activity.removeAll()
205208
deleteAllInvoices()
206209
_walletState.update { WalletState() }

app/src/main/java/to/bitkit/services/CurrencyService.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import kotlin.math.pow
1616
import kotlin.math.roundToLong
1717

1818
@Singleton
19-
class CurrencyService @Inject constructor(
19+
class CurrencyService @Inject constructor( //TODO REPLACE DIRECT ACCESS WITH CurrencyRepo
2020
private val blocktankHttpClient: BlocktankHttpClient,
2121
) {
2222
private var cachedRates: List<FxRate>? = null

0 commit comments

Comments
 (0)