Updated on 2026-08-14

This commit is contained in:
Tangem 2023-10-23 11:59:24 +03:00
commit 6bcb15430c
971 changed files with 24716 additions and 13701 deletions

View file

@ -1,6 +1,7 @@
package com.tangem.data.appcurrency
import com.tangem.data.appcurrency.utils.AppCurrencyConverter
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CurrenciesResponse
@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.joda.time.Duration
import timber.log.Timber
internal class DefaultAppCurrencyRepository(
private val tangemTechApi: TangemTechApi,
@ -34,7 +34,7 @@ internal class DefaultAppCurrencyRepository(
.collect(::send)
}
launch(dispatchers.io) {
withContext(dispatchers.io) {
if (selectedAppCurrencyStore.isEmpty()) {
fetchDefaultAppCurrency()
}
@ -47,6 +47,7 @@ internal class DefaultAppCurrencyRepository(
val currencies = availableAppCurrenciesStore.getAllSyncOrNull()
?.map(appCurrencyConverter::convert)
?.sortedBy(AppCurrency::name)
requireNotNull(currencies) {
"No available currencies stored"
@ -80,15 +81,15 @@ internal class DefaultAppCurrencyRepository(
}
private suspend fun fetchAvailableCurrencies() {
try {
val response = tangemTechApi.getCurrencyList()
val response = safeApiCall(
call = { tangemTechApi.getCurrencyList().bind() },
onError = {
cacheRegistry.invalidate(AVAILABLE_CURRENCIES_CACHE_KEY)
getDefaultCurrenciesResponse()
},
)
availableAppCurrenciesStore.store(response)
} catch (e: Throwable) {
Timber.e(e, "Unable to fetch available currencies")
availableAppCurrenciesStore.store(getDefaultCurrenciesResponse())
}
availableAppCurrenciesStore.store(response)
}
private fun getDefaultCurrenciesResponse(): CurrenciesResponse = CurrenciesResponse(

View file

@ -12,13 +12,11 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.core)
implementation(projects.domain.appTheme)
implementation(projects.domain.appTheme.models)
/** Project - Data */
implementation(projects.core.datasource)
implementation(projects.data.common)
/** Project - Utils */
implementation(projects.core.utils)
@ -29,6 +27,4 @@ dependencies {
/** Other */
implementation(deps.kotlin.coroutines)
implementation(deps.timber)
implementation(deps.jodatime)
}

View file

@ -0,0 +1,35 @@
package com.tangem.data.apptheme
import com.tangem.datasource.local.apptheme.AppThemeModeStore
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal class DefaultAppThemeModeRepository(
private val appThemeModeStore: AppThemeModeStore,
private val dispatchers: CoroutineDispatcherProvider,
) : AppThemeModeRepository {
override fun getAppThemeMode(): Flow<AppThemeMode> {
return channelFlow {
launch(dispatchers.io) {
if (appThemeModeStore.isEmpty()) {
appThemeModeStore.store(AppThemeMode.DEFAULT)
}
}
launch(dispatchers.io) {
appThemeModeStore.get().collect(::send)
}
}
}
override suspend fun changeAppThemeMode(mode: AppThemeMode) {
withContext(dispatchers.io) {
appThemeModeStore.store(mode)
}
}
}

View file

@ -1,19 +0,0 @@
package com.tangem.data.apptheme
import com.tangem.domain.apptheme.model.AppThemeMode
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
internal class MockAppThemeModeRepository : AppThemeModeRepository {
private val appThemeModeFlow = MutableStateFlow(AppThemeMode.DEFAULT)
override fun getAppThemeMode(): Flow<AppThemeMode> {
return appThemeModeFlow
}
override suspend fun changeAppThemeMode(mode: AppThemeMode) {
appThemeModeFlow.value = mode
}
}

View file

@ -1,7 +1,9 @@
package com.tangem.data.apptheme.di
import com.tangem.data.apptheme.MockAppThemeModeRepository
import com.tangem.data.apptheme.DefaultAppThemeModeRepository
import com.tangem.datasource.local.apptheme.AppThemeModeStore
import com.tangem.domain.apptheme.repository.AppThemeModeRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -14,7 +16,10 @@ internal object AppThemeModeDataModule {
@Provides
@Singleton
fun provideAppThemeModeRepository(): AppThemeModeRepository {
return MockAppThemeModeRepository()
fun provideAppThemeModeRepository(
appThemeModeStore: AppThemeModeStore,
dispatchers: CoroutineDispatcherProvider,
): AppThemeModeRepository {
return DefaultAppThemeModeRepository(appThemeModeStore, dispatchers)
}
}

1
data/balance-hiding/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,27 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.balancehiding"
}
dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.kotlin.coroutines)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
}

View file

@ -0,0 +1,36 @@
package com.tangem.data.balancehiding
import com.tangem.datasource.local.appcurrency.BalanceHidingSettingsStore
import com.tangem.domain.balancehiding.BalanceHidingSettings
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.withContext
internal class DefaultBalanceHidingRepository(
private val balanceHidingSettingsStore: BalanceHidingSettingsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : BalanceHidingRepository {
override fun getBalanceHidingSettingsFlow(): Flow<BalanceHidingSettings> {
return balanceHidingSettingsStore.get()
.onStart { emit(getBalanceHidingSettings()) }
.flowOn(dispatchers.io)
.distinctUntilChanged()
}
override suspend fun storeBalanceHidingSettings(balanceHidingSettings: BalanceHidingSettings) {
withContext(dispatchers.io) {
balanceHidingSettingsStore.store(balanceHidingSettings)
}
}
override suspend fun getBalanceHidingSettings(): BalanceHidingSettings {
return withContext(dispatchers.io) {
balanceHidingSettingsStore.getSyncOrDefault()
}
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.balancehiding
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import com.tangem.domain.balancehiding.DeviceFlipDetector
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
internal class DefaultDeviceFlipDetector(context: Context) : DeviceFlipDetector {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private var gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
override fun getDeviceFlipFlow(): Flow<Unit> = callbackFlow {
val listener = FlipListener { trySend(Unit) }
gravitySensor?.let {
sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_NORMAL)
}
awaitClose { sensorManager.unregisterListener(listener) }
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.data.balancehiding
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.os.SystemClock
internal class FlipListener(private val action: () -> Unit) : SensorEventListener {
private val zAxisThreshold = -6
private val throttleTimeMs = 3000
private var lastTriggerTime = 0L
private var isScreenDown = false
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
/* no-op */
}
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
val currentTime = SystemClock.elapsedRealtime()
val zAxisValue = it.values[2]
if (zAxisValue < zAxisThreshold && !isScreenDown) {
isScreenDown = true
lastTriggerTime = currentTime
// TODO add module logging
// Timber.tag("onSensorChanged").d("screen down")
} else if (zAxisValue >= zAxisThreshold) {
if (isScreenDown && currentTime - lastTriggerTime <= throttleTimeMs) {
// Timber.tag("onSensorChanged").d("screen up!")
lastTriggerTime = currentTime
action.invoke()
}
isScreenDown = false
}
}
}
}

View file

@ -0,0 +1,38 @@
package com.tangem.data.balancehiding.di
import android.content.Context
import com.tangem.data.balancehiding.DefaultBalanceHidingRepository
import com.tangem.data.balancehiding.DefaultDeviceFlipDetector
import com.tangem.datasource.local.appcurrency.BalanceHidingSettingsStore
import com.tangem.domain.balancehiding.DeviceFlipDetector
import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import dagger.hilt.android.qualifiers.ApplicationContext
@Module
@InstallIn(SingletonComponent::class)
internal object BalanceHidingModule {
@Provides
@Singleton
fun provideBalanceHidingRepository(
balanceHidingSettingsStore: BalanceHidingSettingsStore,
dispatchers: CoroutineDispatcherProvider,
): BalanceHidingRepository {
return DefaultBalanceHidingRepository(
balanceHidingSettingsStore = balanceHidingSettingsStore,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideFlipDetector(@ApplicationContext context: Context): DeviceFlipDetector {
return DefaultDeviceFlipDetector(context = context)
}
}

View file

@ -17,6 +17,7 @@ dependencies {
implementation(deps.tangem.card.android)
implementation(deps.tangem.card.core)
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.data.source.preferences)

View file

@ -1,16 +1,49 @@
package com.tangem.data.card
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.card.UsedCardInfo
import com.tangem.datasource.local.card.UsedCardsStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
internal class DefaultCardRepository(
private val preferencesDataSource: PreferencesDataSource,
private val usedCardsStore: UsedCardsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : CardRepository {
override suspend fun wasCardScanned(cardId: String): Boolean {
return withContext(dispatchers.io) { preferencesDataSource.usedCardsPrefStorage.wasScanned(cardId) }
override fun wasCardScanned(cardId: String): Flow<Boolean> {
return channelFlow {
launch(dispatchers.io) {
usedCardsStore.get()
.collect { savedCards ->
send(element = savedCards.any { it.cardId == cardId })
}
}
withContext(dispatchers.io) {
if (usedCardsStore.getSyncOrNull() == null) {
send(element = false)
}
}
}
}
override suspend fun setCardWasScanned(cardId: String) {
withContext(dispatchers.io) {
usedCardsStore.store(
item = usedCardsStore.getSyncOrNull()
?.updateCard(cardId)
?: listOf(UsedCardInfo(cardId = cardId, isScanned = true)),
)
}
}
private fun List<UsedCardInfo>.updateCard(cardId: String): List<UsedCardInfo> {
val card = find { it.cardId == cardId } ?: UsedCardInfo(cardId = cardId, isScanned = true)
return addOrReplace(item = card.copy(isScanned = true), predicate = { it.cardId == cardId })
}
}

View file

@ -4,6 +4,7 @@ import com.tangem.data.card.DefaultCardRepository
import com.tangem.data.card.DefaultCardSdkConfigRepository
import com.tangem.data.card.sdk.CardSdkProvider
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.card.UsedCardsStore
import com.tangem.domain.card.repository.CardRepository
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -32,9 +33,9 @@ internal object CardDataModule {
@Provides
@Singleton
fun provideCardRepository(
preferencesDataSource: PreferencesDataSource,
usedCardsStore: UsedCardsStore,
dispatchers: CoroutineDispatcherProvider,
): CardRepository {
return DefaultCardRepository(preferencesDataSource = preferencesDataSource, dispatchers = dispatchers)
return DefaultCardRepository(usedCardsStore = usedCardsStore, dispatchers = dispatchers)
}
}

View file

@ -5,11 +5,17 @@ plugins {
id("configuration")
}
android {
namespace = "com.tangem.data.common"
}
dependencies {
implementation(projects.core.datasource)
implementation(deps.kotlin.coroutines)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.arrow.core)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)

View file

@ -1 +0,0 @@
<manifest package="com.tangem.data.common" />

View file

@ -0,0 +1,47 @@
package com.tangem.data.common.api
import arrow.core.raise.Raise
import arrow.core.raise.recover
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import timber.log.Timber
/**
* A wrapper around the [Raise] interface specific for [ApiResponseError]. It provides utility functions to
* operate on [ApiResponse] instances.
*
* @property raise A [Raise] instance for raising [ApiResponseError].
*/
@JvmInline
value class ApiResponseRaise(
private val raise: Raise<ApiResponseError>,
) : Raise<ApiResponseError> by raise {
/**
* Binds the given [ApiResponse] to its underlying value or raises an error.
*
* @return The underlying data of the response if it's successful.
*/
fun <T : Any> ApiResponse<T>.bind(): T = when (this) {
is ApiResponse.Success -> data
is ApiResponse.Error -> raise.raise(cause)
}
}
/**
* Attempts to execute an API call safely, providing error handling.
*
* @param call The API call block to execute.
* @param onError A function to handle errors and return a fallback value of type [T].
*
* @return The result of the API call or the fallback value provided by [onError] if an error occurs.
*/
inline fun <T> safeApiCall(call: ApiResponseRaise.() -> T, onError: (ApiResponseError) -> T): T {
return recover(
block = { call(ApiResponseRaise(raise = this)) },
recover = {
Timber.w(it, "Unable to perform safe API call")
onError(it)
},
)
}

View file

@ -24,6 +24,15 @@ interface CacheRegistry {
*/
suspend fun invalidate(key: String)
/**
* Invalidates cache keys in registry.
*
* If the key doesn't exist, or it's already invalidated, this method doesn't have any effect.
*
* @param keys cache keys.
*/
suspend fun invalidate(keys: Collection<String>)
/**
* Invalidates all cache keys in the registry.
*

View file

@ -2,8 +2,11 @@ package com.tangem.data.common.cache
import com.tangem.datasource.local.cache.CacheKeysStore
import com.tangem.datasource.local.cache.model.CacheKey
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.joda.time.Duration
import org.joda.time.LocalDateTime
import timber.log.Timber
internal class DefaultCacheRegistry(
private val cacheKeysStore: CacheKeysStore,
@ -18,11 +21,18 @@ internal class DefaultCacheRegistry(
}
override suspend fun invalidate(key: String) {
cacheKeysStore.remove(key)
Timber.d("Invalidate the cache key: $key")
withContext(NonCancellable) { cacheKeysStore.remove(key) }
}
override suspend fun invalidate(keys: Collection<String>) {
Timber.d("Invalidate cache keys: $keys")
withContext(NonCancellable) { cacheKeysStore.remove(keys) }
}
override suspend fun invalidateAll() {
cacheKeysStore.clear()
Timber.d("Invalidate all cache keys")
withContext(NonCancellable) { cacheKeysStore.clear() }
}
override suspend fun invokeOnExpire(
@ -34,18 +44,23 @@ internal class DefaultCacheRegistry(
val isExpired = isExpired(key) || skipCache
if (!isExpired) return
cacheKeysStore.store(
key = CacheKey(
id = key,
updatedAt = LocalDateTime.now(),
expiresIn = expireIn,
),
)
try {
Timber.d("Invoke the action associated with the cache key: $key")
cacheKeysStore.store(
key = CacheKey(
id = key,
updatedAt = LocalDateTime.now(),
expiresIn = expireIn,
),
)
block()
} catch (e: Throwable) {
Timber.e(e, "The action related to the cache key has failed: $key")
invalidate(key)
throw e
}
}

View file

@ -14,13 +14,16 @@ dependencies {
/** DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.kotlin.coroutines)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.domain.settings)
implementation(projects.domain.balanceHiding.models)
implementation(projects.data.source.preferences)
}

View file

@ -0,0 +1,103 @@
package com.tangem.data.settings
import com.tangem.datasource.local.settings.AppLaunchCountStore
import com.tangem.datasource.local.settings.AppRatingShowingCountStore
import com.tangem.datasource.local.settings.FundsFoundDateInMillisStore
import com.tangem.datasource.local.settings.UserInteractingStatusStore
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.withContext
import java.util.Calendar
internal class DefaultAppRatingRepository(
private val fundsFoundDateInMillisStore: FundsFoundDateInMillisStore,
private val appLaunchCountStore: AppLaunchCountStore,
private val appRatingShowingCountStore: AppRatingShowingCountStore,
private val userInteractingStatusStore: UserInteractingStatusStore,
private val dispatchers: CoroutineDispatcherProvider,
) : AppRatingRepository {
override suspend fun initialize() {
fundsFoundDateInMillisStore.getSyncOrNull()
?: fundsFoundDateInMillisStore.store(item = FUNDS_FOUND_DATE_UNDEFINED)
appLaunchCountStore.getSyncOrNull()
?: appLaunchCountStore.store(item = DEFAULT_APP_LAUNCH_COUNT)
appRatingShowingCountStore.getSyncOrNull()
?: appRatingShowingCountStore.store(item = FIRST_SHOWING_COUNT)
userInteractingStatusStore.getSyncOrNull()
?: userInteractingStatusStore.store(item = false)
}
override suspend fun setWalletWithFundsFound() {
withContext(dispatchers.io) {
val foundDate = fundsFoundDateInMillisStore.getSyncOrNull()
if (foundDate != null && foundDate != FUNDS_FOUND_DATE_UNDEFINED) return@withContext
fundsFoundDateInMillisStore.store(item = Calendar.getInstance().timeInMillis)
val appLaunchCount = appLaunchCountStore.getSyncOrNull().let { count ->
if (count == null) {
appLaunchCountStore.store(item = DEFAULT_APP_LAUNCH_COUNT)
DEFAULT_APP_LAUNCH_COUNT
} else {
count
}
}
appRatingShowingCountStore.store(item = appLaunchCount + FIRST_SHOWING_COUNT)
userInteractingStatusStore.getSyncOrNull()
?: userInteractingStatusStore.store(item = false)
}
}
override fun isReadyToShow(): Flow<Boolean> {
// TODO: [REDACTED_JIRA]
return combine(
userInteractingStatusStore.get(),
appRatingShowingCountStore.get(),
appLaunchCountStore.get(),
fundsFoundDateInMillisStore.get(),
) { isInteracting, ratingShowingCount, appLaunchCount, fundsFoundDate ->
if (!isInteracting) {
val diff = Calendar.getInstance().timeInMillis - fundsFoundDate
val diffInDays = diff / DAY_IN_MILLIS
val isFundsFound = fundsFoundDate != FUNDS_FOUND_DATE_UNDEFINED
appLaunchCount >= ratingShowingCount && diffInDays >= FIRST_SHOWING_COUNT && isFundsFound
} else {
appLaunchCount >= ratingShowingCount
}
}
}
override suspend fun remindLater() {
appLaunchCountStore.getSyncOrNull()?.let {
updateNextShowing(at = it + DEFER_SHOWING_COUNT)
}
}
override suspend fun setNeverToShow() {
updateNextShowing(at = Int.MAX_VALUE)
}
private suspend fun updateNextShowing(at: Int) {
withContext(dispatchers.io) {
appRatingShowingCountStore.store(item = at)
userInteractingStatusStore.store(item = true)
}
}
private companion object {
const val FUNDS_FOUND_DATE_UNDEFINED = -1L
const val DEFAULT_APP_LAUNCH_COUNT = 1
const val FIRST_SHOWING_COUNT = 3
const val DEFER_SHOWING_COUNT = 20
const val DAY_IN_MILLIS = 1000 * 60 * 60 * 24
}
}

View file

@ -10,12 +10,6 @@ internal class DefaultSettingsRepository(
private val dispatchers: CoroutineDispatcherProvider,
) : SettingsRepository {
override suspend fun isUserAlreadyRateApp(): Boolean {
return withContext(dispatchers.io) {
preferencesDataSource.appRatingLaunchObserver.isReadyToShow()
}
}
override suspend fun shouldShowSaveUserWalletScreen(): Boolean {
return withContext(dispatchers.io) { preferencesDataSource.shouldShowSaveUserWalletScreen }
}

View file

@ -1,7 +1,13 @@
package com.tangem.data.settings.di
import com.tangem.data.settings.DefaultAppRatingRepository
import com.tangem.data.settings.DefaultSettingsRepository
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.settings.AppLaunchCountStore
import com.tangem.datasource.local.settings.AppRatingShowingCountStore
import com.tangem.datasource.local.settings.FundsFoundDateInMillisStore
import com.tangem.datasource.local.settings.UserInteractingStatusStore
import com.tangem.domain.settings.repositories.AppRatingRepository
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -20,6 +26,27 @@ internal object SettingsDataModule {
preferencesDataSource: PreferencesDataSource,
dispatchers: CoroutineDispatcherProvider,
): SettingsRepository {
return DefaultSettingsRepository(preferencesDataSource = preferencesDataSource, dispatchers = dispatchers)
return DefaultSettingsRepository(
preferencesDataSource = preferencesDataSource,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideAppRatingRepository(
fundsFoundDateInMillisStore: FundsFoundDateInMillisStore,
appLaunchCountStore: AppLaunchCountStore,
appRatingShowingCountStore: AppRatingShowingCountStore,
userInteractingStatusStore: UserInteractingStatusStore,
dispatchers: CoroutineDispatcherProvider,
): AppRatingRepository {
return DefaultAppRatingRepository(
fundsFoundDateInMillisStore = fundsFoundDateInMillisStore,
appLaunchCountStore = appLaunchCountStore,
appRatingShowingCountStore = appRatingShowingCountStore,
userInteractingStatusStore = userInteractingStatusStore,
dispatchers = dispatchers,
)
}
}

View file

@ -56,12 +56,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con
putBoolean(SAVE_WALLET_DIALOG_SHOWN_KEY, value)
}
var shouldSaveUserWallets: Boolean
get() = preferences.getBoolean(SAVE_USER_WALLETS_KEY, false)
set(value) = preferences.edit {
putBoolean(SAVE_USER_WALLETS_KEY, value)
}
var shouldSaveAccessCodes: Boolean
get() = preferences.getBoolean(SAVE_ACCESS_CODES_KEY, false)
set(value) = preferences.edit {
@ -102,7 +96,6 @@ class PreferencesDataSource @Inject internal constructor(applicationContext: Con
private const val ZENDESK_FIRST_LAUNCH_KEY = "chatFirstLaunchKey"
private const val SPRINKLR_FIRST_LAUNCH_KEY = "sprinklrFirstLaunch"
private const val SAVE_WALLET_DIALOG_SHOWN_KEY = "saveUserWalletShown"
private const val SAVE_USER_WALLETS_KEY = "saveUserWallets"
private const val SAVE_ACCESS_CODES_KEY = "saveAccessCodes"
private const val APPLICATION_STOPPED_KEY = "applicationStopped"
private const val OPEN_WELCOME_ON_RESUME_KEY = "openWelcomeOnResume"

View file

@ -17,6 +17,7 @@ dependencies {
implementation(projects.domain.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.txhistory.models)
implementation(projects.domain.wallets.models)
/** Project - Data */
@ -40,4 +41,5 @@ dependencies {
implementation(deps.moshi.kotlin)
implementation(deps.jodatime)
implementation(deps.timber)
implementation(deps.retrofit) // For HttpException
}

View file

@ -2,14 +2,18 @@ package com.tangem.data.tokens.di
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.repository.DefaultCurrenciesRepository
import com.tangem.data.tokens.repository.DefaultMarketCryptoCurrencyRepository
import com.tangem.data.tokens.repository.DefaultNetworksRepository
import com.tangem.data.tokens.repository.DefaultQuotesRepository
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appcurrency.SelectedAppCurrencyStore
import com.tangem.datasource.local.network.NetworksStatusesStore
import com.tangem.datasource.local.quote.QuotesStore
import com.tangem.datasource.local.token.UserMarketCoinsStore
import com.tangem.datasource.local.token.UserTokensStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -30,10 +34,18 @@ internal object TokensDataModule {
tangemTechApi: TangemTechApi,
userTokensStore: UserTokensStore,
userWalletsStore: UserWalletsStore,
userMarketCoinsStore: UserMarketCoinsStore,
cacheRegistry: CacheRegistry,
dispatchers: CoroutineDispatcherProvider,
): CurrenciesRepository {
return DefaultCurrenciesRepository(tangemTechApi, userTokensStore, userWalletsStore, cacheRegistry, dispatchers)
return DefaultCurrenciesRepository(
tangemTechApi = tangemTechApi,
userTokensStore = userTokensStore,
userWalletsStore = userWalletsStore,
userMarketCoinsStore = userMarketCoinsStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
)
}
@Provides
@ -46,17 +58,18 @@ internal object TokensDataModule {
dispatchers: CoroutineDispatcherProvider,
): QuotesRepository {
return DefaultQuotesRepository(
tangemTechApi,
quotesStore,
selectedAppCurrencyStore,
cacheRegistry,
dispatchers,
tangemTechApi = tangemTechApi,
quotesStore = quotesStore,
selectedAppCurrencyStore = selectedAppCurrencyStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideNetworksRepository(
networksStatusesStore: NetworksStatusesStore,
walletManagersFacade: WalletManagersFacade,
userWalletsStore: UserWalletsStore,
userTokensStore: UserTokensStore,
@ -64,11 +77,20 @@ internal object TokensDataModule {
dispatchers: CoroutineDispatcherProvider,
): NetworksRepository {
return DefaultNetworksRepository(
walletManagersFacade,
userWalletsStore,
userTokensStore,
cacheRegistry,
dispatchers,
networksStatusesStore = networksStatusesStore,
walletManagersFacade = walletManagersFacade,
userWalletsStore = userWalletsStore,
userTokensStore = userTokensStore,
cacheRegistry = cacheRegistry,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideDefaultMarketCoinsRepository(
userMarketCoinsStore: UserMarketCoinsStore,
): MarketCryptoCurrencyRepository {
return DefaultMarketCryptoCurrencyRepository(userMarketCoinsStore)
}
}

View file

@ -1,40 +1,47 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.utils.CardCurrenciesFactory
import com.tangem.data.tokens.utils.ResponseCurrenciesFactory
import com.tangem.data.tokens.utils.UserTokensResponseFactory
import com.tangem.data.tokens.utils.*
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserMarketCoinsStore
import com.tangem.datasource.local.token.UserTokensStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.common.util.hasDerivation
import com.tangem.domain.core.error.DataError
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@Suppress("LargeClass")
internal class DefaultCurrenciesRepository(
private val tangemTechApi: TangemTechApi,
private val userTokensStore: UserTokensStore,
private val userWalletsStore: UserWalletsStore,
private val userMarketCoinsStore: UserMarketCoinsStore,
private val cacheRegistry: CacheRegistry,
private val dispatchers: CoroutineDispatcherProvider,
) : CurrenciesRepository {
private val demoConfig = DemoConfig()
private val responseCurrenciesFactory = ResponseCurrenciesFactory(demoConfig)
private val cardCurrenciesFactory = CardCurrenciesFactory(demoConfig)
private val responseCurrenciesFactory = ResponseCryptoCurrenciesFactory()
private val cardCurrenciesFactory = CardCryptoCurrenciesFactory(demoConfig)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
override suspend fun saveTokens(
userWalletId: UserWalletId,
@ -53,6 +60,59 @@ internal class DefaultCurrenciesRepository(
storeAndPushTokens(userWalletId, response)
}
override suspend fun addCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
return withContext(dispatchers.io) {
val savedCurrencies = requireNotNull(
value = userTokensStore.getSyncOrNull(userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform add currencies action" },
)
val filteredCurrencies = currencies.toMutableList()
filteredCurrencies.filterNot { currency ->
val blockchain = getBlockchain(networkId = currency.network.id)
val networkId = blockchain.toNetworkId()
val contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress
savedCurrencies.tokens.firstOrNull { token ->
token.contractAddress == contractAddress &&
token.networkId == networkId &&
token.derivationPath == currency.network.derivationPath.value
} != null
}
val newCoins = createCoinsForNewTokens(
userWalletId = userWalletId,
newTokens = filteredCurrencies.filterIsInstance<CryptoCurrency.Token>(),
savedCurrencies = savedCurrencies.tokens,
)
val newCurrencies = newCoins + filteredCurrencies
storeAndPushTokens(
userWalletId = userWalletId,
response = savedCurrencies.copy(
tokens = savedCurrencies.tokens + newCurrencies.map(userTokensResponseFactory::createResponseToken),
),
)
}
}
private suspend fun createCoinsForNewTokens(
userWalletId: UserWalletId,
newTokens: List<CryptoCurrency.Token>,
savedCurrencies: List<UserTokensResponse.Token>,
): List<CryptoCurrency.Coin> {
return newTokens
.filterNot { savedCurrencies.hasCoinForToken(it) } // tokens without coins
.mapNotNull {
CryptoCurrencyFactory().createCoin(
blockchain = getBlockchain(networkId = it.network.id),
extraDerivationPath = it.network.derivationPath.value,
derivationStyleProvider = getUserWallet(userWalletId).scanResponse.derivationStyleProvider,
)
}
.distinct()
}
override suspend fun removeCurrency(userWalletId: UserWalletId, currency: CryptoCurrency) =
withContext(dispatchers.io) {
val savedCurrencies = requireNotNull(
@ -69,6 +129,23 @@ internal class DefaultCurrenciesRepository(
)
}
override suspend fun removeCurrencies(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
return withContext(dispatchers.io) {
val savedCurrencies = requireNotNull(
value = userTokensStore.getSyncOrNull(userWalletId),
lazyMessage = { "Saved tokens empty. Can not perform remove currencies action" },
)
val tokens = currencies.map(userTokensResponseFactory::createResponseToken)
storeAndPushTokens(
userWalletId = userWalletId,
response = savedCurrencies.copy(
tokens = savedCurrencies.tokens.filterNot(tokens::contains),
),
)
}
}
override suspend fun getSingleCurrencyWalletPrimaryCurrency(userWalletId: UserWalletId): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
@ -78,19 +155,44 @@ internal class DefaultCurrenciesRepository(
}
}
override suspend fun getSingleCurrencyWalletWithCardCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
cardCurrenciesFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse)
}
}
override suspend fun getSingleCurrencyWalletWithCardCurrency(
userWalletId: UserWalletId,
id: CryptoCurrency.ID,
): CryptoCurrency {
return withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = false)
val currency = cardCurrenciesFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse)
.find { it.id == id }
requireNotNull(currency) { "Unable to find currency with provided ID: $id" }
}
}
override fun getMultiCurrencyWalletCurrenciesUpdates(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
launch(dispatchers.io) {
getMultiCurrencyWalletCurrencies(userWallet).collect(::send)
getMultiCurrencyWalletCurrencies(userWallet)
.collectLatest(::send)
}
launch(dispatchers.io) {
withContext(dispatchers.io) {
fetchTokensIfCacheExpired(userWallet, refresh = false)
}
}
.cancellable()
}
override suspend fun getMultiCurrencyWalletCurrenciesSync(
@ -106,15 +208,13 @@ internal class DefaultCurrenciesRepository(
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
}
return responseCurrenciesFactory.createCurrencies(
response = storedTokens,
card = userWallet.scanResponse.card,
)
return responseCurrenciesFactory.createCurrencies(storedTokens, userWallet.scanResponse)
}
override suspend fun getMultiCurrencyWalletCurrency(
userWalletId: UserWalletId,
id: CryptoCurrency.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency = withContext(dispatchers.io) {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet, isMultiCurrencyWalletExpected = true)
@ -123,7 +223,34 @@ internal class DefaultCurrenciesRepository(
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
}
responseCurrenciesFactory.createCurrency(id, response, userWallet.scanResponse.card)
responseCurrenciesFactory.createCurrency(id, response, userWallet.scanResponse, derivationPath.value)
}
override suspend fun getNetworkCoin(
userWalletId: UserWalletId,
networkId: Network.ID,
derivationPath: Network.DerivationPath,
): CryptoCurrency.Coin {
val userWallet = getUserWallet(userWalletId)
ensureIsCorrectUserWallet(userWallet = userWallet, isMultiCurrencyWalletExpected = true)
fetchTokensIfCacheExpired(userWallet = userWallet, refresh = false)
val storedTokens = requireNotNull(userTokensStore.getSyncOrNull(userWallet.walletId)) {
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
}
val blockchain = Blockchain.fromId(networkId.value)
val blockchainNetworkId = blockchain.toNetworkId()
val coinId = blockchain.toCoinId()
val storedCoin = storedTokens.tokens
.find {
it.networkId == blockchainNetworkId && it.id == coinId && it.derivationPath == derivationPath.value
} ?: error("Coin in this network $networkId not found")
val coin = responseCurrenciesFactory.createCurrency(storedCoin, userWallet.scanResponse)
return coin as? CryptoCurrency.Coin ?: error("Unable to create currency")
}
override fun isTokensGrouped(userWalletId: UserWalletId): Flow<Boolean> {
@ -135,7 +262,7 @@ internal class DefaultCurrenciesRepository(
.map { it.group == UserTokensResponse.GroupType.NETWORK }
.collect(::send)
}
}
}.cancellable()
}
override fun isTokensSortedByBalance(userWalletId: UserWalletId): Flow<Boolean> {
@ -147,6 +274,24 @@ internal class DefaultCurrenciesRepository(
.map { it.sort == UserTokensResponse.SortType.BALANCE }
.collect(::send)
}
}.cancellable()
}
override fun getMissedAddressesCryptoCurrencies(userWalletId: UserWalletId): Flow<List<CryptoCurrency>> {
return channelFlow {
ensureIsCorrectUserWallet(userWalletId, isMultiCurrencyWalletExpected = true)
val userWallet = getUserWallet(userWalletId = userWalletId)
getMultiCurrencyWalletCurrencies(userWallet = userWallet)
.map {
it.filter { currency ->
val blockchain = Blockchain.fromId(id = currency.network.id.value)
val derivationPath = currency.network.derivationPath.value
derivationPath != null && !userWallet.scanResponse.hasDerivation(blockchain, derivationPath)
}
}
.collectLatest(::send)
}
}
@ -154,7 +299,7 @@ internal class DefaultCurrenciesRepository(
return userTokensStore.get(userWallet.walletId).map { storedTokens ->
responseCurrenciesFactory.createCurrencies(
response = storedTokens,
card = userWallet.scanResponse.card,
scanResponse = userWallet.scanResponse,
)
}
}
@ -168,40 +313,80 @@ internal class DefaultCurrenciesRepository(
}
private suspend fun fetchTokens(userWallet: UserWallet) {
try {
val response = tangemTechApi.getUserTokens(userWallet.walletId.stringValue)
val userWalletId = userWallet.walletId
userTokensStore.store(userWallet.walletId, response)
} catch (e: Throwable) {
handleFetchTokensErrorOrThrow(userWallet, e)
if (demoConfig.isDemoCardId(userWallet.cardId) && userTokensStore.getSyncOrNull(key = userWalletId) == null) {
userTokensStore.store(
key = userWalletId,
value = userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
),
)
return
}
val response = safeApiCall(
call = {
tangemTechApi.getUserTokens(userWalletId.stringValue).bind().let {
it.copy(tokens = it.tokens.distinct())
}
},
onError = { handleFetchTokensError(userWallet, it) },
)
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWallet.walletId, compatibleUserTokensResponse)
fetchExchangeableUserMarketCoinsByIds(userWalletId, compatibleUserTokensResponse)
}
private suspend fun storeAndPushTokens(userWalletId: UserWalletId, response: UserTokensResponse) {
userTokensStore.store(userWalletId, response)
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
val compatibleUserTokensResponse = userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(response)
userTokensStore.store(userWalletId, compatibleUserTokensResponse)
try {
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
} catch (e: Throwable) {
Timber.e("Unable to save user tokens for: ${userWalletId.stringValue}")
}
}
private suspend fun handleFetchTokensErrorOrThrow(userWallet: UserWallet, error: Throwable) {
val errorMessage = error.message ?: throw error
private suspend fun fetchExchangeableUserMarketCoinsByIds(
userWalletId: UserWalletId,
userTokens: UserTokensResponse,
) {
try {
val networkIds = userTokens.tokens
.distinctBy { it.networkId }
.joinToString(separator = ",") { it.networkId }
val response = tangemTechApi.getCoins(networkIds = networkIds, exchangeable = true)
if (NOT_FOUND_HTTP_CODE in errorMessage) {
val response = userTokensStore.getSyncOrNull(userWallet.walletId)
?: userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(
card = userWallet.scanResponse.card,
derivationStyleProvider = userWallet.scanResponse.derivationStyleProvider,
),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
tangemTechApi.saveUserTokens(userWallet.walletId.stringValue, response)
} else {
Timber.e(error, "Unable to fetch currencies for: ${userWallet.walletId}")
userMarketCoinsStore.store(userWalletId, response)
} catch (e: Throwable) {
Timber.e(e, "Unable to fetch user market coins for: ${userWalletId.stringValue}")
}
}
private suspend fun handleFetchTokensError(userWallet: UserWallet, e: ApiResponseError): UserTokensResponse {
val userWalletId = userWallet.walletId
val response = userTokensStore.getSyncOrNull(userWalletId)
?: userTokensResponseFactory.createUserTokensResponse(
currencies = cardCurrenciesFactory.createDefaultCoinsForMultiCurrencyCard(userWallet.scanResponse),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
if (e is ApiResponseError.HttpException && e.code == ApiResponseError.HttpException.Code.NOT_FOUND) {
Timber.w(e, "Requested currencies could not be found in the remote store for: $userWalletId")
tangemTechApi.saveUserTokens(userWalletId.stringValue, response)
} else {
cacheRegistry.invalidate(getTokensCacheKey(userWalletId))
}
return response
}
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find a user wallet with provided ID: $userWalletId"
@ -236,8 +421,4 @@ internal class DefaultCurrenciesRepository(
}
private fun getTokensCacheKey(userWalletId: UserWalletId): String = "tokens_cache_key_${userWalletId.stringValue}"
private companion object {
const val NOT_FOUND_HTTP_CODE = "404"
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.local.token.UserMarketCoinsStore
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.repository.MarketCryptoCurrencyRepository
import com.tangem.domain.wallets.models.UserWalletId
class DefaultMarketCryptoCurrencyRepository(
private val userMarketCoinsStore: UserMarketCoinsStore,
) : MarketCryptoCurrencyRepository {
override suspend fun isExchangeable(userWalletId: UserWalletId, cryptoCurrencyId: CryptoCurrency.ID): Boolean {
val blockchain = Blockchain.fromId(cryptoCurrencyId.rawNetworkId)
val apiNetworkId = blockchain.toNetworkId()
return userMarketCoinsStore.getSyncOrNull(userWalletId)?.coins
?.firstOrNull { it.id == cryptoCurrencyId.rawCurrencyId }
?.networks
?.firstOrNull { it.networkId == apiNetworkId }?.exchangeable ?: false
}
}

View file

@ -1,25 +1,31 @@
package com.tangem.data.tokens.repository
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.utils.CardCurrenciesFactory
import com.tangem.data.tokens.utils.NetworkConverter
import com.tangem.data.tokens.utils.CardCryptoCurrenciesFactory
import com.tangem.data.tokens.utils.NetworkStatusFactory
import com.tangem.data.tokens.utils.ResponseCurrenciesFactory
import com.tangem.data.tokens.utils.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.network.NetworksStatusesStore
import com.tangem.datasource.local.token.UserTokensStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.tokens.models.Network
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.addOrReplace
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber
internal class DefaultNetworksRepository(
private val networksStatusesStore: NetworksStatusesStore,
private val walletManagersFacade: WalletManagersFacade,
private val userWalletsStore: UserWalletsStore,
private val userTokensStore: UserTokensStore,
@ -28,103 +34,174 @@ internal class DefaultNetworksRepository(
) : NetworksRepository {
private val demoConfig by lazy { DemoConfig() }
private val networkConverter by lazy { NetworkConverter() }
private val cardCurrenciesFactory by lazy { CardCurrenciesFactory(demoConfig) }
private val responseCurrenciesFactory by lazy { ResponseCurrenciesFactory(demoConfig) }
private val cardCurrenciesFactory by lazy { CardCryptoCurrenciesFactory(demoConfig) }
private val responseCurrenciesFactory by lazy { ResponseCryptoCurrenciesFactory() }
private val networkStatusFactory by lazy { NetworkStatusFactory() }
private val networksStatuses: MutableStateFlow<List<NetworkStatus>> = MutableStateFlow(emptyList())
override fun getNetworks(networksIds: Set<Network.ID>): Set<Network> {
return networkConverter.convertSet(networksIds)
}
override fun getNetworkStatusesUpdates(
userWalletId: UserWalletId,
networks: Set<Network.ID>,
networks: Set<Network>,
): Flow<Set<NetworkStatus>> = channelFlow {
launch(dispatchers.io) {
networksStatuses.collect {
send(it.toSet())
}
networksStatusesStore.get(userWalletId)
.collectLatest(::send)
}
launch(dispatchers.io) {
fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh = false)
withContext(dispatchers.io) {
fetchNetworksStatusesIfCacheExpired(userWalletId, networks, false)
}
}
.cancellable()
override suspend fun fetchNetworkPendingTransactions(userWalletId: UserWalletId, networks: Set<Network>) {
withContext(dispatchers.io) {
fetchNetworksPendingTransactions(userWalletId, networks)
}
}
override suspend fun getNetworkStatusesSync(
userWalletId: UserWalletId,
networks: Set<Network.ID>,
networks: Set<Network>,
refresh: Boolean,
): Set<NetworkStatus> = withContext(dispatchers.io) {
fetchNetworksStatusesIfCacheExpired(userWalletId, networks, refresh)
networksStatuses.first().toSet()
networksStatusesStore.getSyncOrNull(userWalletId).orEmpty()
}
private suspend fun fetchNetworksStatusesIfCacheExpired(
userWalletId: UserWalletId,
networks: Set<Network.ID>,
networks: Set<Network>,
refresh: Boolean,
) {
cacheRegistry.invokeOnExpire(
key = getNetworksStatusesCacheKey(userWalletId),
skipCache = refresh,
block = { fetchNetworksStatuses(userWalletId, networks) },
)
}
private suspend fun fetchNetworksStatuses(userWalletId: UserWalletId, networks: Set<Network.ID>) {
coroutineScope {
networks
.map { networkId ->
.map { network ->
async {
fetchNetworkStatus(userWalletId, networkId)
fetchNetworkStatusIfCacheExpired(userWalletId, network, refresh)
}
}
.awaitAll()
}
}
private suspend fun fetchNetworkStatus(userWalletId: UserWalletId, networkId: Network.ID) {
val currencies = getCurrencies(userWalletId)
.asSequence()
.filter { it.network.id == networkId }
private suspend fun fetchNetworksPendingTransactions(userWalletId: UserWalletId, networks: Set<Network>) {
coroutineScope {
networks
.map { network ->
async {
fetchNetworkPendingTransactions(userWalletId, network)
}
}
.awaitAll()
}
}
private suspend fun fetchNetworkStatusIfCacheExpired(
userWalletId: UserWalletId,
network: Network,
refresh: Boolean,
) {
cacheRegistry.invokeOnExpire(
key = getNetworksStatusesCacheKey(userWalletId, network),
skipCache = refresh,
block = { fetchNetworkStatus(userWalletId, network) },
)
}
private suspend fun fetchNetworkStatus(userWalletId: UserWalletId, network: Network) {
val currencies = getCurrencies(userWalletId, network)
val result = walletManagersFacade.update(
userWalletId = userWalletId,
networkId = networkId,
network = network,
extraTokens = currencies.filterIsInstance<CryptoCurrency.Token>().toSet(),
)
withContext(NonCancellable) {
invalidateCacheKeyIfNeeded(userWalletId, network, result)
}
val networkStatus = networkStatusFactory.createNetworkStatus(
networkId = networkId,
network = network,
result = result,
currencies = currencies.toSet(),
)
networksStatuses.update { statuses ->
statuses.addOrReplace(networkStatus) { it.networkId == networkStatus.networkId }
}
networksStatusesStore.store(userWalletId, networkStatus)
}
private suspend fun getCurrencies(userWalletId: UserWalletId): List<CryptoCurrency> {
private suspend fun fetchNetworkPendingTransactions(userWalletId: UserWalletId, network: Network) {
val currencies = getCurrencies(userWalletId, network)
val result = walletManagersFacade.updatePendingTransactions(
userWalletId = userWalletId,
network = network,
)
withContext(NonCancellable) {
invalidateCacheKeyIfNeeded(userWalletId, network, result)
}
val networkStatus = networkStatusFactory.createNetworkStatus(
network = network,
result = result,
currencies = currencies.toSet(),
)
networksStatusesStore.store(userWalletId, networkStatus)
}
private suspend fun getCurrencies(userWalletId: UserWalletId, network: Network): Sequence<CryptoCurrency> {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find user wallet with provided ID: $userWalletId"
}
return if (userWallet.isMultiCurrency) {
val currencies = if (userWallet.isMultiCurrency) {
val response = requireNotNull(userTokensStore.getSyncOrNull(userWalletId)) {
"Unable to find tokens response for user wallet with provided ID: $userWalletId"
}
responseCurrenciesFactory.createCurrencies(response, userWallet.scanResponse.card)
responseCurrenciesFactory.createCurrencies(response, userWallet.scanResponse).asSequence()
} else {
val currency = cardCurrenciesFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet.scanResponse)
if (userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()) {
cardCurrenciesFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet.scanResponse)
.asSequence()
} else {
val currency = cardCurrenciesFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet.scanResponse)
listOf(currency)
sequenceOf(currency)
}
}
return currencies.filter { it.network == network }
}
private suspend fun invalidateCacheKeyIfNeeded(
userWalletId: UserWalletId,
network: Network,
result: UpdateWalletManagerResult,
) {
when (result) {
is UpdateWalletManagerResult.Verified,
is UpdateWalletManagerResult.NoAccount,
-> Unit
is UpdateWalletManagerResult.Unreachable,
is UpdateWalletManagerResult.MissedDerivation,
-> {
Timber.w(
"""
Invalidate network cache key
|- User wallet ID: $userWalletId
|- Network: ${network.id}
""".trimIndent(),
)
cacheRegistry.invalidate(getNetworksStatusesCacheKey(userWalletId, network))
}
}
}
private fun getNetworksStatusesCacheKey(userWalletId: UserWalletId): String = "network_status_$userWalletId"
private fun getNetworksStatusesCacheKey(userWalletId: UserWalletId, network: Network): String {
return "network_status_${userWalletId}_${network.id.value}_${network.derivationPath.value}"
}
}

View file

@ -1,18 +1,19 @@
package com.tangem.data.tokens.repository
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.tokens.utils.QuotesConverter
import com.tangem.data.tokens.utils.QuotesUnsupportedCurrenciesIdAdapter
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.appcurrency.SelectedAppCurrencyStore
import com.tangem.datasource.local.quote.QuotesStore
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.tokens.models.Quote
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Quote
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
internal class DefaultQuotesRepository(
private val tangemTechApi: TangemTechApi,
@ -23,23 +24,22 @@ internal class DefaultQuotesRepository(
) : QuotesRepository {
private val quotesConverter = QuotesConverter()
private val quotesUnsupportedCurrenciesAdapter = QuotesUnsupportedCurrenciesIdAdapter()
@Volatile
private var quotesFetchedForAppCurrency: String? = null
@OptIn(ExperimentalCoroutinesApi::class)
override fun getQuotesUpdates(currenciesIds: Set<CryptoCurrency.ID>): Flow<Set<Quote>> {
return channelFlow {
launch(dispatchers.io) {
quotesStore.get(currenciesIds)
.map(quotesConverter::convertSet)
.collect(::send)
}
return selectedAppCurrencyStore.get()
.distinctUntilChanged()
.flatMapLatest { appCurrency ->
fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = false)
launch(dispatchers.io) {
selectedAppCurrencyStore.get().collectLatest { appCurrency ->
fetchExpiredQuotes(currenciesIds, appCurrency.id, refresh = false)
}
quotesStore.get(currenciesIds).map(quotesConverter::convertSet)
}
}
.cancellable()
.flowOn(dispatchers.io)
}
override suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote> {
@ -73,16 +73,24 @@ internal class DefaultQuotesRepository(
}
private suspend fun fetchQuotes(rawCurrenciesIds: Set<String>, appCurrencyId: String) {
try {
val response = tangemTechApi.getQuotes(
currencyId = appCurrencyId,
coinIds = rawCurrenciesIds.joinToString(separator = ","),
)
val replacementIdsResult = quotesUnsupportedCurrenciesAdapter.replaceUnsupportedCurrencies(rawCurrenciesIds)
val response = safeApiCall(
call = {
val coinIds = replacementIdsResult.idsForRequest.joinToString(separator = ",")
tangemTechApi.getQuotes(appCurrencyId, coinIds).bind()
},
onError = {
cacheRegistry.invalidate(rawCurrenciesIds.map(::getQuoteCacheKey))
null
},
)
quotesStore.store(response)
} catch (e: Throwable) {
Timber.e(e, "Unable to fetch quotes for: $rawCurrenciesIds")
throw e
if (response != null) {
val updatedResponse = quotesUnsupportedCurrenciesAdapter.getResponseWithUnsupportedCurrencies(
response,
replacementIdsResult.idsFiltered,
)
quotesStore.store(updatedResponse)
}
}

View file

@ -0,0 +1,85 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrency
internal class CardCryptoCurrenciesFactory(private val demoConfig: DemoConfig) {
private val cryptoCurrencyFactory = CryptoCurrencyFactory()
fun createDefaultCoinsForMultiCurrencyCard(scanResponse: ScanResponse): List<CryptoCurrency.Coin> {
val cardDerivationStyleProvider = scanResponse.derivationStyleProvider
val card = scanResponse.card
var blockchains = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.demoBlockchains
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchains = blockchains.mapNotNull { it.getTestnetVersion() }
}
return blockchains.mapNotNull {
cryptoCurrencyFactory.createCoin(
blockchain = it,
extraDerivationPath = null,
derivationStyleProvider = cardDerivationStyleProvider,
)
}
}
fun createPrimaryCurrencyForSingleCurrencyCard(scanResponse: ScanResponse): CryptoCurrency {
val cardDerivationStyleProvider = scanResponse.derivationStyleProvider
val resolver = scanResponse.cardTypesResolver
val blockchain = resolver.getBlockchain()
val coin = cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = cardDerivationStyleProvider,
)
requireNotNull(coin) { "Coin for the single currency card cannot be null" }
val primaryToken = resolver.getPrimaryToken()?.let { token ->
cryptoCurrencyFactory.createToken(
sdkToken = token,
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = cardDerivationStyleProvider,
)
}
return primaryToken ?: coin
}
fun createCurrenciesForSingleCurrencyCardWithToken(scanResponse: ScanResponse): List<CryptoCurrency> {
val cardDerivationStyleProvider = scanResponse.derivationStyleProvider
val resolver = scanResponse.cardTypesResolver
val blockchain = resolver.getBlockchain()
val coin = cryptoCurrencyFactory.createCoin(
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = cardDerivationStyleProvider,
)
requireNotNull(coin) { "Coin for the single currency card cannot be null" }
val primaryToken = resolver.getPrimaryToken()?.let { token ->
cryptoCurrencyFactory.createToken(
sdkToken = token,
blockchain = blockchain,
extraDerivationPath = null,
derivationStyleProvider = cardDerivationStyleProvider,
)
}
return listOfNotNull(coin, primaryToken)
}
}

View file

@ -1,48 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.models.CryptoCurrency
internal class CardCurrenciesFactory(private val demoConfig: DemoConfig) {
private val cryptoCurrencyFactory by lazy { CryptoCurrencyFactory() }
fun createDefaultCoinsForMultiCurrencyCard(
card: CardDTO,
derivationStyleProvider: DerivationStyleProvider,
): List<CryptoCurrency.Coin> {
var blockchains = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.demoBlockchains
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchains = blockchains.mapNotNull { it.getTestnetVersion() }
}
return blockchains.mapNotNull { cryptoCurrencyFactory.createCoin(it, derivationStyleProvider) }
}
fun createPrimaryCurrencyForSingleCurrencyCard(scanResponse: ScanResponse): CryptoCurrency {
val derivationStyleProvider = scanResponse.derivationStyleProvider
val resolver = scanResponse.cardTypesResolver
val blockchain = resolver.getBlockchain()
val coin = requireNotNull(cryptoCurrencyFactory.createCoin(blockchain, derivationStyleProvider)) {
"Coin for the single currency card cannot be null"
}
val primaryToken = resolver.getPrimaryToken()?.let { token ->
cryptoCurrencyFactory.createToken(token, blockchain, derivationStyleProvider)
}
return primaryToken ?: coin
}
}

View file

@ -2,7 +2,8 @@ package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.tokens.model.CryptoCurrency
import timber.log.Timber
import com.tangem.blockchain.common.Token as SdkToken
@ -12,6 +13,7 @@ class CryptoCurrencyFactory {
fun createToken(
sdkToken: SdkToken,
blockchain: Blockchain,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Token? {
if (blockchain == Blockchain.Unknown) {
@ -19,35 +21,40 @@ class CryptoCurrencyFactory {
return null
}
val id = getTokenId(blockchain, sdkToken)
val network = getNetwork(blockchain, extraDerivationPath, derivationStyleProvider) ?: return null
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(
id = id,
network = getNetwork(blockchain) ?: return null,
network = network,
name = sdkToken.name,
symbol = sdkToken.symbol,
iconUrl = getTokenIconUrl(blockchain, sdkToken),
decimals = sdkToken.decimals,
isCustom = isCustomToken(id),
isCustom = isCustomToken(id, network),
contractAddress = sdkToken.contractAddress,
derivationPath = getDerivationPath(blockchain, derivationStyleProvider),
)
}
fun createCoin(blockchain: Blockchain, derivationStyleProvider: DerivationStyleProvider): CryptoCurrency.Coin? {
fun createCoin(
blockchain: Blockchain,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Coin? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
return null
}
val network = getNetwork(blockchain, extraDerivationPath, derivationStyleProvider) ?: return null
return CryptoCurrency.Coin(
id = getCoinId(blockchain),
network = getNetwork(blockchain) ?: return null,
id = getCoinId(network, blockchain.toCoinId()),
network = network,
name = blockchain.fullName,
symbol = blockchain.currency,
iconUrl = getCoinIconUrl(blockchain),
decimals = blockchain.decimals(),
derivationPath = getDerivationPath(blockchain, derivationStyleProvider),
isCustom = isCustomCoin(network),
)
}
}

View file

@ -1,22 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.models.Network
import com.tangem.utils.converter.Converter
internal class NetworkConverter : Converter<Network.ID, Network?> {
override fun convert(value: Network.ID): Network? {
val blockchain = Blockchain.fromId(value.value)
return getNetwork(blockchain)
}
override fun convertList(input: Collection<Network.ID>): List<Network> {
return input.mapNotNull(::convert)
}
override fun convertSet(input: Collection<Network.ID>): Set<Network> {
return input.mapNotNullTo(hashSetOf(), ::convert)
}
}

View file

@ -1,10 +1,19 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.tokens.models.Network
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.tokens.model.Network
import timber.log.Timber
internal fun getNetwork(blockchain: Blockchain): Network? {
internal fun getBlockchain(networkId: Network.ID): Blockchain {
return Blockchain.fromId(networkId.value)
}
internal fun getNetwork(
blockchain: Blockchain,
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): Network? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to convert Unknown blockchain to the domain network model")
return null
@ -14,10 +23,33 @@ internal fun getNetwork(blockchain: Blockchain): Network? {
id = Network.ID(blockchain.id),
name = blockchain.fullName,
isTestnet = blockchain.isTestnet(),
derivationPath = getNetworkDerivationPath(blockchain, extraDerivationPath, derivationStyleProvider),
standardType = getNetworkStandardType(blockchain),
)
}
private fun getNetworkDerivationPath(
blockchain: Blockchain,
extraDerivationPath: String?,
cardDerivationStyleProvider: DerivationStyleProvider,
): Network.DerivationPath {
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider)
return if (extraDerivationPath.isNullOrBlank()) {
if (defaultDerivationPath.isNullOrBlank()) {
Network.DerivationPath.None
} else {
Network.DerivationPath.Card(defaultDerivationPath)
}
} else {
if (extraDerivationPath == defaultDerivationPath) {
Network.DerivationPath.Card(defaultDerivationPath)
} else {
Network.DerivationPath.Custom(extraDerivationPath)
}
}
}
private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType {
return when (blockchain) {
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.StandardType.ERC20
@ -26,4 +58,11 @@ private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType
Blockchain.Tron, Blockchain.TronTestnet -> Network.StandardType.TRC20
else -> Network.StandardType.Unspecified(blockchain.name)
}
}
private fun getDefaultDerivationPath(
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
): String? {
return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
}

View file

@ -1,37 +1,36 @@
package com.tangem.data.tokens.utils
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.model.NetworkAddress
import com.tangem.domain.tokens.model.NetworkStatus
import com.tangem.domain.tokens.model.PendingTransaction
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.tokens.models.Network
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.walletmanager.model.CryptoCurrencyAmount
import com.tangem.domain.walletmanager.model.CryptoCurrencyTransaction
import com.tangem.domain.walletmanager.model.UpdateWalletManagerResult
import timber.log.Timber
import java.math.BigDecimal
internal class NetworkStatusFactory {
fun createNetworkStatus(
networkId: Network.ID,
network: Network,
result: UpdateWalletManagerResult,
currencies: Set<CryptoCurrency>,
): NetworkStatus {
return NetworkStatus(
networkId = networkId,
network = network,
value = when (result) {
is UpdateWalletManagerResult.MissedDerivation -> NetworkStatus.MissedDerivation
is UpdateWalletManagerResult.Unreachable -> NetworkStatus.Unreachable
is UpdateWalletManagerResult.NoAccount -> NetworkStatus.NoAccount(
address = getNetworkAddress(result.defaultAddress, result.addresses),
amountToCreateAccount = result.amountToCreateAccount,
errorMessage = result.errorMessage,
)
is UpdateWalletManagerResult.Verified -> NetworkStatus.Verified(
address = getNetworkAddress(result.defaultAddress, result.addresses),
amounts = formatAmounts(result.currenciesAmounts, currencies),
pendingTransactions = formatTransactions(
networksAddresses = result.addresses,
transactions = result.currentTransactions,
currencies = currencies,
),
@ -43,34 +42,33 @@ internal class NetworkStatusFactory {
private fun formatAmounts(
amounts: Set<CryptoCurrencyAmount>,
currencies: Set<CryptoCurrency>,
): Map<CryptoCurrency.ID, BigDecimal> {
return amounts
.asSequence()
.mapNotNull { amount ->
val currency = when (amount) {
is CryptoCurrencyAmount.Coin -> currencies.singleOrNull { it is CryptoCurrency.Coin }
is CryptoCurrencyAmount.Token -> currencies.firstOrNull {
it is CryptoCurrency.Token &&
it.id.rawCurrencyId == amount.tokenId &&
it.contractAddress == amount.tokenContractAddress
): Map<CryptoCurrency.ID, NetworkStatus.AmountStatus> {
return currencies.associate { currency ->
val amount = when (currency) {
is CryptoCurrency.Coin -> {
amounts.singleOrNull { it is CryptoCurrencyAmount.Coin }
}
is CryptoCurrency.Token -> {
amounts.firstOrNull { amount ->
amount is CryptoCurrencyAmount.Token &&
currency.id.rawCurrencyId == amount.tokenId &&
currency.contractAddress == amount.tokenContractAddress
}
}
if (currency == null) {
Timber.e("Unable to find cryptocurrency for amount: $amount")
null
} else {
currency.id to amount.value
}
}
.toMap()
if (amount == null) {
Timber.e("Unable to find amount for cryptoCurrency: $currency")
currency.id to NetworkStatus.UnreachableAmount
} else {
currency.id to NetworkStatus.LoadedAmount(amount.value)
}
}
}
private fun formatTransactions(
networksAddresses: Set<String>,
transactions: Set<CryptoCurrencyTransaction>,
currencies: Set<CryptoCurrency>,
): Map<CryptoCurrency.ID, Set<PendingTransaction>> {
): Map<CryptoCurrency.ID, Set<TxHistoryItem>> {
if (transactions.isEmpty()) return emptyMap()
return currencies
@ -87,48 +85,13 @@ internal class NetworkStatusFactory {
}
}
currency.id to createCurrentTransactions(networksAddresses, currencyTransactions)
currency.id to createCurrentTransactions(currencyTransactions)
}
.toMap()
}
private fun createCurrentTransactions(
networksAddresses: Set<String>,
transactions: Set<CryptoCurrencyTransaction>,
): Set<PendingTransaction> {
return transactions.mapNotNullTo(hashSetOf()) { createCurrentTransaction(networksAddresses, it) }
}
private fun createCurrentTransaction(
networksAddresses: Set<String>,
transaction: CryptoCurrencyTransaction,
): PendingTransaction? {
val direction = when {
transaction.toAddress in networksAddresses -> PendingTransaction.Direction.Incoming(
fromAddress = transaction.fromAddress,
)
transaction.fromAddress in networksAddresses -> PendingTransaction.Direction.Outgoing(
toAddress = transaction.toAddress,
)
else -> {
Timber.e(
"""
Unable to find transaction direction
|- To address: ${transaction.toAddress}
|- From address: ${transaction.fromAddress}
|- Network addresses: $networksAddresses
""".trimIndent(),
)
return null
}
}
return PendingTransaction(
amount = transaction.amount,
direction = direction,
sentAt = transaction.sentAt,
)
private fun createCurrentTransactions(transactions: Set<CryptoCurrencyTransaction>): Set<TxHistoryItem> {
return transactions.mapTo(hashSetOf()) { it.txHistoryItem }
}
private fun getNetworkAddress(defaultAddress: String, availableAddresses: Set<String>): NetworkAddress {

View file

@ -1,8 +1,9 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.local.quote.model.StoredQuote
import com.tangem.domain.tokens.models.Quote
import com.tangem.domain.tokens.model.Quote
import com.tangem.utils.converter.Converter
import java.math.BigDecimal
internal class QuotesConverter : Converter<StoredQuote, Quote> {
@ -11,8 +12,8 @@ internal class QuotesConverter : Converter<StoredQuote, Quote> {
return Quote(
rawCurrencyId = rawCurrencyId,
fiatRate = responseQuote.price,
priceChange = responseQuote.priceChange.movePointLeft(2),
fiatRate = responseQuote.price ?: BigDecimal.ZERO,
priceChange = (responseQuote.priceChange ?: BigDecimal.ZERO).movePointLeft(2),
)
}
}

View file

@ -0,0 +1,56 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.QuotesResponse
/**
* Adapter to replace unsupported currencies for quotes request if it necessary
*/
internal class QuotesUnsupportedCurrenciesIdAdapter {
/**
* Replaces unsupported currencies id to it replacements for request
*/
fun replaceUnsupportedCurrencies(currenciesIds: Set<String>): ReplacementResult {
val idsForRequest = mutableSetOf<String>()
val idsFiltered = mutableSetOf<String>()
currenciesIds.forEach { currencyId ->
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[currencyId]
if (replacementId != null) {
idsFiltered.add(currencyId)
idsForRequest.add(replacementId)
} else {
idsForRequest.add(currencyId)
}
}
return ReplacementResult(idsForRequest = idsForRequest, idsFiltered = idsFiltered)
}
/**
* Recover previously replaced currencies to response that uses in application
*/
fun getResponseWithUnsupportedCurrencies(response: QuotesResponse, filteredIds: Set<String>): QuotesResponse {
val updatedQuotes = mutableMapOf<String, QuotesResponse.Quote>()
filteredIds.forEach { filteredId ->
val replacementId = UNSUPPORTED_IDS_WITH_REPLACEMENTS[filteredId]
if (replacementId != null) {
val quoteReplacement = response.quotes[replacementId]
if (quoteReplacement != null) {
updatedQuotes[filteredId] = quoteReplacement
}
}
}
return response.copy(quotes = updatedQuotes + response.quotes)
}
data class ReplacementResult(val idsForRequest: Set<String>, val idsFiltered: Set<String>)
companion object {
/**
* Map that contains unsupported currencies and their replacement for request
*/
private val UNSUPPORTED_IDS_WITH_REPLACEMENTS = mapOf(
"optimistic-ethereum" to "ethereum",
"arbitrum-one" to "ethereum",
)
}
}

View file

@ -0,0 +1,120 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.common.TapWorkarounds.isTestCard
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import timber.log.Timber
import com.tangem.blockchain.common.Token as SdkToken
internal class ResponseCryptoCurrenciesFactory {
fun createCurrency(
currencyId: CryptoCurrency.ID,
response: UserTokensResponse,
scanResponse: ScanResponse,
derivationPath: String?,
): CryptoCurrency {
val responseTokenId = currencyId.rawCurrencyId
val networkId = Blockchain.fromId(currencyId.rawNetworkId).toNetworkId()
val token = requireNotNull(
value = response.tokens
.find { it.id == responseTokenId && it.networkId == networkId && it.derivationPath == derivationPath },
lazyMessage = { "Unable find a token with provided TokenID($responseTokenId) and NetworkID($networkId)" },
)
return requireNotNull(createCurrency(token, scanResponse)) {
"Unable to create a currency with provided ID: $currencyId"
}
}
fun createCurrencies(response: UserTokensResponse, scanResponse: ScanResponse): List<CryptoCurrency> {
return response.tokens
.asSequence()
.mapNotNull { createCurrency(it, scanResponse) }
.distinctBy { it.id }
.toList()
}
fun createCurrency(responseToken: UserTokensResponse.Token, scanResponse: ScanResponse): CryptoCurrency? {
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
Timber.e("Unable to find a blockchain with the network ID: ${responseToken.networkId}")
return null
}
val cardDerivationStyleProvider = scanResponse.derivationStyleProvider
val card = scanResponse.card
if (card.isTestCard) {
blockchain = blockchain.getTestnetVersion() ?: blockchain
}
val sdkToken = createSdkToken(responseToken)
return if (sdkToken == null) {
createCoin(blockchain, responseToken, cardDerivationStyleProvider)
} else {
createToken(blockchain, sdkToken, responseToken.derivationPath, cardDerivationStyleProvider)
}
}
private fun createSdkToken(token: UserTokensResponse.Token): SdkToken? {
return token.contractAddress?.let { contractAddress ->
SdkToken(
name = token.name,
symbol = token.symbol,
contractAddress = contractAddress,
decimals = token.decimals,
id = token.id,
)
}
}
private fun createCoin(
blockchain: Blockchain,
responseToken: UserTokensResponse.Token,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Coin? {
val network = getNetwork(blockchain, responseToken.derivationPath, derivationStyleProvider) ?: return null
return CryptoCurrency.Coin(
id = getCoinId(network, blockchain.toCoinId()),
network = network,
name = responseToken.name,
symbol = responseToken.symbol,
decimals = responseToken.decimals,
iconUrl = getCoinIconUrl(blockchain),
isCustom = isCustomCoin(network),
)
}
private fun createToken(
blockchain: Blockchain,
sdkToken: Token,
responseDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider,
): CryptoCurrency.Token? {
val network = getNetwork(blockchain, responseDerivationPath, derivationStyleProvider)
?: return null
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(
id = id,
network = network,
name = sdkToken.name,
symbol = sdkToken.symbol,
decimals = sdkToken.decimals,
iconUrl = getTokenIconUrl(blockchain, sdkToken),
contractAddress = sdkToken.contractAddress,
isCustom = isCustomToken(id, network),
)
}
}

View file

@ -1,89 +0,0 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.Token
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.models.scan.CardDTO
import com.tangem.domain.tokens.models.CryptoCurrency
import timber.log.Timber
import com.tangem.blockchain.common.Token as SdkToken
internal class ResponseCurrenciesFactory(private val demoConfig: DemoConfig) {
fun createCurrency(currencyId: CryptoCurrency.ID, response: UserTokensResponse, card: CardDTO): CryptoCurrency {
val responseTokenId = currencyId.rawCurrencyId
val token = requireNotNull(response.tokens.firstOrNull { it.id == responseTokenId }) {
"Unable find a token with provided ID: $responseTokenId"
}
return requireNotNull(createCurrency(token, card)) {
"Unable to create a currency with provided ID: $currencyId"
}
}
fun createCurrencies(response: UserTokensResponse, card: CardDTO): List<CryptoCurrency> {
return response.tokens.mapNotNull { createCurrency(it, card) }
}
private fun createCurrency(responseToken: UserTokensResponse.Token, card: CardDTO): CryptoCurrency? {
var blockchain = Blockchain.fromNetworkId(responseToken.networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
Timber.e("Unable to find a blockchain with the network ID: ${responseToken.networkId}")
return null
}
if (demoConfig.isDemoCardId(card.cardId)) {
blockchain = blockchain.getTestnetVersion() ?: blockchain
}
val sdkToken = createSdkToken(responseToken)
return if (sdkToken == null) {
createCoin(blockchain, responseToken)
} else {
createToken(blockchain, sdkToken, responseToken.derivationPath)
}
}
private fun createSdkToken(token: UserTokensResponse.Token): SdkToken? {
return token.contractAddress?.let { contractAddress ->
SdkToken(
name = token.name,
symbol = token.symbol,
contractAddress = contractAddress,
decimals = token.decimals,
id = token.id,
)
}
}
private fun createCoin(blockchain: Blockchain, responseToken: UserTokensResponse.Token): CryptoCurrency.Coin? {
return CryptoCurrency.Coin(
id = getCoinId(blockchain),
network = getNetwork(blockchain) ?: return null,
name = responseToken.name,
symbol = responseToken.symbol,
decimals = responseToken.decimals,
derivationPath = responseToken.derivationPath,
iconUrl = getCoinIconUrl(blockchain),
)
}
private fun createToken(blockchain: Blockchain, sdkToken: Token, derivationPath: String?): CryptoCurrency.Token? {
val id = getTokenId(blockchain, sdkToken)
return CryptoCurrency.Token(
id = id,
network = getNetwork(blockchain) ?: return null,
name = sdkToken.name,
symbol = sdkToken.symbol,
decimals = sdkToken.decimals,
derivationPath = derivationPath,
iconUrl = getTokenIconUrl(blockchain, sdkToken),
contractAddress = sdkToken.contractAddress,
isCustom = isCustomToken(id),
)
}
}

View file

@ -2,40 +2,44 @@ package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.IconsUtil
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.extensions.toCoinId
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.tokens.models.CryptoCurrency.ID
import com.tangem.domain.tokens.models.Network
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrency.ID
import com.tangem.domain.tokens.model.Network
import com.tangem.blockchain.common.Token as SdkToken
import com.tangem.domain.tokens.models.CryptoCurrency.ID.Prefix.COIN_PREFIX as COIN_ID_PREFIX
import com.tangem.domain.tokens.models.CryptoCurrency.ID.Prefix.CUSTOM_TOKEN_PREFIX as CUSTOM_TOKEN_ID_PREFIX
import com.tangem.domain.tokens.models.CryptoCurrency.ID.Prefix.TOKEN_PREFIX as TOKEN_ID_PREFIX
import com.tangem.domain.tokens.models.CryptoCurrency.ID.Suffix.ContractAddress as CustomCurrencyIdSuffix
import com.tangem.domain.tokens.models.CryptoCurrency.ID.Suffix.RawID as CurrencyIdSuffix
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Body as CurrencyIdBody
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Prefix.COIN_PREFIX as COIN_ID_PREFIX
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Prefix.TOKEN_PREFIX as TOKEN_ID_PREFIX
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Suffix.ContractAddress as CustomCurrencyIdSuffix
import com.tangem.domain.tokens.model.CryptoCurrency.ID.Suffix.RawID as CurrencyIdSuffix
private const val DEFAULT_TOKENS_ICONS_HOST = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins"
private const val TOKEN_ICON_SIZE = "large"
private const val TOKEN_ICON_EXT = "png"
internal fun isCustomToken(tokenId: ID): Boolean {
return tokenId.rawCurrencyId == null
internal fun isCustomToken(tokenId: ID, network: Network): Boolean {
return network.derivationPath is Network.DerivationPath.Custom || tokenId.rawCurrencyId == null
}
internal fun getDerivationPath(blockchain: Blockchain, derivationStyleProvider: DerivationStyleProvider): String? {
return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
internal fun isCustomCoin(network: Network): Boolean {
return network.derivationPath is Network.DerivationPath.Custom
}
internal fun getBlockchain(networkId: Network.ID): Blockchain {
return Blockchain.fromId(networkId.value)
internal fun getCoinId(network: Network, coinId: String): ID {
return ID(COIN_ID_PREFIX, getCurrencyIdBody(network), CurrencyIdSuffix(rawId = coinId))
}
internal fun getCoinId(blockchain: Blockchain): ID {
return getTokenOrCoinId(blockchain, token = null)
}
internal fun getTokenId(network: Network, sdkToken: SdkToken): ID {
val sdkTokenId = sdkToken.id
val suffix = if (sdkTokenId == null) {
CustomCurrencyIdSuffix(contractAddress = sdkToken.contractAddress)
} else {
CurrencyIdSuffix(rawId = sdkTokenId, contractAddress = sdkToken.contractAddress)
}
internal fun getTokenId(blockchain: Blockchain, token: SdkToken): ID {
return getTokenOrCoinId(blockchain, token)
return ID(TOKEN_ID_PREFIX, getCurrencyIdBody(network), suffix)
}
internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? {
@ -51,22 +55,31 @@ internal fun getTokenIconUrl(blockchain: Blockchain, token: SdkToken): String? {
internal fun getCoinIconUrl(blockchain: Blockchain): String? {
val coinId = when (blockchain) {
Blockchain.Unknown -> null
Blockchain.TerraV1, Blockchain.TerraV2 -> blockchain.toCoinId()
Blockchain.TerraV1, Blockchain.TerraV2, Blockchain.Near -> blockchain.toCoinId()
else -> blockchain.toNetworkId()
}
return coinId?.let(::getTokenIconUrlFromDefaultHost)
}
private fun getTokenOrCoinId(blockchain: Blockchain, token: SdkToken?): ID {
val sdkTokenId = token?.id
val (prefix, suffix) = when {
token == null -> COIN_ID_PREFIX to CurrencyIdSuffix(rawId = blockchain.toCoinId())
sdkTokenId == null -> CUSTOM_TOKEN_ID_PREFIX to CustomCurrencyIdSuffix(contractAddress = token.contractAddress)
else -> TOKEN_ID_PREFIX to CurrencyIdSuffix(rawId = sdkTokenId)
internal fun List<UserTokensResponse.Token>.hasCoinForToken(token: CryptoCurrency.Token): Boolean {
return any {
val blockchain = getBlockchain(networkId = token.network.id)
val tokenDerivation = token.network.derivationPath.value
it.id == blockchain.toCoinId() && it.derivationPath == tokenDerivation
}
}
return ID(prefix, Network.ID(blockchain.id), suffix)
private fun getCurrencyIdBody(network: Network): CurrencyIdBody {
return when (val path = network.derivationPath) {
is Network.DerivationPath.Custom -> CurrencyIdBody.NetworkIdWithDerivationPath(
rawId = network.id.value,
derivationPath = path.value,
)
is Network.DerivationPath.Card,
is Network.DerivationPath.None,
-> CurrencyIdBody.NetworkId(network.id.value)
}
}
private fun getTokenIconUrlFromDefaultHost(tokenId: String): String {

View file

@ -0,0 +1,37 @@
package com.tangem.data.tokens.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.toCoinId
/**
* Helper to apply compatibility changes for [UserTokensResponse] to support old saved tokens
* in new application with new IDs
*/
internal class UserTokensBackwardCompatibility {
fun applyCompatibilityAndGetUpdated(userTokensResponse: UserTokensResponse): UserTokensResponse {
return userTokensResponse.copy(
tokens = userTokensResponse.tokens.map { token ->
val oldSavedId = NETWORKS_TO_OLD_SAVED_IDS[token.networkId]
if (oldSavedId != null && token.id == oldSavedId) {
Blockchain.fromNetworkId(token.networkId)?.let { blockchain ->
token.copy(
id = blockchain.toCoinId(),
)
} ?: token
} else {
token
}
},
)
}
companion object {
private val NETWORKS_TO_OLD_SAVED_IDS = mapOf(
"optimistic-ethereum" to "ethereum",
"arbitrum-one" to "ethereum",
)
}
}

View file

@ -2,7 +2,7 @@ package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.common.extensions.toNetworkId
import com.tangem.domain.tokens.models.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
internal class UserTokensResponseFactory {
@ -32,7 +32,7 @@ internal class UserTokensResponseFactory {
return UserTokensResponse.Token(
id = currency.id.rawCurrencyId,
networkId = blockchain.toNetworkId(),
derivationPath = currency.derivationPath,
derivationPath = currency.network.derivationPath.value,
name = currency.name,
symbol = currency.symbol,
decimals = currency.decimals,

View file

@ -10,6 +10,8 @@ android {
}
dependencies {
implementation(projects.data.common)
implementation(projects.core.utils)
implementation(projects.core.datasource)
implementation(projects.domain.legacy)
@ -20,7 +22,9 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.androidx.paging.runtime)
implementation(deps.arrow.core)
implementation(deps.timber)
implementation(deps.jodatime)
implementation(deps.tangem.blockchain)
implementation(deps.hilt.core)
kapt(deps.hilt.kapt)

View file

@ -1,6 +1,8 @@
package com.tangem.data.txhistory.di
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.txhistory.repository.DefaultTxHistoryRepository
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.txhistory.repository.TxHistoryRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -17,10 +19,14 @@ internal object TxHistoryDataModule {
@Provides
@Singleton
fun provideTxHistoryRepository(
cacheRegistry: CacheRegistry,
walletManagersFacade: WalletManagersFacade,
userWalletsStore: UserWalletsStore,
txHistoryItemsStore: TxHistoryItemsStore,
): TxHistoryRepository = DefaultTxHistoryRepository(
walletManagersFacade = walletManagersFacade,
userWalletsStore = userWalletsStore,
cacheRegistry,
walletManagersFacade,
userWalletsStore,
txHistoryItemsStore,
)
}

View file

@ -3,64 +3,75 @@ package com.tangem.data.txhistory.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.data.txhistory.repository.paging.TxHistoryPagingSource
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.tokens.models.Network
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.domain.txhistory.models.TxHistoryState
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.domain.txhistory.repository.TxHistoryRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.flow.Flow
class DefaultTxHistoryRepository(
private val cacheRegistry: CacheRegistry,
private val walletManagersFacade: WalletManagersFacade,
private val userWalletsStore: UserWalletsStore,
private val txHistoryItemsStore: TxHistoryItemsStore,
) : TxHistoryRepository {
override suspend fun getTxHistoryItemsCount(networkId: Network.ID, derivationPath: String?): Int {
val userWallet = getUserWallet()
override suspend fun getTxHistoryItemsCount(userWalletId: UserWalletId, network: Network): Int {
val userWallet = getUserWallet(userWalletId)
val state = walletManagersFacade.getTxHistoryState(
userWalletId = userWallet.walletId,
networkId = networkId,
rawDerivationPath = derivationPath,
network = network,
)
return when (state) {
is TxHistoryState.Failed.FetchError -> throw TxHistoryStateError.DataError(state.exception)
TxHistoryState.NotImplemented -> throw TxHistoryStateError.TxHistoryNotImplemented
TxHistoryState.Success.Empty -> throw TxHistoryStateError.EmptyTxHistories
is TxHistoryState.NotImplemented -> throw TxHistoryStateError.TxHistoryNotImplemented
is TxHistoryState.Success.Empty -> throw TxHistoryStateError.EmptyTxHistories
is TxHistoryState.Success.HasTransactions -> state.txCount
}
}
override fun getTxHistoryItems(
networkId: Network.ID,
derivationPath: String?,
userWalletId: UserWalletId,
currency: CryptoCurrency,
pageSize: Int,
refresh: Boolean,
): Flow<PagingData<TxHistoryItem>> {
val userWallet = getUserWallet()
return Pager(
val pager = Pager(
config = PagingConfig(
pageSize = pageSize,
initialLoadSize = pageSize,
),
pagingSourceFactory = {
TxHistoryPagingSource(
loadPage = { page: Int, pageSize: Int ->
walletManagersFacade.getTxHistoryItems(
userWalletId = userWallet.walletId,
networkId = networkId,
rawDerivationPath = derivationPath,
page = page,
pageSize = pageSize,
)
},
sourceParams = TxHistoryPagingSource.Params(userWalletId, currency, pageSize, refresh),
txHistoryItemsStore = txHistoryItemsStore,
walletManagersFacade = walletManagersFacade,
cacheRegistry = cacheRegistry,
)
},
).flow
)
return pager.flow
}
private fun getUserWallet(): UserWallet = requireNotNull(userWalletsStore.selectedUserWalletOrNull) {
"Selected wallet must not be null"
override fun getTxExploreUrl(txHash: String, networkId: Network.ID): String {
val blockchain = Blockchain.fromId(networkId.value)
return blockchain.getExploreTxUrl(txHash)
}
private suspend fun getUserWallet(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Unable to find user wallet with provided ID: $userWalletId"
}
}
}

View file

@ -2,34 +2,96 @@ package com.tangem.data.txhistory.repository.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.tangem.data.common.cache.CacheRegistry
import com.tangem.datasource.local.txhistory.TxHistoryItemsStore
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.txhistory.models.PaginationWrapper
import com.tangem.domain.txhistory.models.TxHistoryItem
private const val INITIAL_PAGE = 1
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import timber.log.Timber
internal class TxHistoryPagingSource(
private val loadPage: suspend (page: Int, pageSize: Int) -> PaginationWrapper<TxHistoryItem>,
private val sourceParams: Params,
private val txHistoryItemsStore: TxHistoryItemsStore,
private val walletManagersFacade: WalletManagersFacade,
private val cacheRegistry: CacheRegistry,
) : PagingSource<Int, TxHistoryItem>() {
private val storeKey = TxHistoryItemsStore.Key(sourceParams.userWalletId, sourceParams.currency)
override fun getRefreshKey(state: PagingState<Int, TxHistoryItem>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(other = 1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(other = 1)
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.inc() ?: anchorPage?.nextKey?.dec()
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TxHistoryItem> {
val currentPage = params.key ?: INITIAL_PAGE
return try {
val result = loadPage(currentPage, params.loadSize)
val pageToLoad = params.key ?: INITIAL_PAGE
LoadResult.Page(
data = result.items,
prevKey = if (currentPage > INITIAL_PAGE) currentPage.minus(1) else null,
nextKey = if (result.page < result.totalPages) currentPage.plus(1) else null,
return try {
val wrappedItems = loadItems(
pageToLoad = pageToLoad,
pageSize = sourceParams.pageSize,
refresh = sourceParams.refresh && params is LoadParams.Refresh,
)
} catch (e: Exception) {
val items = wrappedItems.items
val prevPage = when {
items.isEmpty() -> null
pageToLoad > INITIAL_PAGE -> pageToLoad.dec()
else -> null
}
val nextPage = when {
items.isEmpty() -> INITIAL_PAGE
pageToLoad < wrappedItems.totalPages -> pageToLoad.inc()
else -> null
}
LoadResult.Page(items, prevKey = prevPage, nextKey = nextPage)
} catch (e: Throwable) {
Timber.e(e, "Unable to load the transaction history for the requested page: $pageToLoad")
LoadResult.Error(e)
}
}
private suspend fun loadItems(pageToLoad: Int, pageSize: Int, refresh: Boolean): PaginationWrapper<TxHistoryItem> {
cacheRegistry.invokeOnExpire(
key = getTxHistoryPageKey(pageToLoad),
skipCache = refresh,
block = { fetch(pageToLoad, pageSize) },
)
return requireNotNull(txHistoryItemsStore.getSyncOrNull(storeKey, pageToLoad)) {
"The transaction history page #$pageToLoad could not be retrieved"
}
}
private suspend fun fetch(pageToLoad: Int, pageSize: Int) {
val wrappedItems = walletManagersFacade.getTxHistoryItems(
userWalletId = sourceParams.userWalletId,
currency = sourceParams.currency,
page = pageToLoad,
pageSize = pageSize,
)
txHistoryItemsStore.store(storeKey, wrappedItems)
}
private fun getTxHistoryPageKey(page: Int): String {
return "tx_history_page_${sourceParams.currency}_${sourceParams.userWalletId}_$page"
}
data class Params(
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val pageSize: Int,
val refresh: Boolean,
)
private companion object {
private const val INITIAL_PAGE = 1
}
}

View file

@ -11,6 +11,7 @@ android {
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.data.source.preferences)
implementation(projects.domain.wallets)

View file

@ -1,16 +1,29 @@
package com.tangem.data.wallets
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.datasource.local.userwallet.ShouldSaveUserWalletStore
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
internal class DefaultWalletsRepository(
private val preferencesDataSource: PreferencesDataSource,
private val shouldSaveUserWalletStore: ShouldSaveUserWalletStore,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletsRepository {
override suspend fun shouldSaveUserWallets(): Boolean {
return withContext(dispatchers.io) { preferencesDataSource.shouldSaveUserWallets }
override suspend fun initialize() {
withContext(dispatchers.io) {
shouldSaveUserWalletStore.getSyncOrNull() ?: shouldSaveUserWalletStore.store(item = false)
}
}
override suspend fun shouldSaveUserWalletsSync(): Boolean {
return withContext(dispatchers.io) { shouldSaveUserWalletStore.getSyncOrNull() ?: false }
}
override fun shouldSaveUserWallets(): Flow<Boolean> = shouldSaveUserWalletStore.get()
override suspend fun saveShouldSaveUserWallets(item: Boolean) {
withContext(dispatchers.io) { shouldSaveUserWalletStore.store(item = item) }
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.data.wallets.di
import com.tangem.data.source.preferences.PreferencesDataSource
import com.tangem.data.wallets.DefaultWalletsRepository
import com.tangem.datasource.local.userwallet.ShouldSaveUserWalletStore
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -17,11 +17,11 @@ object WalletsDataModule {
@Provides
@Singleton
fun providesWalletsRepository(
preferencesDataSource: PreferencesDataSource,
shouldSaveUserWalletStore: ShouldSaveUserWalletStore,
coroutineDispatcherProvider: CoroutineDispatcherProvider,
): WalletsRepository {
return DefaultWalletsRepository(
preferencesDataSource = preferencesDataSource,
shouldSaveUserWalletStore = shouldSaveUserWalletStore,
dispatchers = coroutineDispatcherProvider,
)
}