Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-03 21:52:57 +03:00
commit a0c9ace219
1607 changed files with 40580 additions and 16776 deletions

View file

@ -24,7 +24,9 @@ dependencies {
// region Project - Domain
api(projects.domain.account)
api(projects.domain.card)
api(projects.domain.common)
api(projects.domain.models)
api(projects.domain.tokens)
// endregion
// region Project - Data

View file

@ -28,7 +28,7 @@ internal class AccountListConverter @AssistedInject constructor(
override fun convert(value: GetWalletAccountsResponse): AccountList {
return AccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),

View file

@ -50,7 +50,9 @@ internal class CryptoPortfolioConverter @AssistedInject constructor(
derivationIndex = value.derivationIndex.value,
icon = value.icon.value.name,
iconColor = value.icon.color.name,
tokens = value.cryptoCurrencies.map(userTokensResponseFactory::createResponseToken),
tokens = value.cryptoCurrencies.map {
userTokensResponseFactory.createResponseToken(currency = it, accountId = value.accountId)
},
)
}

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.utils.converter.Converter
@ -21,8 +20,8 @@ internal object SaveWalletAccountsResponseConverter : Converter<AccountList, Sav
)
}
private fun toDTO(account: Account.CryptoPortfolio): WalletAccountDTO {
return WalletAccountDTO(
private fun toDTO(account: Account.CryptoPortfolio): SaveWalletAccountsResponse.AccountDTO {
return SaveWalletAccountsResponse.AccountDTO(
id = account.accountId.value,
name = AccountNameConverter.convert(value = account.accountName),
derivationIndex = account.derivationIndex.value,

View file

@ -7,13 +7,16 @@ import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher
import com.tangem.data.account.repository.DefaultAccountsCRUDRepository
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -38,6 +41,7 @@ internal object AccountDataModule {
walletAccountsSaver: WalletAccountsSaver,
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
userTokensSaver: UserTokensSaver,
eTagsStore: ETagsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
dispatchers: CoroutineDispatcherProvider,
@ -48,6 +52,7 @@ internal object AccountDataModule {
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
convertersContainer = accountConverterFactoryContainer,
dispatchers = dispatchers,
@ -61,4 +66,16 @@ internal object AccountDataModule {
@Provides
@Singleton
fun provideWalletAccountsSaver(impl: DefaultWalletAccountsFetcher): WalletAccountsSaver = impl
@Provides
@Singleton
fun provideMainAccountTokensMigration(
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userTokensSaver: UserTokensSaver,
): MainAccountTokensMigration {
return DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
userTokensSaver = userTokensSaver,
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.data.account.di
import com.tangem.data.account.producer.AccountListCryptoCurrenciesProducer
import com.tangem.data.account.producer.DefaultMultiWalletCryptoCurrenciesProducer
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object MultiWalletCryptoCurrenciesProducerModule {
@Singleton
@Provides
fun provideMultiWalletCryptoCurrenciesProducerFactory(
accountsFeatureToggles: AccountsFeatureToggles,
defaultImpl: DefaultMultiWalletCryptoCurrenciesProducer.Factory,
accountsImpl: AccountListCryptoCurrenciesProducer.Factory,
): MultiWalletCryptoCurrenciesProducer.Factory {
return if (accountsFeatureToggles.isFeatureEnabled) accountsImpl else defaultImpl
}
}

View file

@ -11,6 +11,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
/**
* Implementation of [MultiAccountListFetcher]
@ -20,7 +21,7 @@ import java.util.concurrent.ConcurrentHashMap
*
[REDACTED_AUTHOR]
*/
internal class DefaultMultiAccountListFetcher(
internal class DefaultMultiAccountListFetcher @Inject constructor(
private val singleAccountListFetcher: SingleAccountListFetcher,
private val userWalletsStore: UserWalletsStore,
) : MultiAccountListFetcher {

View file

@ -3,6 +3,7 @@ package com.tangem.data.account.fetcher
import arrow.core.Either
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import javax.inject.Inject
/**
* Implementation of [SingleAccountListFetcher]
@ -11,7 +12,7 @@ import com.tangem.domain.account.fetcher.SingleAccountListFetcher
*
[REDACTED_AUTHOR]
*/
internal class DefaultSingleAccountListFetcher(
internal class DefaultSingleAccountListFetcher @Inject constructor(
private val walletAccountsFetcher: WalletAccountsFetcher,
) : SingleAccountListFetcher {

View file

@ -2,6 +2,7 @@ package com.tangem.data.account.fetcher
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsFetcher
@ -11,9 +12,10 @@ import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.common.response.isNetworkError
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
@ -31,17 +33,20 @@ import javax.inject.Singleton
* @property accountsResponseStoreFactory factory to create [AccountsResponseStore]
* @property userTokensSaver saves user tokens to the database
* @property fetchWalletAccountsErrorHandler handles errors during fetching wallet accounts
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
* @property eTagsStore store for ETags to manage caching
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class DefaultWalletAccountsFetcher @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val userTokensSaver: UserTokensSaver,
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : WalletAccountsFetcher, WalletAccountsSaver {
@ -49,30 +54,33 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
override suspend fun fetch(userWalletId: UserWalletId) {
val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse)
val unassignedTokens = accountsResponse?.unassignedTokens
?: return
if (!unassignedTokens.isNullOrEmpty()) {
if (accountsResponse.accounts.isEmpty()) {
initializeAccounts(userWalletId, accountsResponse)
} else if (accountsResponse.unassignedTokens.isNotEmpty()) {
assignTokens(userWalletId, accountsResponse)
}
}
override suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse) {
push(userWalletId = userWalletId, accounts = response.accounts)
store(userWalletId = userWalletId, response = response)
}
override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) {
val store = getAccountsResponseStore(userWalletId = userWalletId)
store.updateData { response }
}
override suspend fun push(userWalletId: UserWalletId, accounts: List<WalletAccountDTO>) {
push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts))
override suspend fun push(
userWalletId: UserWalletId,
accounts: List<WalletAccountDTO>,
): GetWalletAccountsResponse? {
return push(userWalletId = userWalletId, body = SaveWalletAccountsResponse(accounts = accounts))
}
override suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse) {
safeApiCall(
override suspend fun push(
userWalletId: UserWalletId,
body: SaveWalletAccountsResponse,
): GetWalletAccountsResponse? {
return safeApiCall(
call = {
var eTag = getETag(userWalletId)
@ -98,6 +106,8 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
throw error
}
null
},
)
}
@ -122,25 +132,41 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
responseBody
},
onError = {
onError = { throwable ->
// pushWalletAccounts and storeWalletAccounts help to avoid cyclic dependency
fetchWalletAccountsErrorHandler.handle(
error = it,
error = throwable,
userWalletId = userWalletId,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = ::push,
storeWalletAccounts = ::store,
)
null
},
)
}
private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
val response = defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = UserTokensResponse(
group = accountsResponse.wallet.group,
sort = accountsResponse.wallet.sort,
tokens = accountsResponse.unassignedTokens,
),
)
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts)
if (syncedResponse != null) {
store(userWalletId = userWalletId, response = syncedResponse)
}
}
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)
pushAndStore(userWalletId = userWalletId, response = accountsResponseWithTokens)
store(userWalletId = userWalletId, response = accountsResponseWithTokens)
userTokensSaver.push(
userWalletId = userWalletId,
@ -153,7 +179,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
}
private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) {
val eTag = apiResponse.headers[IF_NONE_MATCH_HEADER]?.firstOrNull()
val eTag = apiResponse.headers[ETAG_HEADER]?.firstOrNull()
if (eTag != null) {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)

View file

@ -1,10 +1,8 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
@ -13,10 +11,6 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import timber.log.Timber
import javax.inject.Inject
@ -24,12 +18,9 @@ import javax.inject.Inject
/**
* Handles errors that occur during the fetching of wallet accounts
*
* @property userTokensSaver saves user tokens to the storage
* @property userWalletsStore provides access to user wallet data
* @property userTokensResponseStore provides access to user token responses.
* @property cryptoPortfolioCF factory for converting crypto portfolios
* @property userTokensResponseFactory factory for creating user token responses
* @property cardCryptoCurrencyFactory factory for creating default cryptocurrencies for multi-currency wallets
* @property userTokensSaver saves user tokens to the storage
* @property userTokensResponseStore provides access to user token responses.
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
*
* @see DefaultWalletAccountsFetcher
*
@ -37,11 +28,8 @@ import javax.inject.Inject
*/
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val userTokensSaver: UserTokensSaver,
private val userWalletsStore: UserWalletsStore,
private val userTokensResponseStore: UserTokensResponseStore,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
) {
/**
@ -59,72 +47,47 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
error: ApiResponseError,
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (userWalletId: UserWalletId, accounts: List<WalletAccountDTO>) -> Unit,
storeWalletAccounts: suspend (userWalletId: UserWalletId, response: GetWalletAccountsResponse) -> Unit,
) {
pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?,
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): GetWalletAccountsResponse? {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
if (isResponseUpToDate) {
Timber.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
return
return savedAccountsResponse
}
val (accountDTOs, userTokensResponse) = if (savedAccountsResponse == null) {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
createDefaultAccountDTOs(userWallet) to getFromLegacyStore(userWalletId).orDefault(userWallet)
} else {
savedAccountsResponse.accounts to savedAccountsResponse.toUserTokensResponse()
}
var response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
pushWalletAccounts(userWalletId, accountDTOs)
userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse)
val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs)
if (updatedResponse != null) {
response = updatedResponse
}
}
val response = savedAccountsResponse.orDefault(userWalletId, accountDTOs, userTokensResponse)
storeWalletAccounts(userWalletId, response)
return response
}
private fun createDefaultAccountDTOs(userWallet: UserWallet): List<WalletAccountDTO> {
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val converter = cryptoPortfolioCF.create(userWallet = userWallet)
return converter.convertListBack(input = accounts)
private suspend fun createDefaultResponse(userWalletId: UserWalletId): GetWalletAccountsResponse {
return defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = getFromLegacyStore(userWalletId),
)
}
private suspend fun getFromLegacyStore(userWalletId: UserWalletId): UserTokensResponse? {
return userTokensResponseStore.getSyncOrNull(userWalletId)
?.let {
it.copy(
tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens),
)
}
.also { userTokensResponseStore.clear(userWalletId) }
}
private fun UserTokensResponse?.orDefault(userWallet: UserWallet): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet = userWallet),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
private fun GetWalletAccountsResponse?.orDefault(
userWalletId: UserWalletId,
accountDTOs: List<WalletAccountDTO>,
userTokensResponse: UserTokensResponse,
): GetWalletAccountsResponse {
if (this != null) return this
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = accountDTOs.size,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = userTokensResponse.tokens),
unassignedTokens = emptyList(),
)
}
}

View file

@ -0,0 +1,64 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.some
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.*
/**
* Implementation of [MultiWalletCryptoCurrenciesProducer] that produces crypto currencies of all accounts
*
* @property params params
* @property userWalletsStore UserWallet's store
* @property accountsResponseStoreFactory factory to create store with accounts response
* @property responseCryptoCurrenciesFactory factory for creating [CryptoCurrency] from `UserTokensResponse`
* @property dispatchers dispatchers
*
[REDACTED_AUTHOR]
*/
internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor(
@Assisted val params: MultiWalletCryptoCurrenciesProducer.Params,
private val userWalletsStore: UserWalletsStore,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : MultiWalletCryptoCurrenciesProducer {
override val fallback: Option<Set<CryptoCurrency>> = emptySet<CryptoCurrency>().some()
override fun produce(): Flow<Set<CryptoCurrency>> {
val userWallet = userWalletsStore.getSyncStrict(key = params.userWalletId)
if (!userWallet.isMultiCurrency) {
error("${this::class.simpleName} supports only multi-currency wallet")
}
return accountsResponseStoreFactory.create(userWalletId = userWallet.walletId).data
.distinctUntilChanged()
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response.toUserTokensResponse(),
userWallet = userWallet,
).toSet()
}
.onEmpty { emit(emptySet()) }
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : MultiWalletCryptoCurrenciesProducer.Factory {
override fun create(params: MultiWalletCryptoCurrenciesProducer.Params): AccountListCryptoCurrenciesProducer
}
}

View file

@ -5,6 +5,7 @@ import arrow.core.some
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.MultiAccountListProducer
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -35,10 +36,11 @@ internal class DefaultMultiAccountListProducer @AssistedInject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<List<AccountList>> {
return userWalletsStore.userWallets
.map { it.map(UserWallet::walletId) }
.distinctUntilChanged()
.flatMapLatest { userWallets ->
.flatMapLatest { ids ->
combine(
flows = userWallets.map(walletAccountListFlowFactory::create),
flows = ids.map(walletAccountListFlowFactory::create),
transform = ::listOf,
)
}

View file

@ -1,4 +1,4 @@
package com.tangem.data.tokens
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.some

View file

@ -2,7 +2,6 @@ package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.none
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -11,16 +10,13 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleAccountListProducer].
* Produces a list of [AccountList] for a specific user wallet.
*
* @property params params containing the user wallet ID
* @property userWalletsStore store that provides user wallets
* @property walletAccountListFlowFactory builder to create flows of [AccountList] for each wallet
* @property dispatchers coroutine dispatchers provider
*
@ -28,7 +24,6 @@ import kotlinx.coroutines.flow.mapNotNull
*/
internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@Assisted val params: SingleAccountListProducer.Params,
private val userWalletsStore: UserWalletsStore,
private val walletAccountListFlowFactory: WalletAccountListFlowFactory,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountListProducer {
@ -37,11 +32,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
override fun produce(): Flow<AccountList> {
return userWalletsStore.userWallets
.mapNotNull { userWallets ->
userWallets.firstOrNull { it.walletId == params.userWalletId }
}
.flatMapLatest(walletAccountListFlowFactory::create)
return walletAccountListFlowFactory.create(userWalletId = params.userWalletId)
.flowOn(dispatchers.default)
}

View file

@ -4,9 +4,11 @@ import com.tangem.data.account.converter.AccountListConverter
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.models.wallet.requireColdWallet
import kotlinx.coroutines.flow.*
@ -22,12 +24,15 @@ import javax.inject.Inject
[REDACTED_AUTHOR]
*/
internal class WalletAccountListFlowFactory @Inject constructor(
private val userWalletsStore: UserWalletsStore,
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val accountListConverterFactory: AccountListConverter.Factory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) {
fun create(userWallet: UserWallet): Flow<AccountList> {
fun create(userWalletId: UserWalletId): Flow<AccountList> {
val userWallet = userWalletsStore.getSyncStrict(userWalletId)
return if (userWallet.isMultiCurrency) {
createForMultiWallet(userWallet)
} else {
@ -50,9 +55,9 @@ internal class WalletAccountListFlowFactory @Inject constructor(
val currencies = if (isSingleWalletWithToken) {
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = userWallet).toSet()
} else {
cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet).let(::setOf)
setOf(cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet = userWallet))
}
return AccountList.empty(userWallet = userWallet, cryptoCurrencies = currencies)
return AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = currencies)
}
}

View file

@ -9,8 +9,10 @@ import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
@ -24,9 +26,11 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.replaceBy
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
[REDACTED_AUTHOR]
@ -38,6 +42,7 @@ internal class DefaultAccountsCRUDRepository(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val userTokensSaver: UserTokensSaver,
private val eTagsStore: ETagsStore,
private val convertersContainer: AccountConverterFactoryContainer,
private val dispatchers: CoroutineDispatcherProvider,
@ -101,12 +106,44 @@ internal class DefaultAccountsCRUDRepository(
}
override suspend fun saveAccounts(accountList: AccountList) {
val userWalletId = accountList.userWallet.walletId
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = accountList.userWalletId)
val converter = convertersContainer.getWalletAccountsResponseCF.create(userWallet = accountList.userWallet)
val accountsResponse = converter.convert(value = accountList)
val accountDTOs = converter.convertListBack(
input = accountList.accounts.filterIsInstance<Account.CryptoPortfolio>(),
)
walletAccountsSaver.pushAndStore(userWalletId = userWalletId, response = accountsResponse)
val syncedResponse = walletAccountsSaver.push(userWalletId = accountList.userWalletId, accounts = accountDTOs)
if (syncedResponse != null) {
walletAccountsSaver.store(userWalletId = accountList.userWalletId, response = syncedResponse)
}
}
override suspend fun saveAccount(account: Account.CryptoPortfolio) {
val store = getAccountsResponseStore(userWalletId = account.userWalletId)
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = account.userWalletId)
val newAccountDTO = converter.convertBack(value = account)
store.updateData { response ->
response ?: return@updateData response
response.copy(
accounts = response.accounts.toMutableList().apply {
replaceBy(newAccountDTO) { it.id == newAccountDTO.id }
},
)
}
}
override suspend fun syncTokens(userWalletId: UserWalletId) {
val response = getAccountsResponseSync(userWalletId = userWalletId)
if (response == null) {
Timber.e("Can't sync tokens. No accounts response found for wallet: $userWalletId")
return
}
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
}
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
@ -30,6 +31,7 @@ typealias AccountsResponseStore = DataStore<GetWalletAccountsResponse?>
*
[REDACTED_AUTHOR]
*/
@Singleton
internal class AccountsResponseStoreFactory @Inject constructor(
@ApplicationContext private val context: Context,
@NetworkMoshi private val moshi: Moshi,

View file

@ -0,0 +1,147 @@
package com.tangem.data.account.tokens
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.either
import arrow.core.raise.ensureNotNull
import arrow.core.toNonEmptyListOrNull
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.assignTokens
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.lib.crypto.derivation.AccountNodeRecognizer
import timber.log.Timber
/**
* Implementation of [MainAccountTokensMigration] for migrating tokens associated with a main account.
* The migration process involves transferring unassigned tokens from the main account to a selected account.
*
* @property accountsResponseStoreFactory Factory for creating stores to access cached account responses.
* @property userTokensSaver Saver for updating user tokens in persistent storage.
*
[REDACTED_AUTHOR]
*/
internal class DefaultMainAccountTokensMigration(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val userTokensSaver: UserTokensSaver,
) : MainAccountTokensMigration {
override suspend fun migrate(
userWalletId: UserWalletId,
derivationIndex: DerivationIndex,
): Either<Throwable, Unit> = either {
if (derivationIndex == DerivationIndex.Main) {
Timber.i("Migration skipped: derivation index is Main")
return@either
}
val store = accountsResponseStoreFactory.create(userWalletId)
val response = store.getSyncOrNull()
ensureNotNull(response) {
val exception = IllegalStateException("No cached accounts response found")
Timber.e(exception)
exception
}
val mainAccount = findAccount(response = response, derivationIndex = DerivationIndex.Main)
val selectedAccount = findAccount(response = response, derivationIndex = derivationIndex)
val unassignedTokens = mainAccount.findUnassignedTokens(derivationIndex)
if (unassignedTokens == null) {
Timber.i("No unassigned tokens found for migration")
return@either
}
val updatedResponse = response.copy(
accounts = response.accounts.map { account ->
when (account.id) {
mainAccount.id -> {
account.copy(tokens = account.tokens.orEmpty() - unassignedTokens)
}
selectedAccount.id -> {
selectedAccount.assignTokens(userWalletId, unassignedTokens)
}
else -> account
}
},
)
userTokensSaver.push(
userWalletId = userWalletId,
response = updatedResponse.toUserTokensResponse(),
onFailSend = {
// TODO: save failed state to retry later
// [REDACTED_JIRA]
val exception = IllegalStateException("Failed to push updated tokens after migration")
Timber.e(exception)
raise(exception)
},
)
}
private fun Raise<Throwable>.findAccount(
response: GetWalletAccountsResponse,
derivationIndex: DerivationIndex,
): WalletAccountDTO {
val account = response.accounts.firstOrNull { it.derivationIndex == derivationIndex.value }
return ensureNotNull(account) {
val exception = IllegalStateException("No account found with derivation index: $derivationIndex")
Timber.e(exception)
exception
}
}
private fun WalletAccountDTO.findUnassignedTokens(
derivationIndex: DerivationIndex,
): List<UserTokensResponse.Token>? {
val tokens = this.tokens
if (tokens.isNullOrEmpty()) return tokens
return tokens
.filterByDerivationIndex(derivationIndex)
.map { it.copy(accountId = null) }
.toNonEmptyListOrNull()
}
private fun List<UserTokensResponse.Token>.filterByDerivationIndex(
derivationIndex: DerivationIndex,
): List<UserTokensResponse.Token> {
return filter { savedToken ->
val blockchain = Blockchain.fromNetworkId(networkId = savedToken.networkId)
if (blockchain == null) {
Timber.e("Token has unknown networkId: $savedToken")
return@filter false
}
val derivationPathValue = savedToken.derivationPath
if (derivationPathValue == null) {
Timber.e("Token has no derivation path: $savedToken")
return@filter false
}
val accountNodeRecognizer = AccountNodeRecognizer(blockchain)
val accountNodeValue = accountNodeRecognizer.recognize(derivationPathValue)
if (accountNodeValue == null) {
Timber.e("Token has unrecognized derivation path: $savedToken")
return@filter false
}
accountNodeValue == derivationIndex.value.toLong()
}
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.data.account.utils
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
/**
* Factory to create default [GetWalletAccountsResponse].
*
* @property userWalletsListRepository repository to get user wallet information
* @property cryptoPortfolioCF converter factory to convert crypto portfolio accounts
* @property userTokensResponseFactory factory to create [UserTokensResponse]
* @property cardCryptoCurrencyFactory factory to get default coins for multi-currency wallet
*
[REDACTED_AUTHOR]
*/
internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
) {
suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId }
val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty()
val response = userTokensResponse.orDefault(userWallet = userWallet)
return GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = response.group,
sort = response.sort,
totalAccounts = accountDTOs.size,
),
accounts = accountDTOs.assignTokens(userWalletId = userWalletId, tokens = response.tokens),
unassignedTokens = emptyList(),
)
}
private fun createDefaultAccountDTOs(userWallet: UserWallet): List<WalletAccountDTO> {
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val converter = cryptoPortfolioCF.create(userWallet = userWallet)
return converter.convertListBack(input = accounts)
}
private fun UserTokensResponse?.orDefault(userWallet: UserWallet?): UserTokensResponse {
if (this != null) return this
return userTokensResponseFactory.createUserTokensResponse(
currencies = userWallet?.let(cardCryptoCurrencyFactory::createDefaultCoinsForMultiCurrencyWallet).orEmpty(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}

View file

@ -54,4 +54,14 @@ internal fun List<WalletAccountDTO>.assignTokens(
tokens = enrichedTokens[accountDTO.id].orEmpty(),
)
}
}
internal fun WalletAccountDTO.assignTokens(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): WalletAccountDTO {
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.filter { it.accountId == this.id }
return copy(tokens = enrichedTokens)
}

View file

@ -8,7 +8,6 @@ import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
internal fun createWalletAccountDTO(
@ -72,13 +71,13 @@ internal fun createGetWalletAccountsResponse(
}
internal fun createAccountList(
userWallet: UserWallet,
userWalletId: UserWalletId,
sortType: TokensSortType = TokensSortType.BALANCE,
groupType: TokensGroupType = TokensGroupType.NETWORK,
): AccountList {
return AccountList(
userWallet = userWallet,
accounts = setOf(createCryptoPortfolio(userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(createCryptoPortfolio(userWalletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,

View file

@ -96,7 +96,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
@ -110,7 +110,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),
@ -124,7 +124,7 @@ class AccountListConverterTest {
),
expected = Result.success(
createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),

View file

@ -46,7 +46,7 @@ class GetWalletAccountsResponseConverterTest {
@Test
fun `cryptoPortfolioConverter throws exception`() {
// Arrange
val domain = createAccountList(userWallet = userWallet)
val domain = createAccountList(userWalletId = userWallet.walletId)
val exception = IllegalStateException("Test exception")
every { cryptoPortfolioConverter.convertBack(any()) } throws exception
@ -92,7 +92,7 @@ class GetWalletAccountsResponseConverterTest {
return listOf(
ConvertModel(
value = createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
),
@ -106,7 +106,7 @@ class GetWalletAccountsResponseConverterTest {
),
ConvertModel(
value = createAccountList(
userWallet = userWallet,
userWalletId = userWallet.walletId,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
),

View file

@ -2,14 +2,10 @@ package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@ -19,13 +15,11 @@ class SaveWalletAccountsResponseConverterTest {
@Test
fun convert() {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns UserWalletId("011")
}
val userWalletId = UserWalletId("011")
val accountList = AccountList(
userWallet = userWallet,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWallet.walletId)),
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
)
.getOrNull()!!
@ -36,7 +30,7 @@ class SaveWalletAccountsResponseConverterTest {
// Assert
val expected = SaveWalletAccountsResponse(
accounts = listOf(
WalletAccountDTO(
SaveWalletAccountsResponse.AccountDTO(
id = accountList.mainAccount.accountId.value,
name = (accountList.mainAccount.accountName as? AccountName.Custom)?.value,
derivationIndex = accountList.mainAccount.derivationIndex.value,

View file

@ -2,18 +2,19 @@ package com.tangem.data.account.fetcher
import com.google.common.truth.Truth
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.IF_NONE_MATCH_HEADER
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -24,6 +25,7 @@ import org.junit.jupiter.api.*
/**
[REDACTED_AUTHOR]
*/
@Suppress("Unchecked_Cast", "UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultWalletAccountsFetcherTest {
@ -35,6 +37,7 @@ class DefaultWalletAccountsFetcherTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val fetchWalletAccountsErrorHandler: FetchWalletAccountsErrorHandler = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
private val fetcher: DefaultWalletAccountsFetcher = DefaultWalletAccountsFetcher(
@ -42,6 +45,7 @@ class DefaultWalletAccountsFetcherTest {
accountsResponseStoreFactory = accountsResponseStoreFactory,
userTokensSaver = userTokensSaver,
fetchWalletAccountsErrorHandler = fetchWalletAccountsErrorHandler,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -78,14 +82,17 @@ class DefaultWalletAccountsFetcherTest {
val savedAccountsResponse = null
val unassignedToken = createToken(accountId = null)
val accounts = listOf(createWalletAccountDTO(userWalletId = userWalletId, tokens = null))
val accountsResponse = createGetWalletAccountsResponse(
userWalletId = userWalletId,
unassignedTokens = listOf(unassignedToken),
)
.copy(accounts = accounts)
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
headers = mapOf(ETAG_HEADER to listOf(newETag)),
)
accountsResponseStoreFlow.value = savedAccountsResponse
@ -110,7 +117,7 @@ class DefaultWalletAccountsFetcherTest {
eTag = eTag,
body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts),
)
} returns ApiResponse.Success(data = Unit)
} returns ApiResponse.Success(data = updatedAccountsResponse)
// Act
fetcher.fetch(userWalletId)
@ -124,12 +131,6 @@ class DefaultWalletAccountsFetcherTest {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag)
accountsResponseStoreFactory.create(userWalletId = userWalletId)
accountsResponseStore.updateData(any())
eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts),
)
accountsResponseStoreFactory.create(userWalletId = userWalletId)
accountsResponseStore.updateData(any())
}
@ -156,7 +157,7 @@ class DefaultWalletAccountsFetcherTest {
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
headers = mapOf(ETAG_HEADER to listOf(newETag)),
)
accountsResponseStoreFlow.value = savedAccountsResponse
@ -199,7 +200,7 @@ class DefaultWalletAccountsFetcherTest {
fun `fetch should call error handler when getWalletAccounts returns error`() = runTest {
// Arrange
val savedAccountsResponse = null
val apiError = ApiResponse.Error(ApiResponseError.NetworkException)
val apiError = ApiResponse.Error(ApiResponseError.NetworkException())
accountsResponseStoreFlow.value = savedAccountsResponse
@ -207,6 +208,16 @@ class DefaultWalletAccountsFetcherTest {
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
} returns apiError as ApiResponse<GetWalletAccountsResponse>
coEvery {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)
} returns savedAccountsResponse
// Act
fetcher.fetch(userWalletId)
@ -232,87 +243,6 @@ class DefaultWalletAccountsFetcherTest {
userTokensSaver.push(userWalletId = any(), response = any())
}
}
@Test
fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest {
// Arrange
val savedAccountsResponse = null
val unassignedToken = createToken(accountId = null)
val accountsResponse = createGetWalletAccountsResponse(
userWalletId = userWalletId,
unassignedTokens = listOf(unassignedToken),
)
val newETag = "newEtag"
val apiResponse = ApiResponse.Success(
data = accountsResponse,
headers = mapOf(IF_NONE_MATCH_HEADER to listOf(newETag)),
)
val apiError = ApiResponseError.HttpException(
code = ApiResponseError.HttpException.Code.PRECONDITION_FAILED,
message = null,
errorBody = null,
)
val saveApiResponse = ApiResponse.Error(apiError)
accountsResponseStoreFlow.value = savedAccountsResponse
val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"
val updatedAccountsResponse = accountsResponse.copy(
accounts = accountsResponse.accounts.map {
it.copy(tokens = listOf(unassignedToken.copy(accountId = accountId)))
},
unassignedTokens = emptyList(),
)
coEvery {
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
} returns apiResponse
coEvery { accountsResponseStore.updateData(any()) } returns accountsResponse
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts),
)
} returns saveApiResponse as ApiResponse<Unit>
// Act
val actual = runCatching { fetcher.fetch(userWalletId) }.exceptionOrNull()!!
// Assert
Truth.assertThat(actual).isEqualTo(apiError)
coVerifyOrder {
accountsResponseStoreFactory.create(userWalletId = userWalletId)
accountsResponseStore.data
eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
tangemTechApi.getWalletAccounts(walletId = userWalletId.stringValue, eTag = eTag)
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = newETag)
accountsResponseStoreFactory.create(userWalletId = userWalletId)
accountsResponseStore.updateData(any())
eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = SaveWalletAccountsResponse(updatedAccountsResponse.accounts),
)
}
coVerify(inverse = true) {
fetchWalletAccountsErrorHandler.handle(
error = any(),
userWalletId = any(),
savedAccountsResponse = any(),
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)
}
}
}
@Nested
@ -343,28 +273,26 @@ class DefaultWalletAccountsFetcherTest {
@Test
fun `push should call saveWalletAccounts with correct params`() = runTest {
// Arrange
val accounts = listOf(
mockk<WalletAccountDTO>(),
)
val response = SaveWalletAccountsResponse(accounts)
val getResponse = createGetWalletAccountsResponse(userWalletId, tokens = null)
val saveResponse = SaveWalletAccountsResponse(getResponse.accounts)
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = response,
body = saveResponse,
)
} returns ApiResponse.Success(data = Unit)
} returns ApiResponse.Success(data = getResponse)
// Act
fetcher.push(userWalletId, response)
fetcher.push(userWalletId, saveResponse)
// Assert
coVerify {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = response,
body = saveResponse,
)
}
}
@ -373,7 +301,7 @@ class DefaultWalletAccountsFetcherTest {
fun `push should throw error when saveWalletAccounts returns PRECONDITION_FAILED`() = runTest {
// Arrange
val accounts = listOf(
mockk<WalletAccountDTO>(),
createWalletAccountDTO(userWalletId = userWalletId, tokens = null),
)
val response = SaveWalletAccountsResponse(accounts)
val apiError = ApiResponseError.HttpException(
@ -388,7 +316,7 @@ class DefaultWalletAccountsFetcherTest {
eTag = eTag,
body = response,
)
} returns saveApiResponse as ApiResponse<Unit>
} returns saveApiResponse as ApiResponse<GetWalletAccountsResponse>
// Act
val actual = runCatching { fetcher.push(userWalletId, response) }.exceptionOrNull()!!
@ -398,44 +326,6 @@ class DefaultWalletAccountsFetcherTest {
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class PushAndStore {
@Test
fun `pushAndStore should call push and store with correct params`() = runTest {
// Arrange
listOf(
mockk<WalletAccountDTO>(),
)
val response = createGetWalletAccountsResponse(userWalletId)
coEvery {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = SaveWalletAccountsResponse(accounts = response.accounts),
)
} returns ApiResponse.Success(data = Unit)
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
// Act
fetcher.pushAndStore(userWalletId, response)
// Assert
coVerifyOrder {
tangemTechApi.saveWalletAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
body = SaveWalletAccountsResponse(accounts = response.accounts),
)
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.updateData(any())
}
}
}
private fun createToken(
networkId: String = "ethereum",
derivationPath: String = "m/44'/60'/0'/0/0",

View file

@ -1,9 +1,9 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code
@ -11,12 +11,11 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -29,35 +28,25 @@ import org.junit.jupiter.api.TestInstance
class FetchWalletAccountsErrorHandlerTest {
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory = mockk()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory: UserTokensResponseFactory = mockk()
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val handler = FetchWalletAccountsErrorHandler(
userTokensSaver = userTokensSaver,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
)
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
private val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse =
mockk(relaxed = true)
private val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
@BeforeEach
fun setupEach() {
clearMocks(
userTokensSaver,
userWalletsStore,
userTokensResponseStore,
cryptoPortfolioCF,
cryptoPortfolioConverter,
cardCryptoCurrencyFactory,
defaultWalletAccountsResponseFactory,
)
}
@ -70,9 +59,6 @@ class FetchWalletAccountsErrorHandlerTest {
errorBody = null,
)
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk()
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk()
// Act
handler.handle(
error = error,
@ -84,12 +70,8 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
storeWalletAccounts(any(), any())
@ -105,29 +87,11 @@ class FetchWalletAccountsErrorHandlerTest {
errorBody = null,
)
val accountDTO = WalletAccountDTO(
id = "nibh",
name = "Michael Dotson",
derivationIndex = 7135,
icon = "consectetuer",
iconColor = "ferri",
tokens = listOf(),
totalTokens = 7738,
totalNetworks = 3348,
)
val accountDTO = createWalletAccountDTO(userWalletId)
val savedAccountsResponse = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 1,
),
accounts = listOf(accountDTO),
unassignedTokens = emptyList(),
)
val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk(relaxed = true)
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse
// Act
handler.handle(
@ -140,28 +104,21 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify {
pushWalletAccounts(userWalletId, listOf(accountDTO))
userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
pushWalletAccounts(userWalletId, listOf(accountDTO))
storeWalletAccounts(userWalletId, savedAccountsResponse)
}
coVerify(inverse = true) {
userWalletsStore.getSyncStrict(key = any())
userTokensResponseStore.getSyncOrNull(userWalletId = any())
userTokensResponseFactory.createUserTokensResponse(any(), any(), any())
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(any())
cryptoPortfolioCF.create(any())
cryptoPortfolioConverter.convertListBack(any())
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
}
}
@Test
fun `uses default accounts when savedAccountsResponse is null`() = runTest {
// Arrange
val error = ApiResponseError.TimeoutException
val accounts = AccountList.empty(userWallet).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val error = ApiResponseError.TimeoutException()
val accountDTO = WalletAccountDTO(
id = "nibh",
@ -186,21 +143,10 @@ class FetchWalletAccountsErrorHandlerTest {
val userTokensResponse = savedAccountsResponse.toUserTokensResponse()
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
every { cryptoPortfolioCF.create(userWallet) } returns cryptoPortfolioConverter
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountDTO)
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns null
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> Unit = mockk(relaxed = true)
val storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit = mockk(relaxed = true)
coEvery { userTokensResponseStore.getSyncOrNull(userWalletId) } returns userTokensResponse
coEvery {
defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse)
} returns savedAccountsResponse
// Act
handler.handle(
@ -213,16 +159,8 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify {
userWalletsStore.getSyncStrict(userWalletId)
cryptoPortfolioCF.create(userWallet)
cryptoPortfolioConverter.convertListBack(accounts)
userTokensResponseStore.getSyncOrNull(userWalletId)
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
defaultWalletAccountsResponseFactory.create(userWalletId, userTokensResponse)
storeWalletAccounts(userWalletId, any())
}

View file

@ -51,8 +51,8 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -63,7 +63,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -73,11 +73,11 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -95,9 +95,9 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -107,10 +107,10 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -128,9 +128,9 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -141,7 +141,7 @@ class DefaultMultiAccountListProducerTest {
every { userWalletsStore.userWallets } returns userWalletsFlow
val exception = RuntimeException("Converter error")
every { walletAccountListFlowFactory.create(userWallet) } throws exception
every { walletAccountListFlowFactory.create(userWalletId) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
@ -152,7 +152,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -178,7 +178,7 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
every { walletAccountListFlowFactory.create(userWallet) } returns emptyFlow()
every { walletAccountListFlowFactory.create(userWalletId) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -188,7 +188,7 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@ -203,9 +203,9 @@ class DefaultMultiAccountListProducerTest {
val userWalletsFlow = MutableStateFlow(listOf(userWallet, userWallet2))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
every { walletAccountListFlowFactory.create(userWallet2) } returns emptyFlow()
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
every { walletAccountListFlowFactory.create(userWalletId2) } returns emptyFlow()
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -215,8 +215,8 @@ class DefaultMultiAccountListProducerTest {
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWallet2)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId2)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.data.tokens
package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.blockchain.common.Blockchain
@ -261,7 +261,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
val actual1 = getEmittedValues(flow = producerFlow)
// Assert
val expected1 = producer.fallback
val expected1 = producer.fallback.getOrNull()
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
@ -297,7 +297,7 @@ internal class DefaultMultiWalletCryptoCurrenciesProducerTest {
val actual = producer.produce().let(::getEmittedValues)
// Assert
val expected = producer.fallback
val expected = producer.fallback.getOrNull()
Truth.assertThat(actual.size).isEqualTo(1)
Truth.assertThat(actual.first()).isEqualTo(expected)

View file

@ -2,7 +2,6 @@ package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.models.TokensSortType
@ -11,7 +10,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@ -26,7 +24,6 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultSingleAccountListProducerTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val walletAccountListFlowFactory: WalletAccountListFlowFactory = mockk()
private val userWalletId = UserWalletId("011")
@ -36,24 +33,22 @@ class DefaultSingleAccountListProducerTest {
private val producer = DefaultSingleAccountListProducer(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
userWalletsStore = userWalletsStore,
walletAccountListFlowFactory = walletAccountListFlowFactory,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@AfterEach
fun tearDownEach() {
clearMocks(userWalletsStore, walletAccountListFlowFactory)
clearMocks(walletAccountListFlowFactory)
}
@Test
fun produce() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
MutableStateFlow(listOf(userWallet))
val accountList = AccountList.empty(userWallet)
every { walletAccountListFlowFactory.create(userWallet) } returns flowOf(accountList)
val accountList = AccountList.empty(userWalletId)
every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList)
// Act
val actual = producer.produce().let(::getEmittedValues)
@ -63,22 +58,18 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow will updated if factoryFlow is updated`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val updatedAccountList = AccountList.empty(userWallet = userWallet, sortType = TokensSortType.NONE)
val accountList = AccountList.empty(userWalletId)
val updatedAccountList = AccountList.empty(userWalletId = userWalletId, sortType = TokensSortType.NONE)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -95,23 +86,18 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(secondEmission).containsExactly(updatedAccountList)
coVerifyOrder {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow is filtered the same response`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
val factoryFlow = MutableStateFlow<AccountList?>(null)
every { walletAccountListFlowFactory.create(userWallet) } returns factoryFlow.filterNotNull()
every { walletAccountListFlowFactory.create(userWalletId) } returns factoryFlow.filterNotNull()
// Act (first emission)
factoryFlow.value = accountList
@ -128,71 +114,8 @@ class DefaultSingleAccountListProducerTest {
Truth.assertThat(secondEmission).containsExactly(accountList)
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
walletAccountListFlowFactory.create(userWalletId)
walletAccountListFlowFactory.create(userWalletId)
}
}
@Test
fun `flow is empty if factory throws exception`() = runTest {
// Arrange
val userWalletsFlow = MutableStateFlow(value = listOf(userWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
val exception = RuntimeException("Converter error")
every { walletAccountListFlowFactory.create(userWallet) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
walletAccountListFlowFactory.create(userWallet)
}
}
@Test
fun `flow is empty if userWalletsFlow returns empty flow`() = runTest {
// Arrange
val userWalletsFlow = emptyFlow<List<UserWallet>>()
every { userWalletsStore.userWallets } returns userWalletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(exactly = 1) { userWalletsStore.userWallets }
coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) }
}
@Test
fun `flow is empty if userWalletsFlow doesn't contains userWalletId from params`() = runTest {
// Arrange
val unknownId = UserWalletId("012")
val unknownWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns unknownId
}
val userWalletsFlow = MutableStateFlow(listOf(unknownWallet))
every { userWalletsStore.userWallets } returns userWalletsFlow
// Act
val actual = producer.produce().let(::getEmittedValues)
// Assert
Truth.assertThat(actual).isEmpty() // no emissions
coVerify(ordering = Ordering.SEQUENCE) {
userWalletsStore.userWallets
}
coVerify(inverse = true) { walletAccountListFlowFactory.create(any()) }
}
}

View file

@ -10,6 +10,7 @@ import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -28,6 +29,7 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class WalletAccountListFlowFactoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val accountsResponseStoreFactory: AccountsResponseStoreFactory = mockk()
private val accountsResponseStore: AccountsResponseStore = mockk()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
@ -38,6 +40,7 @@ class WalletAccountListFlowFactoryTest {
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk()
private val factory = WalletAccountListFlowFactory(
userWalletsStore = userWalletsStore,
accountsResponseStoreFactory = accountsResponseStoreFactory,
accountListConverterFactory = accountListConverterFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
@ -48,8 +51,14 @@ class WalletAccountListFlowFactoryTest {
@AfterEach
fun tearDownEach() {
clearMocks(accountListConverter)
clearMocks(
userWalletsStore,
accountsResponseStoreFactory,
accountsResponseStore,
accountListConverterFactory,
accountListConverter,
cardCryptoCurrencyFactory,
)
accountsResponseStoreFlow.value = null
}
@ -61,17 +70,19 @@ class WalletAccountListFlowFactoryTest {
every { this@mockk.isMultiCurrency } returns true
}
every { userWalletsStore.getSyncStrict(userWalletId) } returns userWallet
val accountsResponse = createGetWalletAccountsResponse(userWalletId)
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
accountsResponseStoreFlow.value = accountsResponse
val accountList = AccountList.empty(userWallet)
val accountList = AccountList.empty(userWalletId)
every { accountListConverterFactory.create(userWallet) } returns accountListConverter
every { accountListConverter.convert(accountsResponse) } returns accountList
// Act
val actual = factory.create(userWallet).let(::getEmittedValues)
val actual = factory.create(userWalletId).let(::getEmittedValues)
// Assert
val expected = accountList
@ -94,14 +105,16 @@ class WalletAccountListFlowFactoryTest {
fun `create for single wallet`() = runTest {
val userWallet = MockUserWalletFactory.create().copy(isMultiCurrency = false)
every { userWalletsStore.getSyncStrict(userWallet.walletId) } returns userWallet
val currency = cryptoCurrencyFactory.ethereum
every { cardCryptoCurrencyFactory.createPrimaryCurrencyForSingleCurrencyCard(userWallet) } returns currency
// Act
val actual = factory.create(userWallet).let(::getEmittedValues)
val actual = factory.create(userWallet.walletId).let(::getEmittedValues)
// Assert
val expected = AccountList.empty(userWallet = userWallet, cryptoCurrencies = setOf(currency))
val expected = AccountList.empty(userWalletId = userWallet.walletId, cryptoCurrencies = setOf(currency))
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {
@ -121,16 +134,18 @@ class WalletAccountListFlowFactoryTest {
fun `flow is created for single wallet with token`() = runTest {
val nodl = MockUserWalletFactory.createSingleWalletWithToken()
every { userWalletsStore.getSyncStrict(nodl.walletId) } returns nodl
val currencies = cryptoCurrencyFactory.ethereumAndStellar.toSet()
every {
cardCryptoCurrencyFactory.createCurrenciesForSingleCurrencyCardWithToken(userWallet = nodl)
} returns currencies.toList()
// Act
val actual = factory.create(nodl).let(::getEmittedValues)
val actual = factory.create(nodl.walletId).let(::getEmittedValues)
// Assert
val expected = AccountList.empty(userWallet = nodl, cryptoCurrencies = currencies)
val expected = AccountList.empty(userWalletId = nodl.walletId, cryptoCurrencies = currencies)
Truth.assertThat(actual).containsExactly(expected)
coVerify(ordering = Ordering.SEQUENCE) {

View file

@ -11,6 +11,7 @@ import com.tangem.data.account.store.ArchivedAccountsStore
import com.tangem.data.account.store.ArchivedAccountsStoreFactory
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
@ -25,7 +26,6 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
@ -37,6 +37,7 @@ import kotlin.time.Duration.Companion.minutes
/**
[REDACTED_AUTHOR]
*/
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultAccountsCRUDRepositoryTest {
@ -52,6 +53,7 @@ class DefaultAccountsCRUDRepositoryTest {
private val archivedAccountsStore = ArchivedAccountsStore(runtimeStore = archivedAccountsInnerStore)
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensSaver: UserTokensSaver = mockk()
private val eTagsStore: ETagsStore = mockk()
private val convertersContainer: AccountConverterFactoryContainer = mockk()
@ -64,6 +66,7 @@ class DefaultAccountsCRUDRepositoryTest {
accountsResponseStoreFactory = accountsResponseStoreFactory,
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
convertersContainer = convertersContainer,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -580,22 +583,18 @@ class DefaultAccountsCRUDRepositoryTest {
@Test
fun `saveAccounts should call API and update store`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWalletId = userWalletId)
val accounts = accountList.accounts.filterIsInstance<CryptoPortfolio>()
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse>()
val accountsResponse = createGetWalletAccountsResponse(userWalletId)
accountsResponseStoreFlow.value = accountsResponse
val converter = mockk<GetWalletAccountsResponseConverter> {
every { this@mockk.convert(accountList) } returns accountsResponse
val converter = mockk<CryptoPortfolioConverter> {
every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts
}
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
} returns converter
every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter
coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } returns accountsResponse
// Act
repository.saveAccounts(accountList)
@ -604,35 +603,30 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(accountsResponseStoreFlow.value).isEqualTo(accountsResponse)
coVerifyOrder {
convertersContainer.getWalletAccountsResponseCF.create(userWallet)
converter.convert(accountList)
walletAccountsSaver.pushAndStore(userWalletId, accountsResponse)
convertersContainer.createCryptoPortfolioConverter(userWalletId)
converter.convertListBack(accounts)
walletAccountsSaver.push(userWalletId, accountsResponse.accounts)
}
}
@Test
fun `saveAccounts if API request is failed`() = runTest {
// Arrange
val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
}
val accountList = AccountList.empty(userWalletId = userWalletId)
val accounts = accountList.accounts.filterIsInstance<CryptoPortfolio>()
val accountList = AccountList.empty(userWallet = userWallet)
val accountsResponse = mockk<GetWalletAccountsResponse>()
val accountsResponse = createGetWalletAccountsResponse(userWalletId)
accountsResponseStoreFlow.value = accountsResponse
val converter = mockk<GetWalletAccountsResponseConverter> {
every { this@mockk.convert(accountList) } returns accountsResponse
val converter = mockk<CryptoPortfolioConverter> {
every { this@mockk.convertListBack(accounts) } returns accountsResponse.accounts
}
every {
convertersContainer.getWalletAccountsResponseCF.create(userWallet = userWallet)
} returns converter
every { convertersContainer.createCryptoPortfolioConverter(userWalletId) } returns converter
val exception = Exception("Test error")
coEvery { walletAccountsSaver.pushAndStore(userWalletId, accountsResponse) } throws exception
coEvery { walletAccountsSaver.push(userWalletId, accountsResponse.accounts) } throws exception
// Act
val actual = runCatching { repository.saveAccounts(accountList) }.exceptionOrNull()!!
@ -642,9 +636,8 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(actual).hasMessageThat().isEqualTo(exception.message)
coVerifyOrder {
convertersContainer.getWalletAccountsResponseCF.create(userWallet)
converter.convert(accountList)
walletAccountsSaver.pushAndStore(userWalletId, accountsResponse)
convertersContainer.createCryptoPortfolioConverter(userWalletId)
converter.convertListBack(accounts)
}
}
}

View file

@ -0,0 +1,226 @@
package com.tangem.data.account.token
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.account.store.AccountsResponseStore
import com.tangem.data.account.store.AccountsResponseStoreFactory
import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultMainAccountTokensMigrationTest {
private val accountsResponseStoreFactory = mockk<AccountsResponseStoreFactory>()
private val accountsResponseStore = mockk<AccountsResponseStore>()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val userTokensSaver = mockk<UserTokensSaver>(relaxed = true)
private val migration = DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
userTokensSaver = userTokensSaver,
)
private val userWalletId = UserWalletId("011")
private val derivationIndex = DerivationIndex(1).getOrNull()!!
@BeforeEach
fun setupAll() {
every { accountsResponseStoreFactory.create(userWalletId) } returns accountsResponseStore
every { accountsResponseStore.data } returns accountsResponseStoreFlow
}
@AfterEach
fun tearDown() {
clearMocks(accountsResponseStoreFactory, accountsResponseStore, userTokensSaver)
accountsResponseStoreFlow.value = null
}
@Test
fun `migrate skips when derivation index is Main`() = runTest {
// Act
val actual = migration.migrate(userWalletId, DerivationIndex.Main)
// Assert
assertEitherRight(actual)
coVerify(inverse = true) {
accountsResponseStoreFactory.create(any())
accountsResponseStore.data
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate fails when no cached accounts response`() = runTest {
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
val expected = IllegalStateException("No cached accounts response found")
assertEitherLeft(actual, expected)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate fails when selected account DTO not found`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(
userWalletId = userWalletId,
tokens = listOf(
createBitcoin(accountIndex = DerivationIndex.Main.value),
),
)
accountsResponseStoreFlow.value = response
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
val expected = IllegalStateException("No account found with derivation index: $derivationIndex")
assertEitherLeft(actual, expected)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate skips when no unassigned tokens`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(
userWalletId = userWalletId,
tokens = listOf(
createBitcoin(accountIndex = DerivationIndex.Main.value),
),
)
val selectedAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value,
derivationIndex = derivationIndex.value,
tokens = emptyList(),
)
accountsResponseStoreFlow.value = response.copy(accounts = response.accounts + selectedAccount)
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
assertEitherRight(actual)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
}
}
@Test
fun `migrate updates tokens for selected account`() = runTest {
// Arrange
val unassignedToken = createBitcoin(accountIndex = 1)
val mainAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex.Main).value,
derivationIndex = DerivationIndex.Main.value,
tokens = listOf(
createBitcoin(accountIndex = 0),
unassignedToken,
),
)
val selectedAccount = createWalletAccountDTO(
userWalletId = userWalletId,
accountId = AccountId.forCryptoPortfolio(userWalletId, derivationIndex).value,
derivationIndex = derivationIndex.value,
tokens = emptyList(),
)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
),
accounts = listOf(mainAccount, selectedAccount),
unassignedTokens = emptyList(),
)
accountsResponseStoreFlow.value = response
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
// Assert
assertEitherRight(actual)
val migratedResponse = response.copy(
accounts = listOf(
mainAccount.copy(tokens = mainAccount.tokens!! - unassignedToken),
selectedAccount.copy(tokens = listOf(unassignedToken)),
),
)
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
userTokensSaver.push(
userWalletId = userWalletId,
response = migratedResponse.toUserTokensResponse(),
onFailSend = any(),
)
}
}
private fun createBitcoin(accountIndex: Int): UserTokensResponse.Token {
return UserTokensResponse.Token(
id = "ne",
accountId = AccountId.forCryptoPortfolio(
userWalletId = userWalletId,
derivationIndex = DerivationIndex(accountIndex).getOrNull()!!,
).value,
networkId = "bitcoin",
derivationPath = "m/44'/60'/$accountIndex'/0/0",
name = "Phil Hinton",
symbol = "graeci",
decimals = 6487,
contractAddress = "vim",
addresses = listOf(),
)
}
}

View file

@ -0,0 +1,240 @@
package com.tangem.data.account.utils
import com.google.common.truth.Truth
import com.tangem.data.account.converter.CryptoPortfolioConverter
import com.tangem.data.account.converter.createWalletAccountDTO
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultWalletAccountsResponseFactoryTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val cryptoPortfolioCF = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory = mockk<UserTokensResponseFactory>()
private val cardCryptoCurrencyFactory = mockk<CardCryptoCurrencyFactory>()
private val factory = DefaultWalletAccountsResponseFactory(
userWalletsListRepository = userWalletsListRepository,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
)
private val userWalletId = UserWalletId("011")
@BeforeEach
fun setUpEach() {
every { cryptoPortfolioCF.create(any()) } returns cryptoPortfolioConverter
}
@AfterEach
fun tearDownEach() {
clearMocks(
userWalletsListRepository,
cryptoPortfolioCF,
cryptoPortfolioConverter,
userTokensResponseFactory,
cardCryptoCurrencyFactory,
)
}
@Test
fun `create returns empty accounts when user wallet not found`() = runTest {
// Arrange
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
// Act
val actual = factory.create(userWalletId = userWalletId, userTokensResponse = null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}
@Test
fun `create returns response with default tokens when userTokensResponse is null`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val defaultCoins = listOf(mockk<CryptoCurrency.Coin>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns defaultCoins
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
// Act
val actual = factory.create(userWalletId, null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
cryptoPortfolioConverter.convertListBack(accounts)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
}
}
@Test
fun `create returns response with default tokens when userTokensResponse is null and no default coins`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
val actual = factory.create(userWalletId, null)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = defaultResponse.group,
sort = defaultResponse.sort,
totalAccounts = 0,
),
accounts = emptyList(),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `create returns response with assigned tokens`() = runTest {
// Arrange
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
val assignedTokens = listOf(mockk<CryptoCurrency.Token>(), mockk<CryptoCurrency.Token>())
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = assignedTokens,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
} returns userTokensResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
// Act
val actual = factory.create(userWalletId, userTokensResponse)
// Assert
val expected = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
group = userTokensResponse.group,
sort = userTokensResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}

View file

@ -0,0 +1,50 @@
package com.tangem.domain.card.common
import org.junit.Assert
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
class TwinsHelperTest {
private val pack1Twins = listOf(
"CB610000021",
"CB620000031",
)
private val pack2Twins = listOf(
"CB640000012",
"CB650000011",
)
@Test
fun `twins compatibility pack 1 success`() {
Assert.assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[1]))
assertTrue(TwinsHelper.isTwinsCompatible(pack1Twins[1], pack1Twins[0]))
}
@Test
fun `twins compatibility pack 1 same cards`() {
assertFalse(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack1Twins[0]))
assertFalse(TwinsHelper.isTwinsCompatible(pack1Twins[1], pack1Twins[1]))
}
@Test
fun `twins compatibility pack 2 success`() {
assertTrue(TwinsHelper.isTwinsCompatible(pack2Twins[0], pack2Twins[1]))
assertTrue(TwinsHelper.isTwinsCompatible(pack2Twins[1], pack2Twins[0]))
}
@Test
fun `twins compatibility pack 2 same cards`() {
assertFalse(TwinsHelper.isTwinsCompatible(pack2Twins[0], pack2Twins[0]))
assertFalse(TwinsHelper.isTwinsCompatible(pack2Twins[1], pack2Twins[1]))
}
@Test
fun `twins compatibility false for different packs`() {
assertFalse(TwinsHelper.isTwinsCompatible(pack2Twins[0], pack1Twins[1]))
assertFalse(TwinsHelper.isTwinsCompatible(pack1Twins[1], pack2Twins[0]))
assertFalse(TwinsHelper.isTwinsCompatible(pack1Twins[0], pack2Twins[1]))
assertFalse(TwinsHelper.isTwinsCompatible(pack2Twins[1], pack1Twins[0]))
}
}

View file

@ -37,14 +37,14 @@ internal class DefaultAnalyticsRepository(
}
override suspend fun setWalletBalanceState(userWalletId: UserWalletId, balanceState: WalletBalanceState) {
appPreferencesStore.editData {
val walletsBalanceState = it.getObjectMap<WalletBalanceState>(
appPreferencesStore.editData { preferences ->
val walletsBalanceState = preferences.getObjectMap<WalletBalanceState>(
key = PreferencesKeys.WALLETS_BALANCES_STATES_KEY,
)
val updatedWalletsBalanceState = walletsBalanceState
.plus(pair = userWalletId.stringValue to balanceState)
it.setObjectMap(PreferencesKeys.WALLETS_BALANCES_STATES_KEY, updatedWalletsBalanceState)
preferences.setObjectMap(PreferencesKeys.WALLETS_BALANCES_STATES_KEY, updatedWalletsBalanceState)
}
}
}

View file

@ -20,8 +20,8 @@ class DefaultDeviceFlipDetector @Inject constructor(
) : DeviceFlipDetector, DefaultLifecycleObserver {
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private var gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
private var isResumedState = AtomicBoolean(false)
private val gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
private val isResumedState = AtomicBoolean(false)
override fun onPause(owner: LifecycleOwner) {
isResumedState.set(false)

View file

@ -17,9 +17,9 @@ internal class FlipListener(private val action: () -> Unit) : SensorEventListene
}
override fun onSensorChanged(event: SensorEvent?) {
event?.let {
if (event != null) {
val currentTime = SystemClock.elapsedRealtime()
val zAxisValue = it.values[2]
val zAxisValue = event.values[2]
if (zAxisValue < zAxisThreshold && !isScreenDown) {
isScreenDown = true

View file

@ -12,6 +12,7 @@ android {
dependencies {
/* Project - Domain */
implementation(projects.data.common)
implementation(projects.domain.models)
implementation(projects.domain.blockaid)
implementation(projects.domain.blockaid.models)
@ -20,6 +21,7 @@ dependencies {
/* Project - Core */
implementation(projects.core.utils)
implementation(projects.libs.blockchainSdk)
/* DI */
implementation(deps.hilt.core)

View file

@ -0,0 +1,32 @@
package com.tangem.data.blockaid
import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras
import com.tangem.blockchain.common.Blockchain
import com.tangem.datasource.api.common.blockaid.models.request.BlockAidScanOptions
import com.tangem.datasource.api.common.blockaid.models.request.Data
import com.tangem.datasource.api.common.blockaid.models.request.EvmTransactionBulkScanRequest
import com.tangem.datasource.api.common.blockaid.models.response.TransactionMetadata
import com.tangem.utils.converter.Converter
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
internal class BlockAidEvmScanTransactionConverter(
private val blockchain: Blockchain,
) : Converter<List<SDKTransactionData.Uncompiled>, EvmTransactionBulkScanRequest> {
@Suppress("NullableToStringCall")
override fun convert(value: List<SDKTransactionData.Uncompiled>): EvmTransactionBulkScanRequest {
return EvmTransactionBulkScanRequest(
chain = blockchain.getChainId().toString(),
options = listOf(BlockAidScanOptions.GasEstimation.value),
metadata = TransactionMetadata(domain = "https://tangem.com"),
data = value.map { transactionData ->
Data(
from = transactionData.sourceAddress,
to = transactionData.destinationAddress,
data = (transactionData.extras as? EthereumTransactionExtras)?.callData?.dataHex.orEmpty(),
)
},
aggregated = false,
)
}
}

View file

@ -169,7 +169,7 @@ internal object BlockAidMapper {
val tokenInfo = TokenInfo(
chainId = exposure.asset.chainId,
logoUrl = exposure.asset.logoUrl,
symbol = exposure.asset.symbol ?: "",
symbol = exposure.asset.symbol.orEmpty(),
decimals = exposure.asset.decimals ?: 0,
)
return exposure.spenders.flatMap { (_, spender) ->
@ -188,8 +188,8 @@ internal object BlockAidMapper {
private fun mapApproveNftTransaction(exposure: Exposure): ApproveInfo.NonFungibleToken {
return ApproveInfo.NonFungibleToken(
name = exposure.asset.name.orEmpty(),
logoUrl = exposure.spenders.values.firstOrNull()?.exposure?.firstOrNull()?.logoUrl
?: exposure.asset.logoUrl,
logoUrl = exposure.spenders.values.firstOrNull()
?.exposure?.firstOrNull()?.logoUrl ?: exposure.asset.logoUrl,
)
}
@ -201,7 +201,7 @@ internal object BlockAidMapper {
val token = TokenInfo(
chainId = diff.asset.chainId,
logoUrl = diff.asset.logoUrl,
symbol = diff.asset.symbol ?: "",
symbol = diff.asset.symbol.orEmpty(),
decimals = diff.asset.decimals ?: 0,
)
diff.outTransfer.orEmpty().forEach { transfer ->

View file

@ -3,11 +3,19 @@ package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.domain.blockaid.models.transaction.TransactionData
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
interface BlockAidRepository {
suspend fun verifyDAppDomain(data: DAppData): CheckDAppResult
suspend fun verifyTransaction(data: TransactionData): CheckTransactionResult
suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult
}

View file

@ -0,0 +1,19 @@
package com.tangem.data.blockaid
import arrow.core.Either
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.tangem.blockchain.common.TransactionData
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
class DefaultBlockAidGasEstimate @Inject constructor(
private val repository: BlockAidRepository,
) : BlockAidGasEstimate {
override suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<TransactionData.Uncompiled>,
): Either<Throwable, GasEstimationResult> = Either.catch {
repository.getGasEstimation(cryptoCurrency = cryptoCurrency, transactionDataList = transactionDataList)
}
}

View file

@ -3,12 +3,18 @@ package com.tangem.data.blockaid
import com.domain.blockaid.models.dapp.CheckDAppResult
import com.domain.blockaid.models.dapp.DAppData
import com.domain.blockaid.models.transaction.CheckTransactionResult
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.domain.blockaid.models.transaction.TransactionData
import com.domain.blockaid.models.transaction.TransactionParams
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.data.blockaid.converters.GasEstimationResponseConverter
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.common.blockaid.models.request.DomainScanRequest
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import com.tangem.blockchain.common.TransactionData as SDKTransactionData
internal class DefaultBlockAidRepository(
private val api: BlockAidApi,
@ -30,6 +36,17 @@ internal class DefaultBlockAidRepository(
}
}
override suspend fun getGasEstimation(
cryptoCurrency: CryptoCurrency,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult {
val blockchain = cryptoCurrency.network.toBlockchain()
return when {
blockchain.isEvm() -> scanEvmTransactionBulk(blockchain, transactionDataList)
else -> error("Gas estimation with BlockAid not supported by ${blockchain.fullName}")
}
}
private suspend fun scanEvmTransaction(data: TransactionData): CheckTransactionResult =
withContext(dispatchers.io) {
val response = api.scanJsonRpc(mapper.mapToEvmRequest(data))
@ -41,4 +58,15 @@ internal class DefaultBlockAidRepository(
val response = api.scanSolanaMessage(mapper.mapToSolanaRequest(data))
mapper.mapToDomain(response)
}
private suspend fun scanEvmTransactionBulk(
blockchain: Blockchain,
transactionDataList: List<SDKTransactionData.Uncompiled>,
): GasEstimationResult = withContext(dispatchers.io) {
val response = api.scanEvmTransactionBulk(
BlockAidEvmScanTransactionConverter(blockchain).convert(transactionDataList),
)
GasEstimationResponseConverter.convert(response)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.data.blockaid.converters
import com.domain.blockaid.models.transaction.GasEstimationResult
import com.tangem.blockchain.extensions.hexToBigInteger
import com.tangem.datasource.api.common.blockaid.models.response.GasEstimationResponse
import com.tangem.utils.converter.Converter
internal object GasEstimationResponseConverter : Converter<List<GasEstimationResponse>, GasEstimationResult> {
override fun convert(value: List<GasEstimationResponse>): GasEstimationResult {
return GasEstimationResult(
estimatedGasList = value.map { it.gasEstimation.estimate.hexToBigInteger() },
)
}
}

View file

@ -1,6 +1,8 @@
package com.tangem.data.blockaid.di
import com.tangem.data.blockaid.DefaultBlockAidGasEstimate
import com.tangem.data.blockaid.DefaultBlockAidVerifier
import com.tangem.domain.blockaid.BlockAidGasEstimate
import com.tangem.domain.blockaid.BlockAidVerifier
import dagger.Binds
import dagger.Module
@ -15,4 +17,8 @@ interface BlockAidDataModule {
@Binds
@Singleton
fun bindVerifier(verifier: DefaultBlockAidVerifier): BlockAidVerifier
@Binds
@Singleton
fun bindBlockAidGasEstimate(gasEstimate: DefaultBlockAidGasEstimate): BlockAidGasEstimate
}

View file

@ -20,7 +20,7 @@ internal class DefaultCardRepository(
override fun wasCardScanned(cardId: String): Flow<Boolean> {
return appPreferencesStore.getObjectList<UsedCardInfo>(key = PreferencesKeys.USED_CARDS_INFO_KEY)
.map { savedCards ->
savedCards?.any { it.cardId == cardId } ?: false
savedCards?.any { it.cardId == cardId } == true
}
}
@ -63,11 +63,11 @@ internal class DefaultCardRepository(
}
override suspend fun isActivationStarted(cardId: String): Boolean {
return getUsedCardSync(cardId)?.isActivationStarted ?: false
return getUsedCardSync(cardId)?.isActivationStarted == true
}
override suspend fun isActivationFinished(cardId: String): Boolean {
return getUsedCardSync(cardId)?.isActivationFinished ?: false
return getUsedCardSync(cardId)?.isActivationFinished == true
}
override suspend fun isActivationInProgress(cardId: String): Boolean {

View file

@ -12,18 +12,14 @@ import com.tangem.domain.models.wallet.UserWalletId
*/
interface WalletAccountsSaver {
/** Push and store wallet accounts [response] by [userWalletId] */
@Throws
suspend fun pushAndStore(userWalletId: UserWalletId, response: GetWalletAccountsResponse)
/** Store wallet accounts [response] by [userWalletId] */
suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse)
/** Push wallet accounts [body] by [userWalletId] */
@Throws
suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse)
suspend fun push(userWalletId: UserWalletId, body: SaveWalletAccountsResponse): GetWalletAccountsResponse?
/** Push wallet accounts [accounts] by [userWalletId] */
@Throws
suspend fun push(userWalletId: UserWalletId, accounts: List<WalletAccountDTO>)
suspend fun push(userWalletId: UserWalletId, accounts: List<WalletAccountDTO>): GetWalletAccountsResponse?
}

View file

@ -47,7 +47,7 @@ suspend inline fun <T> safeApiCallWithTimeout(
): T = safeApiCall(
call = {
withTimeoutOrNull(timeoutMillis) { call() }
?: raise(ApiResponseError.TimeoutException)
?: raise(ApiResponseError.TimeoutException())
},
onError = onError,
)

View file

@ -34,6 +34,11 @@ internal class DefaultETagsStore(
appPreferencesStore.store(key = key, value = value)
}
override suspend fun clear(userWalletId: UserWalletId, key: ETagsStore.Key) {
val key = getAccountsETagKey(userWalletId = userWalletId, key = key)
appPreferencesStore.editData { it.remove(key) }
}
private fun getAccountsETagKey(userWalletId: UserWalletId, key: ETagsStore.Key): Preferences.Key<String> {
return stringPreferencesKey(name = "etag_${key}_${userWalletId.stringValue}")
}

View file

@ -25,10 +25,17 @@ interface ETagsStore {
*/
suspend fun store(userWalletId: UserWalletId, key: Key, value: String)
/**
* Clears the stored ETag value for the specified wallet and key
*
* @param userWalletId identifier of the user wallet
* @param key the key for which to get the ETag value
*/
suspend fun clear(userWalletId: UserWalletId, key: Key)
/** Enumeration of possible keys for storing ETag values */
enum class Key {
WalletAccounts,
UserTokens,
;
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.blockchains.ethereum.Chain
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
@ -73,6 +74,17 @@ class CryptoCurrencyFactory(
)
}
fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? {
val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain
return if (blockchain != null) {
createCoin(blockchain, extraDerivationPath, userWallet)
} else {
Timber.e("Unable to get blockchain from chainId == $chainId")
null
}
}
fun createCoin(
blockchain: Blockchain,
extraDerivationPath: String?,

View file

@ -8,6 +8,7 @@ import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import timber.log.Timber
import javax.inject.Inject
@ -47,11 +48,35 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
blockchain = blockchain.getTestnetVersion() ?: blockchain
}
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = responseToken.derivationPath,
userWallet = userWallet,
) ?: return null
return createCurrency(responseToken = responseToken, userWallet = userWallet, network = network)
}
fun createCurrency(
responseToken: UserTokensResponse.Token,
userWallet: UserWallet,
network: Network,
): 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 (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isTestCard()) {
blockchain = blockchain.getTestnetVersion() ?: blockchain
}
val sdkToken = createSdkToken(responseToken)
return if (sdkToken == null) {
createCoin(blockchain, responseToken, userWallet)
createCoin(blockchain, responseToken, network)
} else {
createToken(blockchain, sdkToken, responseToken.derivationPath, userWallet)
createToken(blockchain, sdkToken, network)
}
}
@ -70,14 +95,8 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
private fun createCoin(
blockchain: Blockchain,
responseToken: UserTokensResponse.Token,
userWallet: UserWallet,
network: Network,
): CryptoCurrency.Coin? {
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = responseToken.derivationPath,
userWallet = userWallet,
) ?: return null
return CryptoCurrency.Coin(
id = getCoinId(network, blockchain.toCoinId()),
network = network,
@ -101,18 +120,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
}
}
private fun createToken(
blockchain: Blockchain,
sdkToken: Token,
responseDerivationPath: String?,
userWallet: UserWallet,
): CryptoCurrency.Token? {
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = responseDerivationPath,
userWallet = userWallet,
) ?: return null
private fun createToken(blockchain: Blockchain, sdkToken: Token, network: Network): CryptoCurrency.Token? {
val id = getTokenId(network, sdkToken)
return CryptoCurrency.Token(

View file

@ -81,7 +81,10 @@ private fun getCurrencyIdBody(network: Network): CurrencyIdBody {
rawId = network.rawId,
derivationPath = path.value,
)
is Network.DerivationPath.Card,
is Network.DerivationPath.Card -> CurrencyIdBody.NetworkIdWithDerivationPath(
rawId = network.rawId,
derivationPath = path.value,
)
is Network.DerivationPath.None,
-> CurrencyIdBody.NetworkId(network.rawId)
}

View file

@ -1,18 +1,21 @@
package com.tangem.data.common.currency
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import javax.inject.Inject
// TODO: [REDACTED_JIRA]
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
currencies: List<CryptoCurrency>,
isGroupedByNetwork: Boolean,
isSortedByBalance: Boolean,
accountId: AccountId? = null,
): UserTokensResponse {
return UserTokensResponse(
tokens = currencies.map(::createResponseToken),
tokens = currencies.map { createResponseToken(currency = it, accountId = accountId) },
group = if (isGroupedByNetwork) {
UserTokensResponse.GroupType.NETWORK
} else {
@ -26,10 +29,11 @@ class UserTokensResponseFactory @Inject constructor() {
)
}
fun createResponseToken(currency: CryptoCurrency): UserTokensResponse.Token {
fun createResponseToken(currency: CryptoCurrency, accountId: AccountId? = null): UserTokensResponse.Token {
return with(currency) {
UserTokensResponse.Token(
id = id.rawCurrencyId?.value,
accountId = accountId?.value,
networkId = network.backendId,
derivationPath = network.derivationPath.value,
name = name,

View file

@ -67,6 +67,24 @@ class NetworkFactory @Inject constructor(
)
}
/**
* Create
*
* @param blockchain blockchain
* @param derivationPath derivation path
* @param userWallet user wallet
*/
fun create(blockchain: Blockchain, derivationPath: Network.DerivationPath, userWallet: UserWallet): Network? {
return create(
blockchain = blockchain,
derivationPath = derivationPath,
canHandleTokens = userWallet.canHandleToken(
blockchain = blockchain,
excludedBlockchains = excludedBlockchains,
),
)
}
/**
* Create
*
@ -325,6 +343,9 @@ class NetworkFactory @Inject constructor(
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
// Blockchain.Linea, Blockchain.LineaTestnet,
// Blockchain.ArbitrumNova,
-> Network.TransactionExtrasType.NONE
// endregion
}

View file

@ -47,7 +47,6 @@ interface QuotesFetcher {
value = setOf(PRICE, PRICE_CHANGE_24H, PRICE_CHANGE_1W, PRICE_CHANGE_30D).combine(),
),
LAST_UPDATED_AT(value = "lastUpdatedAt"),
;
}
sealed interface Error {

View file

@ -440,5 +440,42 @@ internal class DefaultQuotesFetcherTest {
}
}
@Test
fun `fetch successfully if not all quotes are fetched`() = runTest {
// Arrange
val apiQuote = MockQuoteResponseFactory.createSinglePrice(BigDecimal.ONE)
val apiResponse = ApiResponse.Success(
data = QuotesResponse(quotes = mapOf("ethereum" to apiQuote)),
)
coEvery {
tangemTechApi.getQuotes(currencyId = "usd", coinIds = "ethereum,bitcoin", fields = "price")
} returns apiResponse
// Act
val actual = fetcher.fetch(
fiatCurrencyId = "usd",
currenciesIds = setOf("ethereum", "bitcoin"),
fields = setOf(Field.PRICE),
)
// Assert
val expected = QuotesResponse(
quotes = mapOf(
"ethereum" to apiQuote,
"bitcoin" to QuotesResponse.Quote.EMPTY,
),
).right()
Truth.assertThat(actual).isEqualTo(expected)
coVerify(exactly = 1) {
tangemTechApi.getQuotes(
currencyId = "usd",
coinIds = "ethereum,bitcoin",
fields = "price",
)
}
}
private fun Iterable<QuoteMetadata>?.toResponseQuotes() = this!!.associate { it.cryptoCurrencyId to it.value }
}

View file

@ -7,7 +7,7 @@ import com.tangem.data.feedback.converters.BlockchainInfoConverter
import com.tangem.data.feedback.converters.WalletMetaInfoConverter
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.models.*
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.models.scan.ScanResponse

View file

@ -6,7 +6,7 @@ import com.tangem.data.feedback.DefaultFeedbackFeatureToggles
import com.tangem.data.feedback.DefaultFeedbackRepository
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.domain.core.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
import com.tangem.domain.feedback.repository.FeedbackRepository
import com.tangem.domain.wallets.legacy.UserWalletsListManager

View file

@ -41,6 +41,11 @@ internal class DefaultCustomTokensRepository(
private val networkFactory: NetworkFactory,
) : CustomTokensRepository {
private val excludedBlockchainsForCustom = setOf(
Blockchain.Quai,
Blockchain.QuaiTestnet,
)
private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains)
private val userTokensResponseFactory = UserTokensResponseFactory()
private val tokenAddressConverter = TokenAddressesConverter()
@ -268,6 +273,7 @@ internal class DefaultCustomTokensRepository(
val scanResponse = userWallet.scanResponse
Blockchain.entries
.filter { it !in excludedBlockchainsForCustom }
.mapNotNull { blockchain ->
val canHandleBlockchain = scanResponse.card.canHandleBlockchain(
blockchain,

View file

@ -41,6 +41,7 @@ internal object TokenMarketListConverter : Converter<TokenMarketListResponse, To
),
tokenCharts = TokenMarket.Charts(h24 = null, week = null, month = null),
stakingRate = stakingRate,
updateTimestamp = value.timestamp,
)
}
return TokenMarketListWithMaxApy(tokens, value.summary?.maxApy)

View file

@ -33,6 +33,7 @@ dependencies {
// region Project - Libs
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain) { exclude(module = "joda-time") }
// endregion
// region DI

View file

@ -0,0 +1,98 @@
package com.tangem.data.networks.converters
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.domain.models.currency.CryptoCurrency.ID.Suffix as CurrencyIdSuffix
/**
* Converts between [CurrencyId] and [CryptoCurrency.ID].
*
* @property rawNetworkId the raw network ID associated with the currency
* @property derivationPath the derivation path used for the network
*
[REDACTED_AUTHOR]
*/
internal class CurrencyIdConverter(
private val rawNetworkId: String,
private val derivationPath: Network.DerivationPath,
) : TwoWayConverter<CurrencyId, CryptoCurrency.ID> {
override fun convert(value: CurrencyId): CryptoCurrency.ID {
val suffixParts = value.value.split(CONTRACT_ADDRESS_DELIMITER)
val rawId = suffixParts.getOrNull(0)
val contractAddress = suffixParts.getOrNull(1)
return if (contractAddress.isNullOrBlank()) {
getCoinId(
coinId = rawId.takeUnless { it.isNullOrBlank() }
?: error("Coin id is null for $rawNetworkId with $derivationPath"),
)
} else {
getTokenId(
rawTokenId = rawId?.ifBlank { null },
contractAddress = contractAddress,
)
}
}
override fun convertBack(value: CryptoCurrency.ID): CurrencyId {
return if (value.isCoin) {
CurrencyId.createCoinId(
coinId = Blockchain.fromId(value.rawNetworkId).toCoinId(),
)
} else {
CurrencyId.createTokenId(
rawTokenId = value.rawCurrencyId?.value,
contractAddress = requireNotNull(value.contractAddress) {
"Token contractAddress is null for token id: $this"
},
)
}
}
private fun getCoinId(coinId: String): CryptoCurrency.ID {
return CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = getCurrencyIdBody(),
suffix = CurrencyIdSuffix.RawID(rawId = coinId),
)
}
private fun getTokenId(rawTokenId: String?, contractAddress: String): CryptoCurrency.ID {
val suffix = if (rawTokenId == null) {
CurrencyIdSuffix.ContractAddress(contractAddress)
} else {
CurrencyIdSuffix.RawID(rawTokenId, contractAddress)
}
return CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = getCurrencyIdBody(),
suffix = suffix,
)
}
private fun getCurrencyIdBody(): CryptoCurrency.ID.Body {
return when (derivationPath) {
is Network.DerivationPath.Card -> {
CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = rawNetworkId,
derivationPath = derivationPath.value,
)
}
is Network.DerivationPath.Custom -> {
CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = rawNetworkId,
derivationPath = derivationPath.value,
)
}
is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId)
}
}
}

View file

@ -1,35 +1,46 @@
package com.tangem.data.networks.converters
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyAmount
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.utils.extensions.mapNotNullValues
import java.math.BigDecimal
private typealias AmountsDataModel = Map<String, BigDecimal>
private typealias AmountsDataModel = List<CurrencyAmount>
private typealias AmountsDomainModel = Map<CryptoCurrency.ID, NetworkStatus.Amount>
/**
* Converter from [AmountsDataModel] to [AmountsDomainModel] and vice versa
*
* @param rawNetworkId the raw network ID associated with the currency
* @param derivationPath the derivation path used for the network
*
[REDACTED_AUTHOR]
*/
internal object NetworkAmountsConverter : TwoWayConverter<AmountsDataModel, AmountsDomainModel> {
internal class NetworkAmountsConverter(
rawNetworkId: String,
derivationPath: Network.DerivationPath,
) : TwoWayConverter<AmountsDataModel, AmountsDomainModel> {
private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath)
override fun convert(value: AmountsDataModel): AmountsDomainModel {
return value
.mapKeys { CryptoCurrency.ID.fromValue(value = it.key) }
.mapValues { (_, amount) -> NetworkStatus.Amount.Loaded(value = amount) }
return value.associate {
val currencyId = currencyIdConverter.convert(value = it.id)
val amount = NetworkStatus.Amount.Loaded(value = it.amount)
currencyId to amount
}
}
override fun convertBack(value: AmountsDomainModel): AmountsDataModel {
return value
.mapKeys { (id, _) -> id.value }
.mapNotNullValues { (_, amount) ->
when (amount) {
is NetworkStatus.Amount.Loaded -> amount.value
is NetworkStatus.Amount.NotFound -> null
}
}
return value.mapNotNull {
val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null
CurrencyAmount(
id = currencyIdConverter.convertBack(value = it.key),
amount = amount.value,
)
}
}
}

View file

@ -15,14 +15,22 @@ internal object NetworkStatusDataModelConverter : Converter<NetworkStatus, Netwo
return when (val status = value.value) {
is NetworkStatus.Verified -> {
val address = NetworkAddressConverter.convertBack(value = status.address)
val amountsConverter = NetworkAmountsConverter(
rawNetworkId = value.network.rawId,
derivationPath = value.network.derivationPath,
)
val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter(
rawNetworkId = value.network.rawId,
derivationPath = value.network.derivationPath,
)
NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(value = value.network.rawId),
derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath),
selectedAddress = address.selectedAddress,
availableAddresses = address.addresses,
amounts = NetworkAmountsConverter.convertBack(value = status.amounts),
yieldSupplyStatuses = NetworkYieldSupplyStatusConverter.convertBack(status.yieldSupplyStatuses),
amounts = amountsConverter.convertBack(value = status.amounts),
yieldSupplyStatuses = yieldSupplyStatusConverter.convertBack(status.yieldSupplyStatuses),
)
}
is NetworkStatus.NoAccount -> {

View file

@ -2,45 +2,45 @@ package com.tangem.data.networks.converters
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.utils.converter.TwoWayConverter
import com.tangem.utils.extensions.mapNotNullValues
private typealias YieldSupplyStatusDataModel = Map<String, NetworkStatusDM.YieldSupplyStatus?>
private typealias YieldSupplyStatusDataModel = List<NetworkStatusDM.YieldSupplyStatus>
private typealias YieldSupplyStatusDomainModel = Map<CryptoCurrency.ID, YieldSupplyStatus?>
internal object NetworkYieldSupplyStatusConverter :
TwoWayConverter<YieldSupplyStatusDataModel, YieldSupplyStatusDomainModel> {
internal class NetworkYieldSupplyStatusConverter(
rawNetworkId: String,
derivationPath: Network.DerivationPath,
) : TwoWayConverter<YieldSupplyStatusDataModel, YieldSupplyStatusDomainModel> {
private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath)
override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel {
return value
.mapKeys { CryptoCurrency.ID.fromValue(value = it.key) }
.mapValues { (_, yieldSupplyStatus) ->
if (yieldSupplyStatus != null) {
YieldSupplyStatus(
isActive = yieldSupplyStatus.isActive,
isInitialized = yieldSupplyStatus.isInitialized,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,
)
} else {
null
}
}
return value.associate {
val id = currencyIdConverter.convert(value = it.id)
val status = YieldSupplyStatus(
isActive = it.isActive,
isInitialized = it.isInitialized,
isAllowedToSpend = it.isAllowedToSpend,
effectiveProtocolBalance = it.effectiveProtocolBalance,
)
id to status
}
}
override fun convertBack(value: YieldSupplyStatusDomainModel): YieldSupplyStatusDataModel {
return value
.mapKeys { (id, _) -> id.value }
.mapNotNullValues { (_, yieldSupplyStatus) ->
if (yieldSupplyStatus != null) {
NetworkStatusDM.YieldSupplyStatus(
isActive = yieldSupplyStatus.isActive,
isInitialized = yieldSupplyStatus.isInitialized,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,
)
} else {
null
}
}
return value.mapNotNull { (currencyId, yieldSupplyStatus) ->
if (yieldSupplyStatus == null) return@mapNotNull null
NetworkStatusDM.YieldSupplyStatus(
id = currencyIdConverter.convertBack(value = currencyId),
isActive = yieldSupplyStatus.isActive,
isInitialized = yieldSupplyStatus.isInitialized,
isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend,
effectiveProtocolBalance = yieldSupplyStatus.effectiveProtocolBalance,
)
}
}
}

View file

@ -22,14 +22,26 @@ internal object SimpleNetworkStatusConverter : Converter<NetworkStatusDM, Simple
),
)
val derivationPath = NetworkDerivationPathConverter.convert(value = value.derivationPath)
val amountsConverter = NetworkAmountsConverter(
rawNetworkId = value.networkId.value,
derivationPath = derivationPath,
)
val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter(
rawNetworkId = value.networkId.value,
derivationPath = derivationPath,
)
val status = when (value) {
is NetworkStatusDM.Verified -> {
NetworkStatus.Verified(
address = address,
amounts = NetworkAmountsConverter.convert(value = value.amounts),
amounts = amountsConverter.convert(value = value.amounts),
pendingTransactions = emptyMap(),
source = StatusSource.CACHE,
yieldSupplyStatuses = NetworkYieldSupplyStatusConverter.convert(value = value.yieldSupplyStatuses),
yieldSupplyStatuses = yieldSupplyStatusConverter.convert(value = value.yieldSupplyStatuses),
)
}
is NetworkStatusDM.NoAccount -> {

View file

@ -8,6 +8,7 @@ import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.networks.repository.DefaultNetworksRepository
import com.tangem.data.networks.store.DefaultNetworksStatusesStore
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.utils.DefaultNetworksCleaner
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.datasource.local.network.entity.NetworkStatusDM
@ -15,6 +16,7 @@ import com.tangem.datasource.utils.MoshiDataStoreSerializer
import com.tangem.datasource.utils.mapWithStringKeyTypes
import com.tangem.datasource.utils.setTypes
import com.tangem.domain.networks.repository.NetworksRepository
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
@ -38,6 +40,7 @@ internal object NetworkDataModule {
dispatchers: CoroutineDispatcherProvider,
): NetworksStatusesStore {
return DefaultNetworksStatusesStore(
context = context,
runtimeStore = RuntimeSharedStore(),
persistenceDataStore = DataStoreFactory.create(
serializer = MoshiDataStoreSerializer(
@ -45,7 +48,7 @@ internal object NetworkDataModule {
types = mapWithStringKeyTypes(valueTypes = setTypes<NetworkStatusDM>()),
defaultValue = emptyMap(),
),
produceFile = { context.dataStoreFile(fileName = "networks_statuses") },
produceFile = { context.dataStoreFile(fileName = "networks_statuses_2") },
scope = CoroutineScope(context = dispatchers.io + SupervisorJob()),
),
dispatchers = dispatchers,
@ -67,4 +70,18 @@ internal object NetworkDataModule {
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideNetworksCleaner(
networksStatusesStore: NetworksStatusesStore,
walletManagersFacade: WalletManagersFacade,
dispatchers: CoroutineDispatcherProvider,
): NetworksCleaner {
return DefaultNetworksCleaner(
networksStatusesStore = networksStatusesStore,
walletManagersFacade = walletManagersFacade,
dispatchers = dispatchers,
)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.data.networks.store
import android.content.Context
import androidx.datastore.core.DataStore
import com.tangem.data.networks.converters.NetworkStatusDataModelConverter
import com.tangem.data.networks.converters.SimpleNetworkStatusConverter
@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
internal typealias WalletIdWithSimpleStatus = Map<String, Set<SimpleNetworkStatus>>
internal typealias WalletIdWithStatusDM = Map<String, Set<NetworkStatusDM>>
@ -27,11 +29,13 @@ internal typealias WalletIdWithStatusDM = Map<String, Set<NetworkStatusDM>>
/**
* Default implementation of [NetworksStatusesStore]
*
* @param context context
* @property runtimeStore runtime store
* @property persistenceDataStore persistence store
* @param dispatchers dispatchers
*/
internal class DefaultNetworksStatusesStore(
context: Context,
private val runtimeStore: RuntimeSharedStore<WalletIdWithSimpleStatus>,
private val persistenceDataStore: DataStore<WalletIdWithStatusDM>,
dispatchers: CoroutineDispatcherProvider,
@ -41,6 +45,16 @@ internal class DefaultNetworksStatusesStore(
init {
scope.launch {
try {
val oldFile = File(context.filesDir, "datastore/networks_statuses")
if (oldFile.exists()) {
oldFile.delete()
}
} catch (e: Exception) {
Timber.e(e, "Error while deleting old networks statuses datastore file")
}
val cachedStatuses = persistenceDataStore.data.firstOrNull() ?: return@launch
runtimeStore.store(
@ -96,6 +110,20 @@ internal class DefaultNetworksStatusesStore(
}
}
override suspend fun clear(userWalletId: UserWalletId, networks: Set<Network>) {
persistenceDataStore.updateData { storedStatuses ->
storedStatuses.toMutableMap().apply {
val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot {
networks.any { network ->
it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value
}
}
this[userWalletId.stringValue] = updatedValues.toSet()
}
}
}
private suspend fun updateInRuntime(
userWalletId: UserWalletId,
networks: Set<Network>,

View file

@ -41,4 +41,7 @@ internal interface NetworksStatusesStore {
* See complex methods in `NetworksStatusesStoreExt`.
*/
suspend fun store(userWalletId: UserWalletId, status: NetworkStatus)
/** Clear statuses of [networks] by [userWalletId] */
suspend fun clear(userWalletId: UserWalletId, networks: Set<Network>)
}

View file

@ -0,0 +1,73 @@
package com.tangem.data.networks.utils
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Default implementation of [NetworksCleaner].
*
* @property networksStatusesStore Store to manage network statuses.
* @property walletManagersFacade Facade to manage wallet managers.
* @property dispatchers Coroutine dispatchers provider.
*
[REDACTED_AUTHOR]
*/
internal class DefaultNetworksCleaner(
private val networksStatusesStore: NetworksStatusesStore,
private val walletManagersFacade: WalletManagersFacade,
private val dispatchers: CoroutineDispatcherProvider,
) : NetworksCleaner {
override suspend fun invoke(userWalletId: UserWalletId, currencies: List<CryptoCurrency>) {
withContext(dispatchers.default) {
val (networks, tokens) = currencies.partitionByType()
coroutineScope {
launch { cleanStore(userWalletId = userWalletId, networks = networks) }
launch { cleanWalletManager(userWalletId = userWalletId, networks = networks, tokens = tokens) }
}
}
}
private suspend fun cleanStore(userWalletId: UserWalletId, networks: Set<Network>) {
if (networks.isNotEmpty()) {
networksStatusesStore.clear(userWalletId = userWalletId, networks = networks)
}
}
private suspend fun cleanWalletManager(
userWalletId: UserWalletId,
networks: Set<Network>,
tokens: Set<CryptoCurrency.Token>,
) {
if (networks.isNotEmpty()) {
walletManagersFacade.remove(userWalletId = userWalletId, networks = networks)
}
if (tokens.isNotEmpty()) {
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = tokens)
}
}
private fun List<CryptoCurrency>.partitionByType(): Pair<Set<Network>, Set<CryptoCurrency.Token>> {
val networks = mutableSetOf<Network>()
val tokens = mutableSetOf<CryptoCurrency.Token>()
for (currency in this) {
when (currency) {
is CryptoCurrency.Coin -> networks.add(currency.network)
is CryptoCurrency.Token -> tokens.add(currency)
}
}
return Pair(networks, tokens)
}
}

View file

@ -0,0 +1,190 @@
package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CurrencyIdConverterTest {
private val rawNetworkId = "ETH"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = CurrencyIdConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath)
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class Convert {
@ParameterizedTest
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { converter.convert(value = model.value) }
// Assert
actual
.onSuccess {
Truth.assertThat(it).isEqualTo(model.expected.getOrNull())
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message)
}
}
private fun provideTestModels(): Collection<ConvertModel> = listOf(
// create coin id
ConvertModel(
value = CurrencyId.createCoinId("ethereum"),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩ethereum"),
),
),
ConvertModel(
value = CurrencyId.createCoinId(""),
expected = Result.failure(
IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"),
),
),
ConvertModel(
value = CurrencyId.createCoinId(" "),
expected = Result.failure(
IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"),
),
),
// create token id
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = null,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = "",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = " ",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = "",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"),
),
),
ConvertModel(
value = CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = " ",
),
expected = Result.success(
CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"),
),
),
)
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class ConvertBack {
@ParameterizedTest
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = runCatching { converter.convertBack(value = model.value) }
// Assert
actual
.onSuccess {
Truth.assertThat(it).isEqualTo(model.expected.getOrNull())
}
.onFailure {
val expected = model.expected.exceptionOrNull()!!
Truth.assertThat(it).isInstanceOf(expected::class.java)
Truth.assertThat(it).hasMessageThat().isEqualTo(expected.message)
}
}
private fun provideTestModels(): Collection<ConvertBackModel> = listOf(
ConvertBackModel(
value = CryptoCurrency.ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum"),
expected = Result.success(
CurrencyId.createCoinId("ethereum"),
),
),
ConvertBackModel(
value = CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
ConvertBackModel(
value = CryptoCurrency.ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
expected = Result.success(
CurrencyId.createTokenId(
rawTokenId = null,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
),
),
)
}
data class ConvertModel(val value: CurrencyId, val expected: Result<CryptoCurrency.ID>)
data class ConvertBackModel(val value: CryptoCurrency.ID, val expected: Result<CurrencyId>)
}

View file

@ -1,16 +1,17 @@
package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.network.NetworkAddress
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class NetworkAddressConverterTest {
@Nested
@ -18,7 +19,7 @@ internal class NetworkAddressConverterTest {
inner class Convert {
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { NetworkAddressConverter.convert(value = model.value) }
@ -190,7 +191,7 @@ internal class NetworkAddressConverterTest {
inner class ConvertBack {
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = NetworkAddressConverter.convertBack(value = model.value)

View file

@ -1,77 +1,99 @@
package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.currency.CryptoCurrency.ID.Body
import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix
import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkStatus.Amount.Loaded
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class NetworkAmountsConverterTest {
private val rawNetworkId = "ETH"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = NetworkAmountsConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath)
@Test
fun convert() {
// Arrange
val value = mapOf(
"coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO,
"coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE,
val value = listOf(
NetworkStatusDM.CurrencyAmount(CurrencyId.createCoinId(coinId = "ethereum"), BigDecimal.ONE),
NetworkStatusDM.CurrencyAmount(
id = CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
amount = BigDecimal.ZERO,
),
NetworkStatusDM.CurrencyAmount(
id = CurrencyId.createTokenId(
rawTokenId = null,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
amount = BigDecimal.TEN,
),
)
// Act
val actual = NetworkAmountsConverter.convert(value)
val actual = converter.convert(value)
// Assert
val expected = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BCH"),
suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"),
ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.ZERO),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to Loaded(value = BigDecimal.ONE),
ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.TEN),
)
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
}
@Test
fun convertBack() {
// Arrange
val value = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BCH"),
suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"),
ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE),
ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.ZERO),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to Loaded(value = BigDecimal.ONE),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BTC"),
suffix = ID.Suffix.RawID(rawId = "bitcoin"),
) to Amount.NotFound,
ID.fromValue(
value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7",
) to Loaded(value = BigDecimal.TEN),
)
// Act
val actual = NetworkAmountsConverter.convertBack(value)
val actual = converter.convertBack(value)
// Assert
val expected = mapOf(
"coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO,
"coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE,
val expected = listOf(
NetworkStatusDM.CurrencyAmount(CurrencyId.createCoinId(coinId = "ethereum"), BigDecimal.ONE),
NetworkStatusDM.CurrencyAmount(
id = CurrencyId.createTokenId(
rawTokenId = "usdt",
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
amount = BigDecimal.ZERO,
),
NetworkStatusDM.CurrencyAmount(
id = CurrencyId.createTokenId(
rawTokenId = null,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
),
amount = BigDecimal.TEN,
),
)
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.network.entity.NetworkStatusDM.*
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.currency.CryptoCurrency.ID.Body
@ -14,7 +16,6 @@ import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
/**
@ -26,7 +27,7 @@ internal class NetworkStatusDataModelConverterTest {
private val network: Network = MockCryptoCurrencyFactory().ethereum.network
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = NetworkStatusDataModelConverter.convert(value = model.value)
@ -48,11 +49,7 @@ internal class NetworkStatusDataModelConverterTest {
),
),
amounts = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BCH"),
suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"),
) to Amount.Loaded(value = BigDecimal.ZERO),
ID.fromValue(value = "coin⟨ETH→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BTC"),
@ -61,14 +58,11 @@ internal class NetworkStatusDataModelConverterTest {
),
pendingTransactions = mapOf(), // doesn't matter
yieldSupplyStatuses = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BCH"),
suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"),
) to YieldSupplyStatus(
ID.fromValue(value = "token⟨ETH→0⟩usdt⚓0x1") to YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),
ID(
prefix = Prefix.COIN_PREFIX,
@ -79,25 +73,29 @@ internal class NetworkStatusDataModelConverterTest {
source = StatusSource.ACTUAL, // doesn't matter
),
),
expected = NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
expected = Verified(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "",
type = NetworkStatusDM.DerivationPath.Type.NONE,
type = DerivationPath.Type.NONE,
),
selectedAddress = "0x123",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x123",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
),
amounts = mapOf("coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO),
yieldSupplyStatuses = mapOf(
"coin⟨BCH⟩bitcoin-cash" to NetworkStatusDM.YieldSupplyStatus(
amounts = listOf(
CurrencyAmount(CurrencyId.createCoinId("ethereum"), BigDecimal.ZERO),
),
yieldSupplyStatuses = listOf(
YieldSupplyStatus(
id = CurrencyId.createTokenId("usdt", "0x1"),
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),
),
),
@ -120,17 +118,17 @@ internal class NetworkStatusDataModelConverterTest {
source = StatusSource.ACTUAL, // doesn't matter
),
),
expected = NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
expected = NoAccount(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "",
type = NetworkStatusDM.DerivationPath.Type.NONE,
type = DerivationPath.Type.NONE,
),
selectedAddress = "0x123",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x123",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
),
amountToCreateAccount = BigDecimal.ONE,

View file

@ -2,82 +2,77 @@ package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.currency.CryptoCurrency.ID.Body
import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class NetworkYieldSupplyStatusConverterTest {
private val rawNetworkId = "ETH"
private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0")
private val derivationPathHashCode = "-1843072795"
private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath)
private val domainStatus = YieldSupplyStatus(
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal.ONE,
)
@Test
fun convert() {
// Arrange
val value = mapOf(
"coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
"coin⟨ETH→12367123⟩ethereum" to null,
val value = listOf(
createDataStatus(id = CurrencyId.createCoinId("ethereum")),
createDataStatus(id = CurrencyId.createTokenId("usdt", "0x1")),
)
// Act
val actual = NetworkYieldSupplyStatusConverter.convert(value)
val actual = converter.convert(value)
// Assert
val expected = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "ETH"),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to null,
ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
)
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).containsExactlyEntriesIn(expected)
}
@Test
fun convertBack() {
// Arrange
val value = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "ETH"),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to null,
ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus,
ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus,
ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdc⚓0x1") to null,
)
// Act
val actual = NetworkYieldSupplyStatusConverter.convertBack(value)
val actual = converter.convertBack(value)
// Assert
val expected = mapOf(
"coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
),
val expected = listOf(
createDataStatus(id = CurrencyId.createCoinId("ethereum")),
createDataStatus(id = CurrencyId.createTokenId("usdt", "0x1")),
)
Truth.assertThat(actual).isEqualTo(expected)
Truth.assertThat(actual).containsExactlyElementsIn(expected)
}
private fun createDataStatus(id: CurrencyId): NetworkStatusDM.YieldSupplyStatus {
return NetworkStatusDM.YieldSupplyStatus(
id = id,
isActive = true,
isInitialized = true,
isAllowedToSpend = true,
effectiveProtocolBalance = BigDecimal.ONE,
)
}
}

View file

@ -2,12 +2,12 @@ package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.datasource.local.network.entity.NetworkStatusDM.*
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency.ID
import com.tangem.domain.models.currency.CryptoCurrency.ID.Body
import com.tangem.domain.models.currency.CryptoCurrency.ID.Prefix
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
@ -15,7 +15,6 @@ import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import java.math.BigDecimal
/**
@ -27,7 +26,7 @@ internal class SimpleNetworkStatusConverterTest {
private val network: Network = MockCryptoCurrencyFactory().ethereum.network
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = runCatching { SimpleNetworkStatusConverter.convert(value = model.value) }
@ -48,34 +47,29 @@ internal class SimpleNetworkStatusConverterTest {
private fun provideTestModels() = listOf(
// region Verified
ConvertModel(
value = NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = Verified(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(
NetworkStatusDM.Address(
value = "0x1",
type = NetworkStatusDM.Address.Type.Primary,
),
NetworkStatusDM.Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Secondary,
),
Address(value = "0x1", type = Address.Type.Primary),
Address(value = "0x2", type = Address.Type.Secondary),
),
amounts = mapOf(
"coin⟨BCH⟩bitcoin-cash" to BigDecimal.ZERO,
"coin⟨ETH→12367123⟩ethereum" to BigDecimal.ONE,
amounts = listOf(
CurrencyAmount(CurrencyId.createCoinId("ethereum"), BigDecimal.ZERO),
CurrencyAmount(CurrencyId.createTokenId("usdt", "0x1"), BigDecimal.ZERO),
),
yieldSupplyStatuses = mapOf(
"coin⟨ETH⟩ethereum" to NetworkStatusDM.YieldSupplyStatus(
yieldSupplyStatuses = listOf(
YieldSupplyStatus(
id = CurrencyId.createCoinId("ethereum"),
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),
"coin⟨ETH⟩ethereum" to null,
),
),
expected = SimpleNetworkStatus(
@ -101,33 +95,17 @@ internal class SimpleNetworkStatusConverterTest {
),
),
amounts = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "BCH"),
suffix = ID.Suffix.RawID(rawId = "bitcoin-cash"),
) to Amount.Loaded(value = BigDecimal.ZERO),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkIdWithDerivationPath(rawId = "ETH", derivationPathHashCode = 12367123),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to Amount.Loaded(value = BigDecimal.ONE),
ID.fromValue("coin⟨ETH→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO),
ID.fromValue("token⟨ETH→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO),
),
pendingTransactions = emptyMap(),
yieldSupplyStatuses = mapOf(
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "ETH"),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to YieldSupplyStatus(
ID.fromValue("coin⟨ETH→3046160⟩ethereum") to YieldSupplyStatus(
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),
ID(
prefix = Prefix.COIN_PREFIX,
body = Body.NetworkId(rawId = "ETH"),
suffix = ID.Suffix.RawID(rawId = "ethereum"),
) to null,
),
source = StatusSource.CACHE,
),
@ -137,21 +115,21 @@ internal class SimpleNetworkStatusConverterTest {
// region NoAccount
ConvertModel(
value = NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = NoAccount(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x1",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
NetworkStatusDM.Address(
Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Secondary,
type = Address.Type.Secondary,
),
),
amountToCreateAccount = BigDecimal.ONE,
@ -189,83 +167,83 @@ internal class SimpleNetworkStatusConverterTest {
// region Error
ConvertModel(
value = NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = Verified(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
),
amounts = emptyMap(),
yieldSupplyStatuses = emptyMap(),
amounts = emptyList(),
yieldSupplyStatuses = emptyList(),
),
expected = Result.failure(
exception = IllegalArgumentException("Selected address must not be null"),
),
),
ConvertModel(
value = NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = Verified(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(),
amounts = emptyMap(),
yieldSupplyStatuses = emptyMap(),
amounts = emptyList(),
yieldSupplyStatuses = emptyList(),
),
expected = Result.failure(
exception = IllegalArgumentException("Selected address must not be null"),
),
),
ConvertModel(
value = NetworkStatusDM.Verified(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = Verified(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x1",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
NetworkStatusDM.Address(
Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Secondary,
type = Address.Type.Secondary,
),
),
amounts = emptyMap(),
yieldSupplyStatuses = emptyMap(),
amounts = emptyList(),
yieldSupplyStatuses = emptyList(),
),
expected = Result.failure(
exception = IllegalArgumentException("Selected address must not be null"),
),
),
ConvertModel(
value = NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = NoAccount(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x1",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
NetworkStatusDM.Address(
Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Secondary,
type = Address.Type.Secondary,
),
),
amountToCreateAccount = BigDecimal.ONE,
@ -276,17 +254,17 @@ internal class SimpleNetworkStatusConverterTest {
),
),
ConvertModel(
value = NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = NoAccount(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(
NetworkStatusDM.Address(
Address(
value = "0x2",
type = NetworkStatusDM.Address.Type.Primary,
type = Address.Type.Primary,
),
),
amountToCreateAccount = BigDecimal.ONE,
@ -297,11 +275,11 @@ internal class SimpleNetworkStatusConverterTest {
),
),
ConvertModel(
value = NetworkStatusDM.NoAccount(
networkId = NetworkStatusDM.ID(network.rawId),
derivationPath = NetworkStatusDM.DerivationPath(
value = NoAccount(
networkId = ID(network.rawId),
derivationPath = DerivationPath(
value = "card",
type = NetworkStatusDM.DerivationPath.Type.CARD,
type = DerivationPath.Type.CARD,
),
selectedAddress = "0x1",
availableAddresses = setOf(),

View file

@ -11,6 +11,7 @@ import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -23,6 +24,7 @@ internal class GetTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -30,6 +30,7 @@ internal class InitializationTest {
every { persistenceStore.data } returns emptyFlow()
DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -44,6 +45,7 @@ internal class InitializationTest {
val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),
@ -66,6 +68,7 @@ internal class InitializationTest {
}
DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -27,6 +28,7 @@ internal class ParameterizedStoreStatusTest(private val model: Model) {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -27,6 +28,7 @@ internal class ParameterizedStoreSuccessTest(private val model: Model) {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -26,6 +27,7 @@ internal class ParameterizedStoreTest(private val model: Model) {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -24,6 +25,7 @@ internal class SetSourceAsCacheTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -11,6 +11,7 @@ import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -24,6 +25,7 @@ internal class SetSourceAsOnlyCacheTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -11,6 +11,7 @@ import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -24,6 +25,7 @@ internal class StoreStatusTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -23,6 +24,7 @@ internal class StoreSuccessTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -10,6 +10,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -23,6 +24,7 @@ internal class StoreTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -14,6 +14,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -27,6 +28,7 @@ internal class UpdateStatusSourceTest {
private val persistenceStore = MockStateDataStore<WalletIdWithStatusDM>(default = emptyMap())
private val store = DefaultNetworksStatusesStore(
context = mockk(),
runtimeStore = runtimeStore,
persistenceDataStore = persistenceStore,
dispatchers = TestingCoroutineDispatcherProvider(),

View file

@ -0,0 +1,101 @@
package com.tangem.data.networks.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultNetworksCleanerTest {
private val networksStatusesStore = mockk<NetworksStatusesStore>(relaxed = true)
private val walletManagersFacade = mockk<WalletManagersFacade>(relaxed = true)
private val cleaner = DefaultNetworksCleaner(
networksStatusesStore = networksStatusesStore,
walletManagersFacade = walletManagersFacade,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId("011")
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val network = cryptoCurrencyFactory.ethereum.network
private val coin = cryptoCurrencyFactory.ethereum
private val token = cryptoCurrencyFactory.createToken(Blockchain.Ethereum)
@BeforeEach
fun setUp() {
clearMocks(networksStatusesStore, walletManagersFacade)
}
@Test
fun `should clear networks and remove managers and tokens when called`() = runTest {
// Arrange
val currencies = listOf(coin, token)
// Act
cleaner(userWalletId = userWalletId, currencies = currencies)
// Assert
coVerifyOrder {
networksStatusesStore.clear(userWalletId, setOf(network))
walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network))
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token))
}
}
@Test
fun `should handle empty currencies`() = runTest {
// Act
cleaner(userWalletId = userWalletId, currencies = emptyList())
// Assert
coVerifyOrder(inverse = true) {
networksStatusesStore.clear(userWalletId = any(), networks = any())
walletManagersFacade.remove(userWalletId = any(), networks = any())
walletManagersFacade.removeTokens(userWalletId = any(), tokens = any())
}
}
@Test
fun `should clear only networks when there are no tokens`() = runTest {
val currencies = listOf(coin)
cleaner(userWalletId = userWalletId, currencies = currencies)
coVerifyOrder {
networksStatusesStore.clear(userWalletId, setOf(network))
walletManagersFacade.remove(userWalletId = userWalletId, networks = setOf(network))
}
coVerifyOrder(inverse = true) {
walletManagersFacade.removeTokens(userWalletId = any(), tokens = any())
}
}
@Test
fun `should clear only tokens when there are no networks`() = runTest {
// Arrange
val currencies = listOf(token)
// Act
cleaner(userWalletId = userWalletId, currencies = currencies)
// Assert
coVerifyOrder {
walletManagersFacade.removeTokens(userWalletId = userWalletId, tokens = setOf(token))
}
coVerifyOrder(inverse = true) {
networksStatusesStore.clear(userWalletId = any(), networks = any())
walletManagersFacade.remove(userWalletId = any(), networks = any())
}
}
}

View file

@ -272,6 +272,7 @@ internal class NetworkStatusFactoryTest(private val model: Model) {
isActive = false,
isInitialized = false,
isAllowedToSpend = false,
effectiveProtocolBalance = BigDecimal.ONE,
),
),
),

View file

@ -2,11 +2,10 @@ package com.tangem.data.notifications
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMapSync
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.datasource.local.preferences.PreferencesKeys.getShouldShowAskNotificationPermissionViaBs
import com.tangem.datasource.local.preferences.utils.*
import com.tangem.domain.notifications.repository.NotificationsRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class DefaultNotificationsRepository @Inject constructor(
@ -17,6 +16,10 @@ class DefaultNotificationsRepository @Inject constructor(
return appPreferencesStore.getSyncOrDefault(PreferencesKeys.getShouldShowNotificationKey(key), true)
}
override fun getShouldShowNotification(key: String): Flow<Boolean> {
return appPreferencesStore.get(PreferencesKeys.getShouldShowNotificationKey(key), true)
}
override suspend fun setShouldShowNotifications(key: String, value: Boolean) {
appPreferencesStore.store(PreferencesKeys.getShouldShowNotificationKey(key), value)
}
@ -61,12 +64,22 @@ class DefaultNotificationsRepository @Inject constructor(
}
override suspend fun setNotificationsWasEnabledAutomatically(userWalletId: String) {
appPreferencesStore.editData {
it.setObjectMap(
appPreferencesStore.editData { preferences ->
preferences.setObjectMap(
key = PreferencesKeys.NOTIFICATIONS_AUTOMATICALLY_ENABLED_STATES_KEY,
value = it.getObjectMap<Boolean>(PreferencesKeys.NOTIFICATIONS_AUTOMATICALLY_ENABLED_STATES_KEY)
value = preferences.getObjectMap<Boolean>(
PreferencesKeys.NOTIFICATIONS_AUTOMATICALLY_ENABLED_STATES_KEY,
)
.plus(userWalletId to true),
)
}
}
override suspend fun shouldAskNotificationPermissionsViaBs(): Boolean {
return appPreferencesStore.getSyncOrDefault(getShouldShowAskNotificationPermissionViaBs(), false)
}
override suspend fun setShouldAskNotificationPermissionsViaBs(shouldAsk: Boolean) {
appPreferencesStore.store(key = getShouldShowAskNotificationPermissionViaBs(), value = shouldAsk)
}
}

View file

@ -23,7 +23,7 @@ internal class DefaultPushNotificationsRepository @Inject constructor(
) : PushNotificationsRepository {
override suspend fun createApplicationId(pushToken: String?): ApplicationId = withContext(dispatchers.io) {
tangemTechApi.createApplicationId(
val appId = tangemTechApi.createApplicationId(
NotificationApplicationCreateBody(
platform = appInfoProvider.platform.lowercase(),
device = appInfoProvider.device,
@ -33,7 +33,9 @@ internal class DefaultPushNotificationsRepository @Inject constructor(
version = appInfoProvider.appVersion,
pushToken = pushToken,
),
).getOrThrow().appId.let(::ApplicationId)
).getOrThrow().appId
ApplicationId(appId)
}
override suspend fun saveApplicationId(appId: ApplicationId) {

View file

@ -159,5 +159,8 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.KaspaTestnet -> null
Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> null
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> null
Blockchain.Quai, Blockchain.QuaiTestnet -> null
// Blockchain.Linea, Blockchain.LineaTestnet -> null
// Blockchain.ArbitrumNova -> null
}
}

View file

@ -50,16 +50,16 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
val result = QrResult(address = address)
extractParameters(withoutSchema)
.forEach {
when (it.key) {
.forEach { entry ->
when (entry.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = it.value.parseBigDecimalOrNull()
result.amount = entry.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
-> {
result.memo = URLDecoder.decode(it.value, "UTF-8")
result.memo = URLDecoder.decode(entry.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
@ -70,7 +70,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
result.address = entry.value
} else {
return QrResult()
}
@ -80,7 +80,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = it.value.parseBigDecimalOrNull()
result.amount = entry.value.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}

View file

@ -161,7 +161,7 @@ internal class DefaultMultiQuoteStatusFetcherTest {
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
val currenciesIds = setOf("BTC", "ETH")
val error = QuotesFetcher.Error.ApiOperationError(ApiResponseError.NetworkException)
val error = QuotesFetcher.Error.ApiOperationError(ApiResponseError.NetworkException())
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
@ -173,7 +173,9 @@ internal class DefaultMultiQuoteStatusFetcherTest {
val actual = fetcher(params)
// Assert
val expected = IllegalStateException("Cause: ApiOperationError(apiError=NetworkException)").left()
val expected = IllegalStateException(
"Cause: ApiOperationError(apiError=com.tangem.datasource.api.common.response.ApiResponseError\$NetworkException)",
).left()
assertEither(actual, expected)
coVerifyOrder {
@ -215,50 +217,6 @@ internal class DefaultMultiQuoteStatusFetcherTest {
}
}
@Test
fun `fetch successfully if not all quotes are fetched`() = runTest {
// Arrange
val params = MultiQuoteStatusFetcher.Params(currenciesIds = currenciesIds, appCurrencyId = null)
coEvery { appCurrencyResponseStore.getSyncOrNull() } returns usdAppCurrency
val currenciesIds = setOf("BTC", "ETH")
val response = QuotesResponse(
quotes = mapOf(
"BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
),
)
coEvery {
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
} returns response.right()
// Act
val actual = fetcher(params)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
val storedQuotes = QuotesResponse(
quotes = mapOf(
"BTC" to MockQuoteResponseFactory.createSinglePrice(value = BigDecimal.ONE),
"ETH" to QuotesResponse.Quote.EMPTY,
),
)
coVerifyOrder {
quotesStore.setSourceAsCache(currenciesIds = params.currenciesIds)
appCurrencyResponseStore.getSyncOrNull()
quotesFetcher.fetch(fiatCurrencyId = "usd", currenciesIds = currenciesIds, fields = fields)
quotesStore.store(values = storedQuotes.quotes)
}
coVerify(inverse = true) {
quotesStore.setSourceAsOnlyCache(currenciesIds = any())
}
}
private companion object {
val currenciesIds = setOf(

View file

@ -30,13 +30,22 @@ internal class DefaultAppRatingRepository(
override fun isReadyToShow(): Flow<Boolean> {
// TODO: [REDACTED_JIRA]
return combine(
appPreferencesStore.get(key = PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY, default = false),
appPreferencesStore.get(
flow = appPreferencesStore.get(
key = PreferencesKeys.USER_WAS_INTERACT_WITH_RATING_KEY,
default = false,
),
flow2 = appPreferencesStore.get(
key = PreferencesKeys.SHOW_RATING_DIALOG_AT_LAUNCH_COUNT_KEY,
default = FIRST_SHOWING_COUNT,
),
appPreferencesStore.get(key = PreferencesKeys.APP_LAUNCH_COUNT_KEY, default = DEFAULT_APP_LAUNCH_COUNT),
appPreferencesStore.get(key = PreferencesKeys.FUNDS_FOUND_DATE_KEY, default = FUNDS_FOUND_DATE_UNDEFINED),
flow3 = appPreferencesStore.get(
key = PreferencesKeys.APP_LAUNCH_COUNT_KEY,
default = DEFAULT_APP_LAUNCH_COUNT,
),
flow4 = appPreferencesStore.get(
key = PreferencesKeys.FUNDS_FOUND_DATE_KEY,
default = FUNDS_FOUND_DATE_UNDEFINED,
),
) { isInteracting, ratingShowingCount, appLaunchCount, fundsFoundDate ->
if (!isInteracting) {
val diff = Calendar.getInstance().timeInMillis - fundsFoundDate

View file

@ -7,6 +7,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.get
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
import com.tangem.domain.settings.repositories.SettingsRepository
import com.tangem.domain.settings.usercountry.models.GB_COUNTRY
@ -91,11 +92,16 @@ internal class DefaultSettingsRepository(
}
override suspend fun shouldSaveAccessCodes(): Boolean {
return appPreferencesStore.getSyncOrDefault(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, default = false)
return appPreferencesStore.getSyncOrNull(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY)?.not()
?: appPreferencesStore.getSyncOrDefault(
key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY,
default = false,
)
}
override suspend fun setShouldSaveAccessCodes(value: Boolean) {
appPreferencesStore.store(key = PreferencesKeys.SHOULD_SAVE_ACCESS_CODES_KEY, value = value)
appPreferencesStore.store(key = PreferencesKeys.REQUIRE_ACCESS_CODE_KEY, value = value.not())
}
override suspend fun incrementAppLaunchCounter() {

View file

@ -24,6 +24,7 @@ import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.request.*
import com.tangem.datasource.api.stakekit.models.response.EnabledYieldsResponse
import com.tangem.datasource.api.stakekit.models.response.model.NetworkTypeDTO
import com.tangem.datasource.api.stakekit.models.response.model.action.StakingActionStatusDTO
import com.tangem.datasource.api.stakekit.models.response.model.transaction.tron.TronStakeKitTransaction
@ -56,6 +57,8 @@ import com.tangem.lib.crypto.BlockchainUtils.isCardano
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.extensions.orZero
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import timber.log.Timber
@ -91,17 +94,20 @@ internal class DefaultStakingRepository(
override suspend fun fetchEnabledYields() {
withContext(dispatchers.io) {
when (val stakingTokensWithYields = stakeKitApi.getEnabledYields(preferredValidatorsOnly = false)) {
is ApiResponse.Success -> stakingYieldsStore.store(
stakingTokensWithYields.data.data.filter {
it.isAvailable == true
},
)
else -> {
stakingYieldsStore.store(emptyList())
throw (stakingTokensWithYields as ApiResponse.Error).cause
val yieldsResponses = getAvailableIntegrationsIds().map {
async { it.getYieldRequest() }
}.awaitAll()
val yields = yieldsResponses.flatMap { response ->
when (response) {
is ApiResponse.Success -> response.data.data.filter { yield -> yield.isAvailable == true }
else -> {
Timber.e("Error fetching enabled yields: ${(response as? ApiResponse.Error)?.cause}")
emptyList()
}
}
}
stakingYieldsStore.store(yields)
}
}
@ -151,6 +157,26 @@ internal class DefaultStakingRepository(
}
}
private suspend fun StakingIntegrationID.getYieldRequest(): ApiResponse<EnabledYieldsResponse> {
return when (this) {
is StakingIntegrationID.Coin -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
network = networkId,
)
is StakingIntegrationID.EthereumToken -> stakeKitApi.getEnabledYields(
preferredValidatorsOnly = false,
yieldId = value,
network = networkId,
)
}
}
private fun getAvailableIntegrationsIds(): List<StakingIntegrationID> {
return StakingIntegrationID.entries.filterNot {
it.blockchain == Blockchain.Cardano && !stakingFeatureToggles.isCardanoStakingEnabled
}
}
private fun NetworkTypeDTO.extractJsonName(): String {
return networkTypeAdapter.toJson(this).replace("\"", "")
}
@ -164,7 +190,11 @@ internal class DefaultStakingRepository(
val yield = getYield(cryptoCurrencyId, symbol)
StakingEntryInfo(
apr = requireNotNull(yield.preferredValidators.maxByOrNull { it.apr.orZero() }?.apr),
rewardInfo = requireNotNull(
yield
.preferredValidators
.maxByOrNull { it.rewardInfo?.rate.orZero() }?.rewardInfo,
),
rewardSchedule = yield.metadata.rewardSchedule,
tokenSymbol = yield.token.symbol,
)
@ -360,7 +390,8 @@ internal class DefaultStakingRepository(
)
val transaction = transactionConverter.convert(transactionResponse.getOrThrow())
val unsignedTransaction = transaction.unsignedTransaction ?: error("No unsigned transaction available")
val unsignedTransaction =
transaction.unsignedTransaction ?: error("No unsigned transaction available")
val transactionData = TransactionData.Compiled(
value = getTransactionDataType(networkId, unsignedTransaction),
fee = fee,
@ -469,7 +500,7 @@ internal class DefaultStakingRepository(
)
}
private fun getEnabledYields(): Flow<List<Yield>> {
override fun getEnabledYields(): Flow<List<Yield>> {
return stakingYieldsStore.get().map {
YieldConverter.convertListIgnoreErrors(
input = it,

View file

@ -10,7 +10,9 @@ import com.tangem.domain.staking.model.stakekit.Yield
import com.tangem.domain.staking.model.stakekit.Yield.Metadata.RewardSchedule
import com.tangem.domain.staking.model.stakekit.Yield.Validator.ValidatorStatus
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toImmutableList
import java.math.RoundingMode
internal object YieldConverter : Converter<YieldDTO, Yield> {
@ -21,7 +23,10 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
private val PARTNERS_NAMES = listOf("Meria")
private const val DIVIDE_SCALE = 8
override fun convert(value: YieldDTO): Yield {
val rewardType = convertRewardType(value.rewardType.asMandatory("rewardType"))
return Yield(
id = value.id.asMandatory("id"),
token = YieldTokenConverter.convert(value.token.asMandatory("token")),
@ -30,14 +35,14 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
status = convertStatus(value.status.asMandatory("status")),
apy = value.apy.asMandatory("apy"),
rewardRate = value.rewardRate.asMandatory("rewardRate"),
rewardType = convertRewardType(value.rewardType.asMandatory("rewardType")),
rewardType = rewardType,
metadata = convertMetadata(value.metadata.asMandatory("metadata")),
validators = value.validators.asMandatory("validators")
.asSequence()
.distinctBy { it.address }
.filter { it.status == ValidatorStatusDTO.ACTIVE }
.map { convertValidator(it) }
.sortedByDescending { it.apr }
.map { convertValidator(validatorDTO = it, rewardType = rewardType) }
.sortedByDescending { it.rewardInfo?.rate?.orZero() }
.sortedByDescending { it.isStrategicPartner }
.toImmutableList(),
isAvailable = value.isAvailable.asMandatory("isAvailable"),
@ -119,15 +124,16 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO): Yield.Validator {
private fun convertValidator(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.Validator {
val address = validatorDTO.address.asMandatory("address")
return Yield.Validator(
address = address,
status = convertValidatorStatus(validatorDTO.status.asMandatory("status")),
name = validatorDTO.name.asMandatory("name"),
image = validatorDTO.image,
website = validatorDTO.website,
apr = validatorDTO.apr,
rewardInfo = createRewardInfo(validatorDTO, rewardType),
commission = validatorDTO.commission,
stakedBalance = validatorDTO.stakedBalance,
votingPower = validatorDTO.votingPower,
@ -136,6 +142,32 @@ internal object YieldConverter : Converter<YieldDTO, Yield> {
)
}
private fun createRewardInfo(validatorDTO: YieldDTO.ValidatorDTO, rewardType: Yield.RewardType): Yield.RewardInfo? {
val aprOrApy = validatorDTO.apr
val commission = validatorDTO.commission
// gross = net / (1 - commission)
return try {
val netApy = aprOrApy
val grossAprOrApy = if (netApy != null && commission != null) {
val commissionFraction = commission.toBigDecimal()
if (commissionFraction < 1.toBigDecimal()) {
netApy.divide(
1.toBigDecimal() - commissionFraction,
DIVIDE_SCALE,
RoundingMode.HALF_UP,
)
} else {
netApy
}
} else {
netApy
}
grossAprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
} catch (_: Exception) {
aprOrApy?.let { Yield.RewardInfo(rate = it, type = rewardType) }
}
}
private fun convertRewardType(rewardTypeDTO: YieldDTO.RewardTypeDTO): Yield.RewardType {
return when (rewardTypeDTO) {
YieldDTO.RewardTypeDTO.APY -> Yield.RewardType.APY

View file

@ -10,6 +10,7 @@ import com.tangem.data.staking.DefaultStakingTransactionHashRepository
import com.tangem.data.staking.converters.error.StakeKitErrorConverter
import com.tangem.data.staking.store.YieldsBalancesStore
import com.tangem.data.staking.toggles.DefaultStakingFeatureToggles
import com.tangem.data.staking.utils.DefaultStakingCleaner
import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.error.StakeKitErrorResponse
import com.tangem.datasource.di.NetworkMoshi
@ -21,6 +22,7 @@ import com.tangem.domain.staking.repositories.StakingErrorResolver
import com.tangem.domain.staking.repositories.StakingRepository
import com.tangem.domain.staking.repositories.StakingTransactionHashRepository
import com.tangem.domain.staking.toggles.StakingFeatureToggles
import com.tangem.domain.staking.utils.StakingCleaner
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -102,4 +104,16 @@ internal object StakingDataModule {
fun provideFeatureToggles(featureTogglesManager: FeatureTogglesManager): StakingFeatureToggles {
return DefaultStakingFeatureToggles(featureTogglesManager)
}
@Provides
@Singleton
fun provideStakingCleaner(
yieldsBalancesStore: YieldsBalancesStore,
dispatchers: CoroutineDispatcherProvider,
): StakingCleaner {
return DefaultStakingCleaner(
yieldsBalancesStore = yieldsBalancesStore,
dispatchers = dispatchers,
)
}
}

Some files were not shown because too many files have changed in this diff Show more