Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-19 09:16:46 +03:00
commit 6437018fc8
1160 changed files with 35466 additions and 6540 deletions

View file

@ -15,9 +15,14 @@ tasks.withType<Test>().configureEach {
dependencies {
// region Project - Common
implementation(projects.common.ui) // It's needed for getting AccountName.DefaultMain value
// endregion
// region Project - Core
implementation(projects.core.datasource)
implementation(projects.core.configToggles)
implementation(projects.core.res)
api(projects.core.utils)
// endregion
@ -27,6 +32,7 @@ dependencies {
api(projects.domain.common)
api(projects.domain.models)
api(projects.domain.tokens)
api(projects.domain.wallets)
// endregion
// region Project - Data
@ -61,11 +67,8 @@ dependencies {
// endregion
// region Test
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
// endregion
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CanBeNonNullable:FetchWalletAccountsErrorHandler.kt$FetchWalletAccountsErrorHandler$savedAccountsResponse: GetWalletAccountsResponse?</ID>
<ID>MultilineLambdaItParameter:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository${ if (it is HttpException &amp;&amp; it.code == HttpException.Code.NOT_MODIFIED) { null } else { throw it } }</ID>
<ID>MultilineLambdaItParameter:FetchWalletAccountsErrorHandler.kt$FetchWalletAccountsErrorHandler${ it.copy( tokens = UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, tokens = it.tokens), ) }</ID>
<ID>MultilineLambdaItParameter:GetWalletAccountsResponseExt.kt${ enrichedTokensByAccountId[it].orEmpty().map { token -&gt; // Tokens from unexisting accounts should be copied to the main account token.copy(accountId = accountDTO.id) } }</ID>
<ID>NoNameShadowing:GetWalletAccountsResponseExt.kt$tokens</ID>
<ID>NullableToStringCall:AccountListCryptoCurrenciesProducer.kt$AccountListCryptoCurrenciesProducer$${this::class.simpleName}</ID>
<ID>NullableToStringCall:DefaultMultiWalletCryptoCurrenciesProducer.kt$DefaultMultiWalletCryptoCurrenciesProducer$${this::class.simpleName}</ID>
<ID>UnnecessaryLet:DefaultAccountsCRUDRepository.kt$DefaultAccountsCRUDRepository$let(AccountName::invoke)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -7,21 +7,28 @@ import javax.inject.Inject
/**
* Container for converter factories related to accounts.
*
* @property accountsListCF factory for creating an account list converter
* @property getWalletAccountsResponseCF factory for creating a wallet accounts response converter
* @property accountsListCF factory for creating an account list converter
* @property cryptoPortfolioCF factory for creating a crypto portfolio converter
* @property userWalletsStore store for accessing user wallet data
*
* @constructor Creates an instance of the container with injected factories.
*
[REDACTED_AUTHOR]
*/
internal class AccountConverterFactoryContainer @Inject constructor(
val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val getWalletAccountsResponseCF: GetWalletAccountsResponseConverter.Factory,
private val accountsListCF: AccountListConverter.Factory,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userWalletsStore: UserWalletsStore,
) {
fun createWalletAccountsResponseConverter(userWalletId: UserWalletId): GetWalletAccountsResponseConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)
return getWalletAccountsResponseCF.create(userWallet)
}
fun createAccountListConverter(userWalletId: UserWalletId): AccountListConverter {
val userWallet = userWalletsStore.getSyncStrict(key = userWalletId)

View file

@ -3,6 +3,8 @@ package com.tangem.data.account.converter
import arrow.core.getOrElse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.converter.Converter
import dagger.assisted.Assisted
@ -27,12 +29,15 @@ internal class AccountListConverter @AssistedInject constructor(
}
override fun convert(value: GetWalletAccountsResponse): AccountList {
val sortType = value.wallet.sort?.let(TokensSortTypeConverter::convert) ?: TokensSortType.NONE
val groupType = value.wallet.group?.let(TokensGroupTypeConverter::convert) ?: TokensGroupType.NONE
return AccountList(
userWalletId = userWallet.walletId,
accounts = value.accounts.map(cryptoPortfolioConverter::convert).toSet(),
accounts = value.accounts.map(cryptoPortfolioConverter::convert),
totalAccounts = value.wallet.totalAccounts,
sortType = TokensSortTypeConverter.convert(value.wallet.sort),
groupType = TokensGroupTypeConverter.convert(value.wallet.group),
sortType = sortType,
groupType = groupType,
)
.getOrElse {
error("Failed to convert GetWalletAccountsResponse to AccountList: $it")

View file

@ -36,6 +36,7 @@ internal class CryptoPortfolioConverter @AssistedInject constructor(
responseCryptoCurrenciesFactory.createCurrencies(
tokens = tokens,
userWallet = userWallet,
accountIndex = value.derivationIndex.toDerivationIndex(),
).toSet()
} else {
emptySet()

View file

@ -1,5 +1,6 @@
package com.tangem.data.account.di
import android.content.Context
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.featuretoggle.DefaultAccountsFeatureToggles
@ -10,9 +11,10 @@ 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.accounts.AccountTokenMigrationStore
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.repository.AccountsCRUDRepository
@ -21,6 +23,7 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -42,8 +45,8 @@ internal object AccountDataModule {
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userWalletsStore: UserWalletsStore,
userTokensSaver: UserTokensSaver,
eTagsStore: ETagsStore,
accountConverterFactoryContainer: AccountConverterFactoryContainer,
@ApplicationContext context: Context,
dispatchers: CoroutineDispatcherProvider,
): AccountsCRUDRepository {
return DefaultAccountsCRUDRepository(
@ -53,8 +56,9 @@ internal object AccountDataModule {
archivedAccountsStoreFactory = ArchivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
archivedAccountsETagStore = RuntimeStateStore(emptyMap()),
convertersContainer = accountConverterFactoryContainer,
resources = context.resources,
dispatchers = dispatchers,
)
}
@ -72,9 +76,11 @@ internal object AccountDataModule {
fun provideMainAccountTokensMigration(
accountsResponseStoreFactory: AccountsResponseStoreFactory,
userTokensSaver: UserTokensSaver,
accountTokenMigrationStore: AccountTokenMigrationStore,
): MainAccountTokensMigration {
return DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
accountTokenMigrationStore = accountTokenMigrationStore,
userTokensSaver = userTokensSaver,
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.account.di
import com.tangem.data.account.producer.DefaultSingleAccountProducer
import com.tangem.domain.account.producer.SingleAccountProducer
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SingleAccountProducerFactoryModule {
@Binds
@Singleton
fun bindSingleAccountProducerFactory(impl: DefaultSingleAccountProducer.Factory): SingleAccountProducer.Factory
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.account.di
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.supplier.SingleAccountSupplier
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 SingleAccountSupplierModule {
@Provides
@Singleton
fun provideSingleAccountSupplier(factory: SingleAccountProducer.Factory): SingleAccountSupplier {
return object : SingleAccountSupplier(
factory = factory,
keyCreator = { "single_account_${it.accountId.value}" },
) {}
}
}

View file

@ -4,7 +4,6 @@ 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
import com.tangem.data.common.account.WalletAccountsSaver
import com.tangem.data.common.api.safeApiCall
@ -19,6 +18,8 @@ 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.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.api.tangemTech.models.orDefault
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -51,18 +52,25 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) : WalletAccountsFetcher, WalletAccountsSaver {
override suspend fun fetch(userWalletId: UserWalletId) {
override suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse {
val savedAccountsResponse = getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
val accountsResponse = fetchWalletAccounts(userWalletId, savedAccountsResponse)
?: return
if (accountsResponse.accounts.isEmpty()) {
initializeAccounts(userWalletId, accountsResponse)
} else if (accountsResponse.unassignedTokens.isNotEmpty()) {
assignTokens(userWalletId, accountsResponse)
return when {
accountsResponse.accounts.isEmpty() -> {
initializeAccounts(userWalletId, accountsResponse)
}
accountsResponse.unassignedTokens.isNotEmpty() -> {
assignTokens(userWalletId, accountsResponse)
}
else -> accountsResponse
}
}
override suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse? {
return getAccountsResponseStore(userWalletId = userWalletId).getSyncOrNull()
}
override suspend fun store(userWalletId: UserWalletId, response: GetWalletAccountsResponse) {
val store = getAccountsResponseStore(userWalletId = userWalletId)
@ -115,7 +123,7 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
private suspend fun fetchWalletAccounts(
userWalletId: UserWalletId,
savedAccountsResponse: GetWalletAccountsResponse?,
): GetWalletAccountsResponse? {
): GetWalletAccountsResponse {
return safeApiCall(
call = {
val apiResponse = withContext(dispatchers.io) {
@ -145,33 +153,47 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
)
}
private suspend fun initializeAccounts(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
private suspend fun initializeAccounts(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val hasSavedTokens = accountsResponse.unassignedTokens.isNotEmpty()
val response = defaultWalletAccountsResponseFactory.create(
userWalletId = userWalletId,
userTokensResponse = UserTokensResponse(
group = accountsResponse.wallet.group,
sort = accountsResponse.wallet.sort,
tokens = accountsResponse.unassignedTokens,
),
userTokensResponse = if (hasSavedTokens) {
UserTokensResponse(
group = accountsResponse.wallet.group.orDefault(),
sort = accountsResponse.wallet.sort.orDefault(),
tokens = accountsResponse.unassignedTokens,
)
} else {
null
},
)
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
val syncedResponse = push(userWalletId = userWalletId, accounts = response.accounts)
store(userWalletId = userWalletId, response = response)
if (syncedResponse != null) {
store(userWalletId = userWalletId, response = syncedResponse)
}
push(userWalletId = userWalletId, accounts = response.accounts)
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
return response
}
private suspend fun assignTokens(userWalletId: UserWalletId, accountsResponse: GetWalletAccountsResponse) {
private suspend fun assignTokens(
userWalletId: UserWalletId,
accountsResponse: GetWalletAccountsResponse,
): GetWalletAccountsResponse {
val accountsResponseWithTokens = accountsResponse.assignTokens(userWalletId)
store(userWalletId = userWalletId, response = accountsResponseWithTokens)
userTokensSaver.push(
userTokensSaver.pushWithRetryer(
userWalletId = userWalletId,
response = accountsResponseWithTokens.toUserTokensResponse(),
)
return accountsResponseWithTokens
}
private suspend fun getETag(userWalletId: UserWalletId): String? {

View file

@ -1,35 +1,55 @@
package com.tangem.data.account.fetcher
import com.tangem.data.account.utils.DefaultWalletAccountsResponseFactory
import com.tangem.data.account.utils.toUserTokensResponse
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.UserTokensResponseAccountIdEnricher
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.ApiResponseError.HttpException.Code
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.WalletIdBody
import com.tangem.datasource.api.tangemTech.models.WalletIdBody.WalletType
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles errors that occur during the fetching of wallet accounts
*
* @property tangemTechApi API for network requests
* @property userTokensSaver saves user tokens to the storage
* @property userTokensResponseStore provides access to user token responses.
* @property defaultWalletAccountsResponseFactory creates [GetWalletAccountsResponse] from [UserTokensResponse]
* @property eTagsStore store for ETags to manage caching
* @property dispatchers dispatchers
*
* @see DefaultWalletAccountsFetcher
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class FetchWalletAccountsErrorHandler @Inject constructor(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
private val userTokensSaver: UserTokensSaver,
private val userTokensResponseStore: UserTokensResponseStore,
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory,
private val eTagsStore: ETagsStore,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
@ -49,23 +69,27 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
savedAccountsResponse: GetWalletAccountsResponse?,
pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse?,
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): GetWalletAccountsResponse? {
): 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 savedAccountsResponse
return requireNotNull(savedAccountsResponse) {
"Saved accounts response is null for wallet: $userWalletId"
}
}
var response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
userTokensSaver.push(userWalletId = userWalletId, response = userTokensResponse)
val updatedResponse = pushWalletAccounts(userWalletId, accountDTOs)
val eTag = createWallet(userWalletId)
if (updatedResponse != null) {
response = updatedResponse
if (eTag != null) {
eTagsStore.store(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts, value = eTag)
pushWalletAccounts(userWalletId, accountDTOs)
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
}
}
@ -90,4 +114,33 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
}
.also { userTokensResponseStore.clear(userWalletId) }
}
/**
* Creates a wallet on the server and returns the ETag if successful.
*
* @param userWalletId The ID of the user wallet to create.
*/
private suspend fun createWallet(userWalletId: UserWalletId): String? {
val userWallet = userWalletsStore.getSyncOrNull(key = userWalletId) ?: return null
val creationResponse = withContext(dispatchers.io) {
tangemTechApi.createWallet(
body = WalletIdBody(
walletId = userWalletId.stringValue,
name = userWallet.name,
walletType = when (userWallet) {
is UserWallet.Cold -> WalletType.COLD
is UserWallet.Hot -> WalletType.HOT
},
),
)
}
return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) {
creationResponse.headers[ETAG_HEADER]?.firstOrNull()
} else {
null
}
}
}

View file

@ -3,9 +3,9 @@ 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.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
@ -48,12 +48,16 @@ internal class AccountListCryptoCurrenciesProducer @AssistedInject constructor(
.map { response ->
if (response == null) return@map emptySet()
responseCryptoCurrenciesFactory.createCurrencies(
response = response.toUserTokensResponse(),
userWallet = userWallet,
).toSet()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty(),
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
}
.onEmpty { emit(emptySet()) }
.distinctUntilChanged()
.flowOn(dispatchers.default)
}

View file

@ -0,0 +1,55 @@
package com.tangem.data.account.producer
import arrow.core.Option
import arrow.core.none
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.account.producer.SingleAccountProducer
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapNotNull
/**
* Default implementation of [SingleAccountProducer] that produces a flow of [Account.CryptoPortfolio]
* for a single account identified by [SingleAccountProducer.Params.accountId].
*
* It uses [SingleAccountListSupplier] to get the list of accounts and filters it to find the
* specific account.
*
* @property params Parameters containing the account ID for which the portfolio is produced.
* @property singleAccountListSupplier Supplier to get the list of accounts.
* @property dispatchers Coroutine dispatcher provider for managing threading.
*/
internal class DefaultSingleAccountProducer @AssistedInject constructor(
@Assisted val params: SingleAccountProducer.Params,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val dispatchers: CoroutineDispatcherProvider,
) : SingleAccountProducer {
override val fallback: Option<Account.CryptoPortfolio>
get() = none()
override fun produce(): Flow<Account.CryptoPortfolio> {
return singleAccountListSupplier(
params = SingleAccountListProducer.Params(userWalletId = params.accountId.userWalletId),
)
.mapNotNull { accountList ->
accountList.accounts.firstOrNull {
it is Account.CryptoPortfolio && params.accountId == it.accountId
} as? Account.CryptoPortfolio
}
.distinctUntilChanged()
.flowOn(dispatchers.default)
}
@AssistedFactory
interface Factory : SingleAccountProducer.Factory {
override fun create(params: SingleAccountProducer.Params): DefaultSingleAccountProducer
}
}

View file

@ -1,21 +1,27 @@
package com.tangem.data.account.repository
import android.content.res.Resources
import arrow.core.Option
import arrow.core.raise.option
import arrow.core.toOption
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.core.res.getStringSafe
import com.tangem.data.account.converter.AccountConverterFactoryContainer
import com.tangem.data.account.converter.ArchivedAccountConverter
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.api.safeApiCall
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError.HttpException
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.models.AccountList
@ -23,6 +29,7 @@ import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -43,8 +50,9 @@ internal class DefaultAccountsCRUDRepository(
private val archivedAccountsStoreFactory: ArchivedAccountsStoreFactory,
private val userWalletsStore: UserWalletsStore,
private val userTokensSaver: UserTokensSaver,
private val eTagsStore: ETagsStore,
private val archivedAccountsETagStore: RuntimeStateStore<Map<String, String?>>,
private val convertersContainer: AccountConverterFactoryContainer,
private val resources: Resources,
private val dispatchers: CoroutineDispatcherProvider,
) : AccountsCRUDRepository {
@ -90,19 +98,49 @@ internal class DefaultAccountsCRUDRepository(
}
override suspend fun fetchArchivedAccounts(userWalletId: UserWalletId) {
val response = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(
walletId = userWalletId.stringValue,
eTag = getETag(userWalletId),
).getOrThrow()
}
val eTag = archivedAccountsETagStore.getSyncOrNull()?.get(key = userWalletId.stringValue)
val store = getArchivedAccountsStore(userWalletId = userWalletId)
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
val response = safeApiCall(
call = {
val apiResponse = withContext(dispatchers.io) {
tangemTechApi.getWalletArchivedAccounts(
walletId = userWalletId.stringValue,
eTag = eTag,
)
}
store.store(value = archivedAccounts)
saveETag(userWalletId, apiResponse)
apiResponse.bind()
},
onError = {
if (it is HttpException && it.code == HttpException.Code.NOT_MODIFIED) {
null
} else {
throw it
}
},
)
if (response != null) {
val converter = ArchivedAccountConverter(userWalletId = userWalletId)
val archivedAccounts = converter.convertList(input = response.accounts)
store.store(value = archivedAccounts)
}
}
override suspend fun saveAccountsLocally(accountList: AccountList) {
val converter = convertersContainer.createWalletAccountsResponseConverter(
userWalletId = accountList.userWalletId,
)
walletAccountsSaver.store(
userWalletId = accountList.userWalletId,
response = converter.convert(accountList),
)
}
override suspend fun saveAccounts(accountList: AccountList) {
@ -143,7 +181,7 @@ internal class DefaultAccountsCRUDRepository(
return
}
userTokensSaver.push(userWalletId = userWalletId, response = response.toUserTokensResponse())
userTokensSaver.pushWithRetryer(userWalletId = userWalletId, response = response.toUserTokensResponse())
}
override suspend fun getTotalAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
@ -154,9 +192,17 @@ internal class DefaultAccountsCRUDRepository(
return accountListResponse.wallet.totalAccounts.toOption()
}
override fun getTotalAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>> {
override suspend fun getTotalActiveAccountsCountSync(userWalletId: UserWalletId): Option<Int> = option {
val accountListResponse = getAccountsResponseSync(userWalletId = userWalletId)
ensureNotNull(accountListResponse)
return accountListResponse.accounts.size.toOption()
}
override fun getTotalActiveAccountsCount(userWalletId: UserWalletId): Flow<Option<Int>> {
return getAccountsResponseStore(userWalletId = userWalletId).data
.map { it?.wallet?.totalAccounts.toOption() }
.map { it?.accounts?.size.toOption() }
}
override fun getUserWallet(userWalletId: UserWalletId): UserWallet {
@ -167,8 +213,25 @@ internal class DefaultAccountsCRUDRepository(
override fun getUserWalletsSync(): List<UserWallet> = userWalletsStore.userWalletsSync
private suspend fun getETag(userWalletId: UserWalletId): String? {
return eTagsStore.getSyncOrNull(userWalletId = userWalletId, key = ETagsStore.Key.WalletAccounts)
override fun checkDefaultAccountName(accountList: AccountList, accountName: AccountName) {
val hasDefaultName = accountList.accounts.any { it.accountName is AccountName.DefaultMain }
if (!hasDefaultName) return
val defaultName = resources.getStringSafe(AccountNameUM.DefaultMain.stringResId)
.let(AccountName::invoke).getOrNull()
require(defaultName != accountName) {
"Cannot use default account name \"$accountName\" for custom accounts"
}
}
private suspend fun saveETag(userWalletId: UserWalletId, apiResponse: ApiResponse<*>) {
val eTag = apiResponse.headers[ETAG_HEADER]?.firstOrNull()
archivedAccountsETagStore.update {
it + (userWalletId.stringValue to eTag)
}
}
private suspend fun getAccountsResponseSync(userWalletId: UserWalletId): GetWalletAccountsResponse? {

View file

@ -9,11 +9,12 @@ 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.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.account.tokens.MainAccountTokensMigration
import com.tangem.domain.models.account.DerivationIndex
@ -32,6 +33,7 @@ import timber.log.Timber
*/
internal class DefaultMainAccountTokensMigration(
private val accountsResponseStoreFactory: AccountsResponseStoreFactory,
private val accountTokenMigrationStore: AccountTokenMigrationStore,
private val userTokensSaver: UserTokensSaver,
) : MainAccountTokensMigration {
@ -78,12 +80,19 @@ internal class DefaultMainAccountTokensMigration(
},
)
userTokensSaver.push(
store.updateData { updatedResponse }
val mainAccountName = mainAccount.name
val selectedAccountName = selectedAccount.name
if (mainAccountName != null && selectedAccountName != null) {
accountTokenMigrationStore.store(userWalletId, mainAccountName to selectedAccountName)
}
val userTokensResponse = updatedResponse.toUserTokensResponse()
userTokensSaver.pushWithRetryer(
userWalletId = userWalletId,
response = updatedResponse.toUserTokensResponse(),
response = userTokensResponse,
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)

View file

@ -1,14 +1,16 @@
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.data.common.network.NetworkFactory
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.userwallet.UserWalletsStore
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.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import javax.inject.Inject
@ -16,22 +18,22 @@ import javax.inject.Inject
/**
* Factory to create default [GetWalletAccountsResponse].
*
* @property userWalletsListRepository repository to get user wallet information
* @property userWalletsStore store 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
* @property networkFactory factory to create network derivation path
*
[REDACTED_AUTHOR]
*/
internal class DefaultWalletAccountsResponseFactory @Inject constructor(
private val userWalletsListRepository: UserWalletsListRepository,
private val userWalletsStore: UserWalletsStore,
private val cryptoPortfolioCF: CryptoPortfolioConverter.Factory,
private val userTokensResponseFactory: UserTokensResponseFactory,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val networkFactory: NetworkFactory,
) {
suspend fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsListRepository.userWalletsSync().firstOrNull { it.walletId == userWalletId }
fun create(userWalletId: UserWalletId, userTokensResponse: UserTokensResponse?): GetWalletAccountsResponse {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId)
val accountDTOs = userWallet?.let(::createDefaultAccountDTOs).orEmpty()
val response = userTokensResponse.orDefault(userWallet = userWallet)
@ -59,10 +61,12 @@ internal class DefaultWalletAccountsResponseFactory @Inject constructor(
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,
return userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = userWallet?.let {
AccountId.forCryptoPortfolio(userWalletId = it.walletId, derivationIndex = DerivationIndex.Main)
},
)
}
}

View file

@ -6,20 +6,6 @@ import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResp
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.models.wallet.UserWalletId
/** Flattens the tokens from all wallet accounts into a single list */
internal fun GetWalletAccountsResponse.flattenTokens(): List<UserTokensResponse.Token> {
return accounts.flatMap { it.tokens.orEmpty() }
}
/** Converts the [GetWalletAccountsResponse] into a [UserTokensResponse] */
internal fun GetWalletAccountsResponse.toUserTokensResponse(): UserTokensResponse {
return UserTokensResponse(
group = wallet.group,
sort = wallet.sort,
tokens = flattenTokens(),
)
}
/**
* Assigns tokens from a [UserTokensResponse] to the wallet accounts in the [GetWalletAccountsResponse]
*
@ -46,13 +32,30 @@ internal fun List<WalletAccountDTO>.assignTokens(
userWalletId: UserWalletId,
tokens: List<UserTokensResponse.Token>,
): List<WalletAccountDTO> {
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
val enrichedTokensByAccountId = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.groupBy { it.accountId }
return map { accountDTO ->
accountDTO.copy(
tokens = enrichedTokens[accountDTO.id].orEmpty(),
)
val accountTokens = enrichedTokensByAccountId[accountDTO.id].orEmpty()
val isMainAccount = accountDTO.derivationIndex == 0
val tokens = if (isMainAccount) {
val existingAccountIds = map(WalletAccountDTO::id).toSet()
val unexistingAccountIds = enrichedTokensByAccountId.keys - existingAccountIds
val customTokens = unexistingAccountIds.flatMap {
enrichedTokensByAccountId[it].orEmpty().map { token ->
// Tokens from unexisting accounts should be copied to the main account
token.copy(accountId = accountDTO.id)
}
}
accountTokens + customTokens
} else {
accountTokens
}
accountDTO.copy(tokens = accountDTO.tokens.orEmpty() + tokens)
}
}
@ -63,5 +66,5 @@ internal fun WalletAccountDTO.assignTokens(
val enrichedTokens = UserTokensResponseAccountIdEnricher(userWalletId, tokens)
.filter { it.accountId == this.id }
return copy(tokens = enrichedTokens)
return copy(tokens = this.tokens.orEmpty() + enrichedTokens)
}

View file

@ -77,7 +77,7 @@ internal fun createAccountList(
): AccountList {
return AccountList(
userWalletId = userWalletId,
accounts = setOf(createCryptoPortfolio(userWalletId)),
accounts = listOf(createCryptoPortfolio(userWalletId)),
totalAccounts = 1,
sortType = sortType,
groupType = groupType,

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
@ -10,6 +9,7 @@ import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk

View file

@ -1,8 +1,8 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.domain.models.account.AccountName
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.AccountId
@ -9,6 +8,7 @@ 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.UserWalletId
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
@ -10,6 +9,7 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk

View file

@ -1,9 +1,9 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.account.converter.CryptoPortfolioIconConverter.DataModel
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.account.models.AccountList
@ -10,6 +9,7 @@ import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk

View file

@ -2,8 +2,6 @@ package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.datasource.api.tangemTech.models.account.SaveWalletAccountsResponse
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.UserWalletId
import org.junit.jupiter.api.Test
@ -16,13 +14,7 @@ class SaveWalletAccountsResponseConverterTest {
fun convert() {
// Arrange
val userWalletId = UserWalletId("011")
val accountList = AccountList(
userWalletId = userWalletId,
accounts = setOf(Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId)),
totalAccounts = 1,
)
.getOrNull()!!
val accountList = createAccountList(userWalletId)
// Act
val actual = SaveWalletAccountsResponseConverter.convert(value = accountList)

View file

@ -1,9 +1,9 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensGroupType
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -1,9 +1,9 @@
package com.tangem.data.account.converter
import com.google.common.truth.Truth
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.models.TokensSortType
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -2,13 +2,13 @@ package com.tangem.data.account.fetcher
import arrow.core.left
import arrow.core.right
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.fetcher.MultiAccountListFetcher
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach

View file

@ -1,10 +1,10 @@
package com.tangem.data.account.fetcher
import com.tangem.common.test.utils.assertEitherLeft
import com.tangem.common.test.utils.assertEitherRight
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.domain.account.fetcher.SingleAccountListFetcher
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
@ -29,6 +29,8 @@ class DefaultSingleAccountListFetcherTest {
// Arrange
val params = SingleAccountListFetcher.Params(userWalletId = userWalletId)
coEvery { walletAccountsFetcher.fetch(userWalletId) } returns mockk()
// Act
val actual = fetcher.invoke(params)

View file

@ -199,7 +199,7 @@ class DefaultWalletAccountsFetcherTest {
@Test
fun `fetch should call error handler when getWalletAccounts returns error`() = runTest {
// Arrange
val savedAccountsResponse = null
val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val apiError = ApiResponse.Error(ApiResponseError.NetworkException())
accountsResponseStoreFlow.value = savedAccountsResponse
@ -212,7 +212,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)
@ -231,7 +231,7 @@ class DefaultWalletAccountsFetcherTest {
fetchWalletAccountsErrorHandler.handle(
error = apiError.cause,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = savedAccountsResponse,
pushWalletAccounts = any(),
storeWalletAccounts = any(),
)

View file

@ -3,19 +3,24 @@ package com.tangem.data.account.fetcher
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.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.ApiResponseError.HttpException.Code
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.WalletIdBody
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -27,14 +32,21 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class FetchWalletAccountsErrorHandlerTest {
private val tangemTechApi: TangemTechApi = mockk()
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensSaver: UserTokensSaver = mockk(relaxUnitFun = true)
private val userTokensResponseStore: UserTokensResponseStore = mockk(relaxUnitFun = true)
private val defaultWalletAccountsResponseFactory: DefaultWalletAccountsResponseFactory = mockk()
private val eTagsStore: ETagsStore = mockk(relaxUnitFun = true)
private val handler = FetchWalletAccountsErrorHandler(
tangemTechApi = tangemTechApi,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
userTokensResponseStore = userTokensResponseStore,
defaultWalletAccountsResponseFactory = defaultWalletAccountsResponseFactory,
eTagsStore = eTagsStore,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val pushWalletAccounts: suspend (UserWalletId, List<WalletAccountDTO>) -> GetWalletAccountsResponse =
@ -53,6 +65,8 @@ class FetchWalletAccountsErrorHandlerTest {
@Test
fun `does not update accounts when response is up to date`() = runTest {
// Arrange
val response = createGetWalletAccountsResponse(userWalletId)
val error = ApiResponseError.HttpException(
code = Code.NOT_MODIFIED,
message = "Not Modified",
@ -63,7 +77,7 @@ class FetchWalletAccountsErrorHandlerTest {
handler.handle(
error = error,
userWalletId = userWalletId,
savedAccountsResponse = null,
savedAccountsResponse = response,
pushWalletAccounts = pushWalletAccounts,
storeWalletAccounts = storeWalletAccounts,
)
@ -73,7 +87,7 @@ class FetchWalletAccountsErrorHandlerTest {
userTokensResponseStore.getSyncOrNull(userWalletId = any())
defaultWalletAccountsResponseFactory.create(userWalletId = any(), userTokensResponse = any())
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any())
storeWalletAccounts(any(), any())
}
}
@ -91,6 +105,29 @@ class FetchWalletAccountsErrorHandlerTest {
val savedAccountsResponse = createGetWalletAccountsResponse(userWalletId)
val eTagValue = "etag-value"
val apiResponse = ApiResponse.Success(
data = Unit,
headers = mapOf(ETAG_HEADER to listOf(eTagValue)),
code = Code.CREATED,
)
val walletName = "Wallet"
val userWallet = mockk<UserWallet.Cold> {
every { this@mockk.walletId } returns userWalletId
every { this@mockk.name } returns walletName
}
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
coEvery {
tangemTechApi.createWallet(
WalletIdBody(
walletId = userWalletId.stringValue,
name = walletName,
walletType = WalletIdBody.WalletType.COLD,
),
)
} returns apiResponse
coEvery { pushWalletAccounts(userWalletId, listOf(accountDTO)) } returns savedAccountsResponse
// Act
@ -104,7 +141,15 @@ class FetchWalletAccountsErrorHandlerTest {
// Assert
coVerify {
userTokensSaver.push(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
userTokensSaver.pushWithRetryer(userWalletId, response = savedAccountsResponse.toUserTokensResponse())
tangemTechApi.createWallet(
WalletIdBody(
walletId = userWalletId.stringValue,
name = walletName,
walletType = WalletIdBody.WalletType.COLD,
),
)
eTagsStore.store(userWalletId, ETagsStore.Key.WalletAccounts, eTagValue)
pushWalletAccounts(userWalletId, listOf(accountDTO))
storeWalletAccounts(userWalletId, savedAccountsResponse)
}
@ -166,7 +211,7 @@ class FetchWalletAccountsErrorHandlerTest {
coVerify(inverse = true) {
pushWalletAccounts(any(), any())
userTokensSaver.push(userWalletId = any(), response = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any())
}
}

View file

@ -1,12 +1,12 @@
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.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -5,7 +5,6 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
@ -15,6 +14,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*

View file

@ -1,12 +1,12 @@
package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.producer.SingleAccountListProducer
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow

View file

@ -3,7 +3,6 @@ package com.tangem.data.account.producer
import com.google.common.truth.Truth
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.account.converter.AccountListConverter
import com.tangem.data.account.converter.createGetWalletAccountsResponse
import com.tangem.data.account.store.AccountsResponseStore
@ -15,6 +14,7 @@ import com.tangem.domain.account.models.AccountList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.test.core.getEmittedValues
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest

View file

@ -1,16 +1,15 @@
package com.tangem.data.account.repository
import android.content.res.Resources
import arrow.core.None
import arrow.core.toOption
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.account.converter.*
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.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
@ -27,6 +26,7 @@ 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.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
@ -54,12 +54,14 @@ class DefaultAccountsCRUDRepositoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensSaver: UserTokensSaver = mockk()
private val eTagsStore: ETagsStore = mockk()
private val archivedAccountsETagStore: RuntimeStateStore<Map<String, String?>> = mockk(relaxUnitFun = true)
private val convertersContainer: AccountConverterFactoryContainer = mockk()
private val accountListConverter: AccountListConverter = mockk()
private val cryptoPortfolioConverter: CryptoPortfolioConverter = mockk()
private val resources: Resources = mockk()
private val repository = DefaultAccountsCRUDRepository(
tangemTechApi = tangemTechApi,
walletAccountsSaver = walletAccountsSaver,
@ -67,8 +69,9 @@ class DefaultAccountsCRUDRepositoryTest {
archivedAccountsStoreFactory = archivedAccountsStoreFactory,
userWalletsStore = userWalletsStore,
userTokensSaver = userTokensSaver,
eTagsStore = eTagsStore,
archivedAccountsETagStore = archivedAccountsETagStore,
convertersContainer = convertersContainer,
resources = resources,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@ -532,7 +535,7 @@ class DefaultAccountsCRUDRepositoryTest {
val archivedAccount = ArchivedAccountConverter(userWalletId).convert(accountDTO)
coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag
coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag)
coEvery {
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
@ -546,9 +549,10 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(actual).containsExactly(archivedAccount)
coVerifyOrder {
eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts)
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
archivedAccountsETagStore.getSyncOrNull()
archivedAccountsStoreFactory.create(userWalletId)
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
archivedAccountsETagStore.update(any())
}
}
@ -558,7 +562,7 @@ class DefaultAccountsCRUDRepositoryTest {
val eTag = "etag123"
val exception = Exception("API error")
coEvery { eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts) } returns eTag
coEvery { archivedAccountsETagStore.getSyncOrNull() } returns mapOf(userWalletId.stringValue to eTag)
coEvery { tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag) } throws exception
// Act
@ -570,7 +574,7 @@ class DefaultAccountsCRUDRepositoryTest {
Truth.assertThat(archivedAccountsStore.getSyncOrNull()).isNull()
coVerifyOrder {
eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.WalletAccounts)
archivedAccountsETagStore.getSyncOrNull()
tangemTechApi.getWalletArchivedAccounts(userWalletId.stringValue, eTag)
}
}
@ -644,15 +648,15 @@ class DefaultAccountsCRUDRepositoryTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCountSync {
inner class GetTotalActiveAccountsCountSync {
@Test
fun `getTotalAccountsCountSync returns None if account list response is null`() = runTest {
fun `getTotalActiveAccountsCountSync returns None if account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
val actual = repository.getTotalActiveAccountsCountSync(userWalletId)
// Assert
Truth.assertThat(actual).isEqualTo(None)
@ -664,20 +668,22 @@ class DefaultAccountsCRUDRepositoryTest {
}
@Test
fun `getTotalAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest {
fun `getTotalActiveAccountsCountSync returns Some with totalAccounts when response is valid`() = runTest {
// Arrange
val totalAccounts = 5
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
val response = createGetWalletAccountsResponse(userWalletId).let {
it.copy(
wallet = it.wallet.copy(totalAccounts = totalAccounts),
)
}
accountsResponseStoreFlow.value = response
// Act
val actual = repository.getTotalAccountsCountSync(userWalletId)
val actual = repository.getTotalActiveAccountsCountSync(userWalletId)
// Assert
val expected = totalAccounts.toOption()
val expected = 1.toOption()
Truth.assertThat(actual).isEqualTo(expected)
verifyOrder {
@ -689,15 +695,15 @@ class DefaultAccountsCRUDRepositoryTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetTotalAccountsCount {
inner class GetTotalActiveAccountsCount {
@Test
fun `getTotalAccountsCount emits 0 when account list response is null`() = runTest {
fun `getTotalActiveAccountsCount emits 0 when account list response is null`() = runTest {
// Arrange
accountsResponseStoreFlow.value = null
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val flow = repository.getTotalActiveAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
@ -710,21 +716,23 @@ class DefaultAccountsCRUDRepositoryTest {
}
@Test
fun `getTotalAccountsCount emits correct value when response is valid`() = runTest {
fun `getTotalActiveAccountsCount emits correct value when response is valid`() = runTest {
// Arrange
val totalAccounts = 7
val response = mockk<GetWalletAccountsResponse> {
every { this@mockk.wallet.totalAccounts } returns totalAccounts
val response = createGetWalletAccountsResponse(userWalletId).let {
it.copy(
wallet = it.wallet.copy(totalAccounts = totalAccounts),
)
}
accountsResponseStoreFlow.value = response
// Act
val flow = repository.getTotalAccountsCount(userWalletId)
val flow = repository.getTotalActiveAccountsCount(userWalletId)
val actual = getEmittedValues(flow)
// Assert
Truth.assertThat(actual).containsExactly(totalAccounts.toOption())
Truth.assertThat(actual).containsExactly(1.toOption())
verifyOrder {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data

View file

@ -1,7 +1,6 @@
package com.tangem.data.account.store
import com.google.common.truth.Truth
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.domain.account.models.ArchivedAccount
import com.tangem.domain.models.account.AccountId
@ -9,6 +8,7 @@ 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.UserWalletId
import com.tangem.test.core.getEmittedValues
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested

View file

@ -1,19 +1,20 @@
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.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.datasource.local.accounts.AccountTokenMigrationStore
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight
import io.mockk.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@ -29,10 +30,12 @@ class DefaultMainAccountTokensMigrationTest {
private val accountsResponseStoreFactory = mockk<AccountsResponseStoreFactory>()
private val accountsResponseStore = mockk<AccountsResponseStore>()
private val accountsResponseStoreFlow = MutableStateFlow<GetWalletAccountsResponse?>(value = null)
private val accountTokenMigrationStore = mockk<AccountTokenMigrationStore>(relaxUnitFun = true)
private val userTokensSaver = mockk<UserTokensSaver>(relaxUnitFun = true)
private val userTokensSaver = mockk<UserTokensSaver>(relaxed = true)
private val migration = DefaultMainAccountTokensMigration(
accountsResponseStoreFactory = accountsResponseStoreFactory,
accountTokenMigrationStore = accountTokenMigrationStore,
userTokensSaver = userTokensSaver,
)
@ -62,7 +65,7 @@ class DefaultMainAccountTokensMigrationTest {
coVerify(inverse = true) {
accountsResponseStoreFactory.create(any())
accountsResponseStore.data
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
}
}
@ -81,7 +84,7 @@ class DefaultMainAccountTokensMigrationTest {
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
}
}
@ -110,7 +113,7 @@ class DefaultMainAccountTokensMigrationTest {
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
}
}
@ -145,7 +148,7 @@ class DefaultMainAccountTokensMigrationTest {
}
coVerify(inverse = true) {
userTokensSaver.push(userWalletId = any(), response = any(), onFailSend = any())
userTokensSaver.pushWithRetryer(userWalletId = any(), response = any(), onFailSend = any())
}
}
@ -183,6 +186,8 @@ class DefaultMainAccountTokensMigrationTest {
accountsResponseStoreFlow.value = response
coEvery { accountsResponseStore.updateData(any()) } returns mockk()
// Act
val actual = migration.migrate(userWalletId, derivationIndex)
@ -199,7 +204,8 @@ class DefaultMainAccountTokensMigrationTest {
coVerifySequence {
accountsResponseStoreFactory.create(userWalletId)
accountsResponseStore.data
userTokensSaver.push(
accountsResponseStore.updateData(any())
userTokensSaver.pushWithRetryer(
userWalletId = userWalletId,
response = migratedResponse.toUserTokensResponse(),
onFailSend = any(),

View file

@ -3,14 +3,14 @@ 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.account.utils.GetWalletAccountsResponseExtTest.Companion.createUserToken
import com.tangem.data.common.currency.UserTokensResponseFactory
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
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.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.*
@ -23,17 +23,17 @@ import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultWalletAccountsResponseFactoryTest {
private val userWalletsListRepository = mockk<UserWalletsListRepository>()
private val userWalletsStore = mockk<UserWalletsStore>()
private val cryptoPortfolioCF = mockk<CryptoPortfolioConverter.Factory>()
private val cryptoPortfolioConverter = mockk<CryptoPortfolioConverter>()
private val userTokensResponseFactory = mockk<UserTokensResponseFactory>()
private val cardCryptoCurrencyFactory = mockk<CardCryptoCurrencyFactory>()
private val networkFactory = mockk<NetworkFactory>()
private val factory = DefaultWalletAccountsResponseFactory(
userWalletsListRepository = userWalletsListRepository,
userWalletsStore = userWalletsStore,
cryptoPortfolioCF = cryptoPortfolioCF,
userTokensResponseFactory = userTokensResponseFactory,
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
networkFactory = networkFactory,
)
private val userWalletId = UserWalletId("011")
@ -46,11 +46,11 @@ class DefaultWalletAccountsResponseFactoryTest {
@AfterEach
fun tearDownEach() {
clearMocks(
userWalletsListRepository,
userWalletsStore,
cryptoPortfolioCF,
cryptoPortfolioConverter,
userTokensResponseFactory,
cardCryptoCurrencyFactory,
networkFactory,
)
}
@ -63,12 +63,12 @@ class DefaultWalletAccountsResponseFactoryTest {
tokens = emptyList(),
)
coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList()
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns null
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = null,
networkFactory = networkFactory,
accountId = null,
)
} returns userTokensResponse
@ -88,11 +88,11 @@ class DefaultWalletAccountsResponseFactoryTest {
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userWalletsStore.getSyncOrNull(userWalletId)
userTokensResponseFactory.createDefaultResponse(
userWallet = null,
networkFactory = networkFactory,
accountId = null,
)
}
}
@ -104,27 +104,25 @@ class DefaultWalletAccountsResponseFactoryTest {
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
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
val token = createUserToken(accountIndex = 0)
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(token),
)
every {
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
} returns defaultResponse
val accountsDTO = createWalletAccountDTO(userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
@ -138,20 +136,19 @@ class DefaultWalletAccountsResponseFactoryTest {
sort = defaultResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
accounts = listOf(accountsDTO.copy(tokens = listOf(token))),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
userWalletsListRepository.userWalletsSync()
userWalletsStore.getSyncOrNull(userWalletId)
cryptoPortfolioConverter.convertListBack(accounts)
cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet)
userTokensResponseFactory.createUserTokensResponse(
currencies = defaultCoins,
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
}
}
@ -162,22 +159,26 @@ class DefaultWalletAccountsResponseFactoryTest {
val userWallet = mockk<UserWallet>(relaxed = true) {
every { walletId } returns userWalletId
}
coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet)
every { cardCryptoCurrencyFactory.createDefaultCoinsForMultiCurrencyWallet(userWallet) } returns emptyList()
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val defaultResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = emptyList(),
)
every {
userTokensResponseFactory.createUserTokensResponse(
currencies = emptyList(),
isGroupedByNetwork = false,
isSortedByBalance = false,
userTokensResponseFactory.createDefaultResponse(
userWallet = userWallet,
networkFactory = networkFactory,
accountId = accounts.first().accountId,
)
} returns defaultResponse
val accounts = AccountList.empty(userWallet.walletId).accounts
.filterIsInstance<Account.CryptoPortfolio>()
every { cryptoPortfolioConverter.convertListBack(accounts) } returns emptyList()
// Act
@ -202,24 +203,20 @@ class DefaultWalletAccountsResponseFactoryTest {
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)
coEvery { userWalletsStore.getSyncOrNull(userWalletId) } returns userWallet
val userTokensResponse = UserTokensResponse(
group = UserTokensResponse.GroupType.NETWORK,
sort = UserTokensResponse.SortType.BALANCE,
tokens = listOf(mockk(relaxed = true)),
tokens = listOf(
createUserToken(accountIndex = 0),
createUserToken(accountIndex = 1),
),
)
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)
val accountsDTO = createWalletAccountDTO(userWalletId = userWalletId)
every { cryptoPortfolioConverter.convertListBack(accounts) } returns listOf(accountsDTO)
// Act
@ -232,7 +229,7 @@ class DefaultWalletAccountsResponseFactoryTest {
sort = userTokensResponse.sort,
totalAccounts = 1,
),
accounts = listOf(accountsDTO),
accounts = listOf(accountsDTO.copy(tokens = userTokensResponse.tokens)),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)

View file

@ -4,6 +4,8 @@ import com.google.common.truth.Truth
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.api.tangemTech.models.account.flattenTokens
import com.tangem.datasource.api.tangemTech.models.account.toUserTokensResponse
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.wallet.UserWalletId
@ -68,9 +70,9 @@ class GetWalletAccountsResponseExtTest {
@Test
fun `flattenTokens returns all tokens from multiple accounts`() {
// Arrange
val token1 = createUserToken(id = "0")
val token2 = createUserToken(id = "1")
val token3 = createUserToken(id = "2")
val token1 = createUserToken(accountIndex = 0)
val token2 = createUserToken(accountIndex = 1)
val token3 = createUserToken(accountIndex = 2)
val account1 = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1, token2))
val account2 = createWalletAccountDTO(derivationIndex = 1, tokens = listOf(token3))
@ -129,10 +131,9 @@ class GetWalletAccountsResponseExtTest {
@Test
fun `toUserTokensResponse includes tokens from accounts and unassignedTokens`() {
// Arrange
val token1 = createUserToken(id = "0")
val token2 = createUserToken(id = "1")
val token1 = createUserToken(accountIndex = 0)
val token2 = createUserToken(accountIndex = 1)
val account = createWalletAccountDTO(derivationIndex = 0, tokens = listOf(token1))
val unassignedToken = token2
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
@ -142,7 +143,7 @@ class GetWalletAccountsResponseExtTest {
totalAccounts = 1,
),
accounts = listOf(account),
unassignedTokens = listOf(unassignedToken),
unassignedTokens = listOf(token2),
)
// Act
@ -167,8 +168,8 @@ class GetWalletAccountsResponseExtTest {
fun `assignTokens correctly assigns tokens to accounts`() {
// Arrange
val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"
val token1 = createUserToken(id = "0", accountId = null)
val token2 = createUserToken(id = "1", accountId = null)
val token1 = createUserToken(accountIndex = 0, accountId = null)
val token2 = createUserToken(accountIndex = 1, accountId = null)
val account1 = createWalletAccountDTO(derivationIndex = 0)
val account2 = createWalletAccountDTO(derivationIndex = 1)
val response = GetWalletAccountsResponse(
@ -192,10 +193,13 @@ class GetWalletAccountsResponseExtTest {
account1.copy(
tokens = listOf(
token1.copy(accountId = accountId),
),
),
account2.copy(
tokens = listOf(
token2.copy(accountId = accountId),
),
),
account2,
),
unassignedTokens = emptyList(),
)
@ -230,6 +234,44 @@ class GetWalletAccountsResponseExtTest {
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `unassignedTokens contain tokens for unexisting accounts`() {
// Arrange
val token1 = createUserToken(accountIndex = 0, accountId = null)
val token2 = createUserToken(accountIndex = 3, accountId = null)
val account = createWalletAccountDTO(derivationIndex = 0)
val response = GetWalletAccountsResponse(
wallet = GetWalletAccountsResponse.Wallet(
version = 1,
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
totalAccounts = 2,
),
accounts = listOf(account),
unassignedTokens = listOf(token1, token2),
)
// Act
val actual = response.assignTokens(userWalletId)
// Assert
val expected = GetWalletAccountsResponse(
wallet = response.wallet,
accounts = listOf(
account.copy(
tokens = listOf(
token1.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"),
token2.copy(accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"),
),
),
),
unassignedTokens = emptyList(),
)
Truth.assertThat(actual).isEqualTo(expected)
}
}
@Nested
@ -240,8 +282,8 @@ class GetWalletAccountsResponseExtTest {
fun `assignTokens correctly assigns tokens to accounts`() {
// Arrange
val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"
val token1 = createUserToken(id = "0", accountId = null)
val token2 = createUserToken(id = "1", accountId = null)
val token1 = createUserToken(accountIndex = 0, accountId = null)
val token2 = createUserToken(accountIndex = 1, accountId = null)
val account1 = createWalletAccountDTO(derivationIndex = 0)
val account2 = createWalletAccountDTO(derivationIndex = 1)
@ -256,10 +298,13 @@ class GetWalletAccountsResponseExtTest {
account1.copy(
tokens = listOf(
token1.copy(accountId = accountId),
),
),
account2.copy(
tokens = listOf(
token2.copy(accountId = accountId),
),
),
account2,
)
Truth.assertThat(actual).isEqualTo(expected)
@ -269,7 +314,7 @@ class GetWalletAccountsResponseExtTest {
fun `assignTokens does not change accounts if there are no unassignedTokens`() {
// Arrange
val accountId = "957B88B12730E646E0F33D3618B77DFA579E8231E3C59C7104BE7165611C8027"
val token1 = createUserToken(id = "0", accountId = accountId)
val token1 = createUserToken(accountIndex = 0, accountId = accountId)
val account1 = createWalletAccountDTO(derivationIndex = 0)
// Act
@ -306,19 +351,19 @@ class GetWalletAccountsResponseExtTest {
totalNetworks = 1,
)
private fun createUserToken(id: String, accountId: String? = "account_id") = UserTokensResponse.Token(
id = id,
accountId = accountId,
networkId = "ethereum",
derivationPath = "m/44'/60'/0'/0/0",
name = "Token",
symbol = "T",
contractAddress = "0x$id",
decimals = 18,
)
companion object {
private companion object {
private val userWalletId = UserWalletId("011")
val userWalletId = UserWalletId("011")
fun createUserToken(accountIndex: Int, accountId: String? = "account_id") = UserTokensResponse.Token(
id = accountIndex.toString(),
accountId = accountId,
networkId = "ethereum",
derivationPath = "m/44'/60'/0'/0/$accountIndex",
name = "Token",
symbol = "T",
contractAddress = "0x$accountIndex",
decimals = 18,
)
}
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CastNullableToNonNullableType:DefaultDeviceFlipDetector.kt$DefaultDeviceFlipDetector$as</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -48,10 +48,7 @@ dependencies {
/* Test */
testImplementation(projects.common.test)
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testImplementation(projects.test.core)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.moshi)
}

View file

@ -0,0 +1,14 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:ApiResponseRaise.kt${ Timber.e(it, "Unable to perform safe API call") onError(it) }</ID>
<ID>MultilineLambdaItParameter:DefaultCardCryptoCurrencyFactory.kt$DefaultCardCryptoCurrencyFactory${ cryptoCurrencyFactory.createCoin( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:UserTokensResponseFactory.kt$UserTokensResponseFactory${ getDefaultWalletBlockchains(userWallet = it, demoConfig = DemoConfig) .map { blockchain -&gt; val derivationPath = networkFactory.createDerivationPath( blockchain = blockchain, extraDerivationPath = null, cardDerivationStyleProvider = userWallet.derivationStyleProvider, ).value UserTokensResponse.Token( id = blockchain.toCoinId(), accountId = accountId?.value, networkId = blockchain.toNetworkId(), derivationPath = derivationPath, name = blockchain.getCoinName(), symbol = blockchain.currency, decimals = blockchain.decimals(), contractAddress = null, ) } }</ID>
<ID>MultilineLambdaItParameter:UserTokensSaver.kt$UserTokensSaver${ if (accountsFeatureToggles.isFeatureEnabled) { it.enrichByAccountId(userWalletId = userWalletId) } else { it } }</ID>
<ID>NamedArguments:CryptoCurrencyFactory.kt$CryptoCurrencyFactory$createCoin(blockchain, extraDerivationPath, userWallet, accountIndex)</ID>
<ID>NoNameShadowing:DefaultETagsStore.kt$DefaultETagsStore$key</ID>
<ID>UnnecessaryLet:DefaultCardCryptoCurrencyFactory.kt$DefaultCardCryptoCurrencyFactory$let(::listOf)</ID>
<ID>UseOrEmpty:UserTokensResponseAddressesEnricher.kt$UserTokensResponseAddressesEnricher$withTimeoutOrNull( FETCH_TIMEOUT_SECONDS.seconds, { multiNetworkStatusSupplier.invoke(MultiNetworkStatusProducer.Params(userWalletId)).first() }, ) ?: emptySet()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,5 +1,6 @@
package com.tangem.data.common.account
import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse
import com.tangem.domain.models.wallet.UserWalletId
/**
@ -11,5 +12,8 @@ interface WalletAccountsFetcher {
/** Fetch wallet accounts by [userWalletId] */
@Throws
suspend fun fetch(userWalletId: UserWalletId)
suspend fun fetch(userWalletId: UserWalletId): GetWalletAccountsResponse
/** Get saved wallet accounts by [userWalletId] */
suspend fun getSaved(userWalletId: UserWalletId): GetWalletAccountsResponse?
}

View file

@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.data.common.network.NetworkFactory
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,7 +19,7 @@ class CryptoCurrencyFactory(
private val excludedBlockchains: ExcludedBlockchains,
) {
private val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) }
val networkFactory by lazy(LazyThreadSafetyMode.NONE) { NetworkFactory(excludedBlockchains) }
@Suppress("LongParameterList") // Yep, it's long
fun createToken(
@ -48,6 +49,7 @@ class CryptoCurrencyFactory(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Token? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
@ -58,6 +60,7 @@ class CryptoCurrencyFactory(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
val id = getTokenId(network, sdkToken)
@ -74,11 +77,16 @@ class CryptoCurrencyFactory(
)
}
fun createCoin(chainId: Int, extraDerivationPath: String?, userWallet: UserWallet): CryptoCurrency.Coin? {
fun createCoin(
chainId: Int,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Coin? {
val blockchain: Blockchain? = Chain.entries.find { it.id == chainId }?.blockchain
return if (blockchain != null) {
createCoin(blockchain, extraDerivationPath, userWallet)
createCoin(blockchain, extraDerivationPath, userWallet, accountIndex)
} else {
Timber.e("Unable to get blockchain from chainId == $chainId")
null
@ -89,6 +97,7 @@ class CryptoCurrencyFactory(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): CryptoCurrency.Coin? {
if (blockchain == Blockchain.Unknown) {
Timber.e("Unable to map the SDK token to the domain token with Unknown blockchain")
@ -99,6 +108,7 @@ class CryptoCurrencyFactory(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
return createCoin(network)

View file

@ -1,14 +1,16 @@
package com.tangem.data.common.currency
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.tokens.getDefaultWalletBlockchains
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -23,10 +25,13 @@ import com.tangem.domain.models.wallet.isMultiCurrency
* @property userWalletsStore user wallets store
* @property userTokensResponseStore user tokens response store
*/
@Suppress("LongParameterList")
internal class DefaultCardCryptoCurrencyFactory(
private val demoConfig: DemoConfig,
private val excludedBlockchains: ExcludedBlockchains,
private val userWalletsStore: UserWalletsStore,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val userTokensResponseStore: UserTokensResponseStore,
private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
) : CardCryptoCurrencyFactory {
@ -99,25 +104,7 @@ internal class DefaultCardCryptoCurrencyFactory(
override fun createDefaultCoinsForMultiCurrencyWallet(userWallet: UserWallet): List<CryptoCurrency.Coin> {
require(userWallet.isMultiCurrency) { "It isn't multi-currency wallet" }
val blockchains = when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.demoBlockchains
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() }
}
blockchainsInternal
}
is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
val blockchains = getDefaultWalletBlockchains(userWallet, demoConfig)
return blockchains.mapNotNull {
cryptoCurrencyFactory.createCoin(
@ -152,15 +139,32 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet,
networks: Set<Network>,
): Map<Network, List<CryptoCurrency>> {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
val existingNetworkWithCurrencies = if (accountsFeatureToggles.isFeatureEnabled) {
val response = walletAccountsFetcher.getSaved(userWallet.walletId)
?: return emptyMap()
val existingNetworkWithCurrencies = responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token ->
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
userWallet = userWallet,
)
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token ->
networks.any {
it.backendId == token.networkId && it.derivationPath.value == token.derivationPath
}
},
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
} else {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token ->
networks.any { it.backendId == token.networkId && it.derivationPath.value == token.derivationPath }
},
userWallet = userWallet,
)
}
.groupBy(CryptoCurrency::network)
return networks.associateWith { emptyList<CryptoCurrency>() } + existingNetworkWithCurrencies
@ -170,15 +174,28 @@ internal class DefaultCardCryptoCurrencyFactory(
userWallet: UserWallet,
rawIds: Set<Network.RawID>,
): Map<Network.RawID, List<CryptoCurrency>> {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
val networkIds = rawIds.map { it.toBlockchain().toNetworkId() }
return responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token -> token.networkId in networkIds },
userWallet = userWallet,
)
return if (accountsFeatureToggles.isFeatureEnabled) {
val response = walletAccountsFetcher.getSaved(userWallet.walletId)
?: return emptyMap()
response.accounts.flatMapTo(hashSetOf()) { accountDTO ->
responseCryptoCurrenciesFactory.createCurrencies(
tokens = accountDTO.tokens.orEmpty().filter { token -> token.networkId in networkIds },
userWallet = userWallet,
accountIndex = DerivationIndex(accountDTO.derivationIndex).getOrNull(),
)
}
} else {
val response = userTokensResponseStore.getSyncOrNull(userWalletId = userWallet.walletId)
?: return emptyMap()
responseCryptoCurrenciesFactory.createCurrencies(
tokens = response.tokens.filter { token -> token.networkId in networkIds },
userWallet = userWallet,
)
}
.groupBy { it.network.id.rawId }
}

View file

@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.toCoinId
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.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,26 +19,31 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
private val networkFactory: NetworkFactory,
) {
fun createCurrency(currencyId: String, response: UserTokensResponse, userWallet: UserWallet): CryptoCurrency {
return response.tokens
.asSequence()
.mapNotNull { createCurrency(it, userWallet) }
.first { it.id.value == currencyId }
fun createCurrencies(
response: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): List<CryptoCurrency> {
return createCurrencies(tokens = response.tokens, userWallet = userWallet, accountIndex = accountIndex)
}
fun createCurrencies(response: UserTokensResponse, userWallet: UserWallet): List<CryptoCurrency> {
return createCurrencies(tokens = response.tokens, userWallet = userWallet)
}
fun createCurrencies(tokens: List<UserTokensResponse.Token>, userWallet: UserWallet): List<CryptoCurrency> {
fun createCurrencies(
tokens: List<UserTokensResponse.Token>,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): List<CryptoCurrency> {
return tokens
.asSequence()
.mapNotNull { createCurrency(it, userWallet) }
.mapNotNull { createCurrency(it, userWallet, accountIndex) }
.distinctBy(CryptoCurrency::id)
.toList()
}
fun createCurrency(responseToken: UserTokensResponse.Token, userWallet: UserWallet): CryptoCurrency? {
fun createCurrency(
responseToken: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): 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}")
@ -52,6 +58,7 @@ class ResponseCryptoCurrenciesFactory @Inject constructor(
blockchain = blockchain,
extraDerivationPath = responseToken.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
return createCurrency(responseToken = responseToken, userWallet = userWallet, network = network)

View file

@ -1,11 +1,17 @@
package com.tangem.data.common.currency
import com.tangem.blockchainsdk.utils.toCoinId
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.common.tokens.getDefaultWalletBlockchains
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import javax.inject.Inject
// TODO: [REDACTED_JIRA]
class UserTokensResponseFactory @Inject constructor() {
fun createUserTokensResponse(
@ -43,4 +49,38 @@ class UserTokensResponseFactory @Inject constructor() {
)
}
}
fun createDefaultResponse(
userWallet: UserWallet?,
networkFactory: NetworkFactory,
accountId: AccountId?,
): UserTokensResponse {
val tokens = userWallet?.let {
getDefaultWalletBlockchains(userWallet = it, demoConfig = DemoConfig)
.map { blockchain ->
val derivationPath = networkFactory.createDerivationPath(
blockchain = blockchain,
extraDerivationPath = null,
cardDerivationStyleProvider = userWallet.derivationStyleProvider,
).value
UserTokensResponse.Token(
id = blockchain.toCoinId(),
accountId = accountId?.value,
networkId = blockchain.toNetworkId(),
derivationPath = derivationPath,
name = blockchain.getCoinName(),
symbol = blockchain.currency,
decimals = blockchain.decimals(),
contractAddress = null,
)
}
}
return UserTokensResponse(
group = UserTokensResponse.GroupType.NONE,
sort = UserTokensResponse.SortType.MANUAL,
tokens = tokens.orEmpty(),
)
}
}

View file

@ -8,7 +8,10 @@ import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.retryer.Retryer
import com.tangem.utils.retryer.RetryerPool
import kotlinx.coroutines.withContext
import timber.log.Timber
class UserTokensSaver(
private val tangemTechApi: TangemTechApi,
@ -16,6 +19,7 @@ class UserTokensSaver(
private val dispatchers: CoroutineDispatcherProvider,
private val addressesEnricher: UserTokensResponseAddressesEnricher,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val pushTokensRetryerPool: RetryerPool,
) {
private val userTokensBackwardCompatibility = UserTokensBackwardCompatibility()
@ -58,6 +62,23 @@ class UserTokensSaver(
}
}
suspend fun pushWithRetryer(
userWalletId: UserWalletId,
response: UserTokensResponse,
useEnricher: Boolean = true,
onFailSend: () -> Unit = {},
) {
push(
userWalletId = userWalletId,
response = response,
useEnricher = useEnricher,
onFailSend = {
pushTokensRetryerPool + createPushTokensRetryer(userWalletId, response)
onFailSend()
},
)
}
private fun UserTokensResponse.applyCompatibility(): UserTokensResponse {
return userTokensBackwardCompatibility.applyCompatibilityAndGetUpdated(userTokensResponse = this)
}
@ -86,4 +107,24 @@ class UserTokensSaver(
private fun UserTokensResponse.enrichByAccountId(userWalletId: UserWalletId): UserTokensResponse {
return UserTokensResponseAccountIdEnricher(userWalletId = userWalletId, response = this)
}
private fun createPushTokensRetryer(userWalletId: UserWalletId, response: UserTokensResponse): Retryer {
return Retryer(attempt = 3) { iteration ->
var isSuccess = true
push(
userWalletId = userWalletId,
response = response,
onFailSend = {
Timber.e(
"Retryer: Failed to push updated tokens on attempt ${iteration + 1} for $userWalletId",
)
isSuccess = false
},
)
isSuccess
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.cache.etag.DefaultETagsStore
import com.tangem.data.common.cache.etag.ETagsStore
import com.tangem.data.common.currency.*
@ -15,10 +16,13 @@ import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.retryer.RetryerPool
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import javax.inject.Singleton
@Module
@ -30,13 +34,17 @@ internal object DataCommonModule {
fun provideCardCryptoCurrencyFactory(
excludedBlockchains: ExcludedBlockchains,
userWalletsStore: UserWalletsStore,
accountsFeatureToggles: AccountsFeatureToggles,
walletAccountsFetcher: WalletAccountsFetcher,
userTokensResponseStore: UserTokensResponseStore,
responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory,
): CardCryptoCurrencyFactory {
return DefaultCardCryptoCurrencyFactory(
demoConfig = DemoConfig(),
demoConfig = DemoConfig,
excludedBlockchains = excludedBlockchains,
userWalletsStore = userWalletsStore,
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory,
)
@ -71,6 +79,9 @@ internal object DataCommonModule {
dispatchers = dispatchers,
addressesEnricher = addressesEnricher,
accountsFeatureToggles = accountsFeatureToggles,
pushTokensRetryerPool = RetryerPool(
coroutineScope = CoroutineScope(SupervisorJob() + dispatchers.default),
),
)
}

View file

@ -7,10 +7,12 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.lib.crypto.derivation.toMutable
import timber.log.Timber
import javax.inject.Inject
@ -21,6 +23,7 @@ import javax.inject.Inject
*
[REDACTED_AUTHOR]
*/
@Suppress("LargeClass")
class NetworkFactory @Inject constructor(
private val excludedBlockchains: ExcludedBlockchains,
) {
@ -32,13 +35,19 @@ class NetworkFactory @Inject constructor(
* @param extraDerivationPath extra derivation path
* @param userWallet user wallet
*/
fun create(blockchain: Blockchain, extraDerivationPath: String?, userWallet: UserWallet): Network? {
fun create(
blockchain: Blockchain,
extraDerivationPath: String?,
userWallet: UserWallet,
accountIndex: DerivationIndex? = null,
): Network? {
return create(
blockchain = blockchain,
derivationPath = createDerivationPath(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
cardDerivationStyleProvider = userWallet.derivationStyleProvider,
accountIndex = accountIndex,
),
canHandleTokens = userWallet.canHandleToken(
blockchain = blockchain,
@ -98,6 +107,7 @@ class NetworkFactory @Inject constructor(
extraDerivationPath: String?,
derivationStyleProvider: DerivationStyleProvider?,
canHandleTokens: Boolean,
accountIndex: DerivationIndex? = null,
): Network? {
return create(
blockchain = blockchain,
@ -105,6 +115,7 @@ class NetworkFactory @Inject constructor(
blockchain = blockchain,
extraDerivationPath = extraDerivationPath,
cardDerivationStyleProvider = derivationStyleProvider,
accountIndex = accountIndex,
),
canHandleTokens = canHandleTokens,
)
@ -148,14 +159,15 @@ class NetworkFactory @Inject constructor(
return true
}
private fun createDerivationPath(
fun createDerivationPath(
blockchain: Blockchain,
extraDerivationPath: String?,
cardDerivationStyleProvider: DerivationStyleProvider?,
accountIndex: DerivationIndex? = null,
): Network.DerivationPath {
if (cardDerivationStyleProvider == null) return Network.DerivationPath.None
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider)
val defaultDerivationPath = getDefaultDerivationPath(blockchain, cardDerivationStyleProvider, accountIndex)
return if (extraDerivationPath.isNullOrBlank()) {
if (defaultDerivationPath.isNullOrBlank()) {
@ -175,8 +187,19 @@ class NetworkFactory @Inject constructor(
private fun getDefaultDerivationPath(
blockchain: Blockchain,
derivationStyleProvider: DerivationStyleProvider,
accountIndex: DerivationIndex?,
): String? {
return blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())?.rawPath
val default = blockchain.derivationPath(derivationStyleProvider.getDerivationStyle())
?: return null
return if (accountIndex == null || accountIndex == DerivationIndex.Main) {
default
} else {
default.toMutable()
.replaceAccountNode(value = accountIndex.value.toLong(), blockchain = blockchain)
.apply()
}
.rawPath
}
private fun getNetworkStandardType(blockchain: Blockchain): Network.StandardType {
@ -344,8 +367,8 @@ class NetworkFactory @Inject constructor(
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
// Blockchain.Linea, Blockchain.LineaTestnet,
// Blockchain.ArbitrumNova,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
-> Network.TransactionExtrasType.NONE
// endregion
}

View file

@ -0,0 +1,33 @@
package com.tangem.data.common.tokens
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.wallet.UserWallet
/**
* Returns the default blockchains for the multi-currency wallet.
*
* @param userWallet The user's wallet, which can be either a cold or hot wallet.
* @param demoConfig Configuration for demo cards, which may specify different default blockchains.
*/
fun getDefaultWalletBlockchains(userWallet: UserWallet, demoConfig: DemoConfig): Collection<Blockchain> {
return when (userWallet) {
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
var blockchainsInternal = if (demoConfig.isDemoCardId(card.cardId)) {
demoConfig.getDemoBlockchains(card.cardId)
} else {
listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
if (card.isTestCard) {
blockchainsInternal = blockchainsInternal.mapNotNull { it.getTestnetVersion() }
}
blockchainsInternal
}
is UserWallet.Hot -> listOf(Blockchain.Bitcoin, Blockchain.Ethereum)
}
}

View file

@ -8,11 +8,12 @@ import com.tangem.common.card.WalletData
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.demo.models.DemoConfig
@ -21,6 +22,7 @@ import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ProductType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.ProvideTestModels
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
@ -37,15 +39,19 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
private val userWalletsStore: UserWalletsStore = mockk()
private val userTokensResponseStore: UserTokensResponseStore = mockk()
private val excludedBlockchains = ExcludedBlockchains()
private val accountsFeatureToggles = mockk<AccountsFeatureToggles>()
private val walletAccountsFetcher = mockk<WalletAccountsFetcher>()
private val factory = DefaultCardCryptoCurrencyFactory(
demoConfig = DemoConfig(),
demoConfig = DemoConfig,
excludedBlockchains = excludedBlockchains,
userWalletsStore = userWalletsStore,
userTokensResponseStore = userTokensResponseStore,
responseCryptoCurrenciesFactory = ResponseCryptoCurrenciesFactory(
networkFactory = NetworkFactory(excludedBlockchains = excludedBlockchains),
),
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
)
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
@ -57,7 +63,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
@BeforeEach
fun init() {
clearMocks(userWalletsStore, userTokensResponseStore, iconUri)
clearMocks(userWalletsStore, userTokensResponseStore, accountsFeatureToggles, walletAccountsFetcher, iconUri)
mockkStatic(Uri::class)
every { Uri.parse(any()) } returns iconUri
@ -75,6 +81,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
val userTokensResponse = model.userTokensResponse
val network = ethereum.network
every { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery { userWalletsStore.getSyncStrict(key = userWallet.walletId) } returns userWallet
coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse
@ -236,6 +243,7 @@ internal class DefaultCardCryptoCurrencyFactoryTest {
val networks = setOf(ethereum.network, bitcoin.network)
val userTokensResponse = model.userTokensResponse
every { accountsFeatureToggles.isFeatureEnabled } returns false
coEvery { userTokensResponseStore.getSyncOrNull(userWallet.walletId) } returns userTokensResponse
// Act

View file

@ -97,9 +97,9 @@ class UserTokensResponseAccountIdEnricherTest {
val validNetworkId = mockCryptoCurrencyFactory.ethereum.network.rawId
val tokenWithUnknownNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
networkId = unknownNetworkId,
derivationPath = "m/44'/60'/0'/0/0",
accountId = null,
)
val tokenWithValidNetworkId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
@ -122,6 +122,32 @@ class UserTokensResponseAccountIdEnricherTest {
Truth.assertThat(actual).isEqualTo(expected)
}
@Test
fun `skips tokens with existing account ids`() {
// Arrange
val tokenWithAccountId = mockCryptoCurrencyFactory.ethereum
.toResponseToken(derivationPath = "m/44'/60'/0'/0/0")
.enrichWithAccountId(accountIndex = 0)
val tokenWithoutAccountId = mockCryptoCurrencyFactory.ethereum.toResponseToken(
accountId = null,
derivationPath = "m/44'/60'/0'/0/0",
)
val response = listOf(tokenWithAccountId, tokenWithoutAccountId).toResponse()
// Act
val actual = UserTokensResponseAccountIdEnricher(userWalletId, response)
// Assert
val expected = listOf(
tokenWithAccountId,
tokenWithoutAccountId.enrichWithAccountId(accountIndex = 0),
).toResponse()
Truth.assertThat(actual).isEqualTo(expected)
}
private fun CryptoCurrency.toResponseToken(
accountId: AccountId? = null,
networkId: String? = null,

View file

@ -30,6 +30,7 @@ class UserTokensSaverTest {
dispatchers = TestingCoroutineDispatcherProvider(),
addressesEnricher = enricher,
accountsFeatureToggles = accountsFeatureToggles,
pushTokensRetryerPool = mockk(),
)
@BeforeEach

View file

@ -6,14 +6,14 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.card.configs.MultiWalletCardConfig
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.derivations.DerivationStyleProvider
import com.tangem.domain.wallets.derivations.derivationStyleProvider
import com.tangem.test.core.ProvideTestModels
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Nested

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>IgnoredReturnValue:DefaultExpressServiceFetcher.kt$DefaultExpressServiceFetcher$put(key = userWalletId, value = default)</ID>
<ID>MultilineLambdaItParameter:DefaultExpressRepository.kt$DefaultExpressRepository${ Timber.w(it, "Unable to fetch express providers") throw it }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,113 @@
package com.tangem.data.express
import arrow.core.Either
import arrow.core.raise.either
import com.tangem.data.express.converter.ExpressAssetConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.request.AssetsRequestBody
import com.tangem.datasource.api.express.models.request.LeastTokenInfo
import com.tangem.datasource.exchangeservice.swap.ExpressUtils.getRefCode
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.token.ExpressAssetsStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.core.lce.Lce
import com.tangem.domain.core.utils.catchOn
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import timber.log.Timber
import javax.inject.Inject
typealias InitializationStatusFlow = MutableStateFlow<Lce<Throwable, List<ExpressAsset>>>
/**
* Default implementation of [ExpressServiceFetcher]
*
* @property tangemExpressApi express api
* @property expressAssetsStore local storage
*
[REDACTED_AUTHOR]
*/
internal class DefaultExpressServiceFetcher @Inject constructor(
private val tangemExpressApi: TangemExpressApi,
private val expressAssetsStore: ExpressAssetsStore,
private val appPreferencesStore: AppPreferencesStore,
private val userWalletsStore: UserWalletsStore,
private val dispatchers: CoroutineDispatcherProvider,
) : ExpressServiceFetcher {
private val initializationStatuses =
MutableStateFlow<Map<UserWalletId, InitializationStatusFlow>>(value = emptyMap())
override suspend fun fetch(userWalletId: UserWalletId, assetIds: Set<ExpressAsset.ID>): Either<Throwable, Unit> =
either {
val userWallet = arrow.core.raise.catch(
block = { userWalletsStore.getSyncStrict(userWalletId) },
catch = ::raise,
)
fetch(userWallet = userWallet, assetIds = assetIds).bind()
}
override suspend fun fetch(userWallet: UserWallet, assetIds: Set<ExpressAsset.ID>): Either<Throwable, Unit> {
return Either.catchOn(dispatchers.io) {
val initializationStatus = getInitializationStatusInternal(userWallet.walletId)
try {
if (assetIds.isNotEmpty()) {
val tokenList = assetIds.map {
LeastTokenInfo(contractAddress = it.contractAddress, network = it.networkId)
}
val response = tangemExpressApi.getAssets(
userWalletId = userWallet.walletId.stringValue,
refCode = getRefCode(userWallet, appPreferencesStore),
body = AssetsRequestBody(tokensList = tokenList),
).getOrThrow()
expressAssetsStore.store(userWallet.walletId, response)
val expressAssets = ExpressAssetConverter.convertList(response)
initializationStatus.update { expressAssets.lceContent() }
}
} catch (e: Throwable) {
if (expressAssetsStore.getSyncOrNull(userWallet.walletId) == null) {
initializationStatus.update { e.lceError() }
}
Timber.e(e, "Unable to fetch assets for: ${userWallet.walletId.stringValue}")
throw e
}
}
}
override fun getInitializationStatus(userWalletId: UserWalletId): Flow<Lce<Throwable, List<ExpressAsset>>> {
return flow { getInitializationStatusInternal(userWalletId).collect { emit(it) } }
}
@Suppress("SuspendFunWithFlowReturnType")
private suspend fun getInitializationStatusInternal(userWalletId: UserWalletId): InitializationStatusFlow {
val initializationStatus = initializationStatuses.value[userWalletId]
if (initializationStatus != null) return initializationStatus
val cached = expressAssetsStore.getSyncOrNull(userWalletId)?.let(ExpressAssetConverter::convertList)
val default: InitializationStatusFlow = MutableStateFlow(value = cached?.lceContent() ?: lceLoading())
initializationStatuses.update { statuses ->
statuses.toMutableMap().apply {
put(key = userWalletId, value = default)
}
}
return default
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.data.express.converter
import com.tangem.datasource.api.express.models.response.Asset
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.utils.converter.Converter
/**
* Converts an [Asset] from the data layer to an [ExpressAsset] in the domain layer.
*
[REDACTED_AUTHOR]
*/
internal object ExpressAssetConverter : Converter<Asset, ExpressAsset> {
override fun convert(value: Asset): ExpressAsset {
return ExpressAsset(
id = ExpressAsset.ID(
networkId = value.network,
contractAddress = value.contractAddress,
),
isExchangeAvailable = value.exchangeAvailable,
isOnrampAvailable = value.onrampAvailable,
)
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.data.express.di
import com.squareup.moshi.Moshi
import com.tangem.data.express.DefaultExpressRepository
import com.tangem.data.express.DefaultExpressServiceFetcher
import com.tangem.data.express.converter.ExpressErrorConverter
import com.tangem.data.express.error.DefaultExpressErrorResolver
import com.tangem.datasource.api.express.TangemExpressApi
@ -10,6 +11,7 @@ import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.express.ExpressErrorResolver
import com.tangem.domain.express.ExpressRepository
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
@ -43,4 +45,10 @@ internal object ExpressDataModule {
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideExpressServiceFetcher(impl: DefaultExpressServiceFetcher): ExpressServiceFetcher {
return impl
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultFeedbackRepository.kt$DefaultFeedbackRepository$private val useNewUserWalletsRepository: Boolean</ID>
<ID>MultilineLambdaItParameter:DefaultFeedbackRepository.kt$DefaultFeedbackRepository${ it.toMutableMap().apply { put(userWallet.walletId, error) } }</ID>
<ID>UseOrEmpty:BlockchainInfoConverter.kt$BlockchainInfoConverter$value.wallet.publicKey.derivationPath?.rawPath ?: ""</ID>
</CurrentIssues>
</SmellBaseline>

1
data/hot-wallet/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,27 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.hilt.android)
id("configuration")
}
android {
namespace = "com.tangem.data.hotwallet"
}
dependencies {
implementation(projects.core.datasource)
implementation(projects.core.utils)
implementation(projects.domain.hotWallet)
implementation(projects.domain.models)
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
implementation(deps.androidx.datastore)
implementation(deps.kotlin.coroutines)
implementation(deps.moshi)
implementation(deps.moshi.kotlin)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultHotWalletRepository.kt$DefaultHotWalletRepository${ it.setObjectMap( key = PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY, value = it.getObjectMap&lt;Boolean&gt;(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY) .plus(userWalletId.stringValue to skipped), ) }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -0,0 +1,28 @@
package com.tangem.data.hotwallet
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObjectMap
import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
internal class DefaultHotWalletRepository(
private val appPreferencesStore: AppPreferencesStore,
) : HotWalletRepository {
override fun accessCodeSkipped(userWalletId: UserWalletId): Flow<Boolean> = appPreferencesStore
.getObjectMap<Boolean>(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY)
.map { it[userWalletId.stringValue] == true }
override suspend fun setAccessCodeSkipped(userWalletId: UserWalletId, skipped: Boolean) {
appPreferencesStore.editData {
it.setObjectMap(
key = PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY,
value = it.getObjectMap<Boolean>(PreferencesKeys.ACCESS_CODE_SKIPPED_STATES_KEY)
.plus(userWalletId.stringValue to skipped),
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.data.hotwallet.di
import com.tangem.data.hotwallet.DefaultHotWalletRepository
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.hotwallet.repository.HotWalletRepository
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 HotWalletDataModule {
@Provides
@Singleton
fun provideHotWalletRepository(appPreferencesStore: AppPreferencesStore): HotWalletRepository {
return DefaultHotWalletRepository(
appPreferencesStore = appPreferencesStore,
)
}
}

View file

@ -15,6 +15,7 @@ android {
dependencies {
/** Project - Domain */
implementation(projects.domain.account)
implementation(projects.domain.demo)
implementation(projects.domain.models)
implementation(projects.domain.manageTokens)

View file

@ -0,0 +1,17 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository${ // TODO: refactor https://tangem.atlassian.net/browse/AND-10006\ if (it.isTestnet() || it in excludedBlockchains || it in hotWalletExcludedBlockchains) { return@mapNotNull null } networkFactory.create( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultManageTokensRepository.kt$DefaultManageTokensRepository${ it.contractAddress != null &amp;&amp; it.networkId == network.backendId &amp;&amp; it.derivationPath == network.derivationPath.value }</ID>
<ID>MultilineLambdaItParameter:ManageTokensUpdateFetcher.kt$ManageTokensUpdateFetcher${ if (it.key == toUpdate[index].key) { Batch(it.key, updatedItems) } else { null } }</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$create(coinsResponse, tokensResponse, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex)</ID>
<ID>NamedArguments:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex)</ID>
<ID>SuspendFunSwallowedCancellation:DefaultManageTokensRepository.kt$DefaultManageTokensRepository$runCatching</ID>
<ID>UnsafeCallOnNullableType:DefaultCustomTokensRepository.kt$DefaultCustomTokensRepository$coinNetwork.decimalCount!!</ID>
<ID>UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$testnetToken.networks?.mapNotNull { network -&gt; createSource( networkId = network.id, contractAddress = network.address, decimals = network.decimalCount, userWallet = userWallet, accountIndex = accountIndex, ) } ?: emptyList()</ID>
<ID>UseOrEmpty:ManagedCryptoCurrencyFactory.kt$ManagedCryptoCurrencyFactory$tokensResponse ?.let { createCustomTokens(it, userWallet, accountIndex) } ?: emptyList()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -238,7 +238,7 @@ internal class DefaultCustomTokensRepository(
"User tokens not found for user wallet [$userWalletId] while removing currency"
}
val token = userTokensResponseFactory.createResponseToken(cryptoCurrency)
val token = userTokensResponseFactory.createResponseToken(currency = cryptoCurrency, accountId = null)
userTokensSaver.storeAndPush(
userWalletId = userWalletId,
response = storedCurrencies.copy(tokens = storedCurrencies.tokens.filterNot { it == token }),
@ -249,6 +249,27 @@ internal class DefaultCustomTokensRepository(
}
}
override suspend fun convertToCryptoCurrency(
userWalletId: UserWalletId,
currency: ManagedCryptoCurrency.Custom,
): CryptoCurrency {
return when (currency) {
is ManagedCryptoCurrency.Custom.Coin -> createCoin(
userWalletId = userWalletId,
networkId = currency.network.id,
derivationPath = currency.network.derivationPath,
)
is ManagedCryptoCurrency.Custom.Token -> cryptoCurrencyFactory.createToken(
network = currency.network,
rawId = currency.currencyId.rawCurrencyId,
name = currency.name,
symbol = currency.symbol,
decimals = currency.decimals,
contractAddress = currency.contractAddress,
)
}
}
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> = withContext(dispatchers.io) {
val userWallet = requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"User wallet [$userWalletId] not found while getting supported networks"

View file

@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.api.safeApiCall
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensResponseFactory
@ -16,20 +17,21 @@ import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher
import com.tangem.data.managetokens.utils.ManagedCryptoCurrencyFactory
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.api.tangemTech.models.orDefault
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.TapWorkarounds.isTestCard
import com.tangem.domain.card.common.extensions.canHandleBlockchain
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.card.common.extensions.supportedBlockchains
import com.tangem.domain.card.common.extensions.supportedTokens
import com.tangem.domain.card.common.extensions.*
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.managetokens.model.*
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -40,7 +42,7 @@ import com.tangem.pagination.fetcher.LimitOffsetBatchFetcher.Request
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
internal class DefaultManageTokensRepository(
private val tangemTechApi: TangemTechApi,
private val userWalletsStore: UserWalletsStore,
@ -51,10 +53,16 @@ internal class DefaultManageTokensRepository(
private val excludedBlockchains: ExcludedBlockchains,
private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
private val dispatchers: CoroutineDispatcherProvider,
private val walletAccountsFetcher: WalletAccountsFetcher,
private val accountsFeatureToggles: AccountsFeatureToggles,
networkFactory: NetworkFactory,
) : ManageTokensRepository {
private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(networkFactory, excludedBlockchains)
private val managedCryptoCurrencyFactory = ManagedCryptoCurrencyFactory(
networkFactory = networkFactory,
excludedBlockchains = excludedBlockchains,
accountsFeatureToggles = accountsFeatureToggles,
)
private val userTokensResponseFactory = UserTokensResponseFactory()
// region getTokenListBatchFlow
@ -82,7 +90,10 @@ internal class DefaultManageTokensRepository(
val userWallet = request.params.userWalletId?.let(userWalletsStore::getSyncStrict)
if (userWallet is UserWallet.Cold && userWallet.scanResponse.card.isTestCard) {
fetchTestnetCurrencies(userWallet, request)
when (val params = request.params) {
is ManageTokensListConfig.Account -> fetchTestnetCurrencies(userWallet, params)
is ManageTokensListConfig.Wallet -> fetchTestnetCurrenciesLegacy(userWallet, params)
}
} else {
fetchCurrencies(
userWallet = userWallet,
@ -94,7 +105,6 @@ internal class DefaultManageTokensRepository(
},
)
@Suppress("ComplexCondition")
private suspend fun fetchCurrencies(
userWallet: UserWallet?,
request: Request<ManageTokensListConfig>,
@ -127,31 +137,22 @@ internal class DefaultManageTokensRepository(
coins = coinsResponse.coins.filterNot { l2BlockchainsCoinIds.contains(it.id) },
)
val tokensResponse = request.params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
// save tokens response only if loadUserTokensFromRemote is true and it means onboarding call
createAndSaveDefaultUserTokensResponse(userWallet = userWallet)
}
} else {
getSavedUserTokensResponseSync(userWalletId)
}
}
val items = if (isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
val items = when (val params = request.params) {
is ManageTokensListConfig.Account -> createManagedCryptoCurrencyList(
params = params,
userWallet = userWallet,
isFirstBatchFetching = isFirstBatchFetching,
loadUserTokensFromRemote = loadUserTokensFromRemote,
query = query,
updatedCoinsResponse = updatedCoinsResponse,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
is ManageTokensListConfig.Wallet -> createManagedCryptoCurrencyListLegacy(
params = params,
userWallet = userWallet,
isFirstBatchFetching = isFirstBatchFetching,
loadUserTokensFromRemote = loadUserTokensFromRemote,
query = query,
updatedCoinsResponse = updatedCoinsResponse,
)
}
@ -162,6 +163,112 @@ internal class DefaultManageTokensRepository(
)
}
private suspend fun createManagedCryptoCurrencyList(
params: ManageTokensListConfig.Account,
userWallet: UserWallet?,
isFirstBatchFetching: Boolean,
loadUserTokensFromRemote: Boolean,
query: String?,
updatedCoinsResponse: CoinsResponse,
): List<ManagedCryptoCurrency> {
val response = params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
runCatching { walletAccountsFetcher.fetch(userWalletId = userWallet.walletId) }.getOrNull()
} else {
walletAccountsFetcher.getSaved(userWalletId)
}
}
val accountId = when {
params.accountId == null -> null
loadUserTokensFromRemote -> {
AccountId.forCryptoPortfolio(
userWalletId = requireNotNull(params.accountId).userWalletId,
derivationIndex = DerivationIndex.Main,
)
}
else -> requireNotNull(params.accountId)
}
val accountDTO = if (response != null && accountId != null) {
response.accounts.firstOrNull { it.id == accountId.value }
} else {
null
}
val tokensResponse = response?.let {
UserTokensResponse(
group = response.wallet.group.orDefault(),
sort = response.wallet.sort.orDefault(),
tokens = accountDTO?.tokens.orEmpty(),
)
}
val isCreateWithCustom = isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
val items = if (isCreateWithCustom) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
}
return items
}
private suspend fun createManagedCryptoCurrencyListLegacy(
params: ManageTokensListConfig.Wallet,
userWallet: UserWallet?,
isFirstBatchFetching: Boolean,
loadUserTokensFromRemote: Boolean,
query: String?,
updatedCoinsResponse: CoinsResponse,
): List<ManagedCryptoCurrency> {
val tokensResponse = params.userWalletId?.let { userWalletId ->
if (loadUserTokensFromRemote && userWallet != null) {
safeApiCall({ tangemTechApi.getUserTokens(userWalletId.stringValue).bind() }) {
// save tokens response only if loadUserTokensFromRemote is true and it means onboarding call
createAndSaveDefaultUserTokensResponse(userWallet = userWallet)
}
} else {
getSavedUserTokensResponseSync(userWalletId)
}
}
val isCreateWithCustom = isFirstBatchFetching &&
tokensResponse != null &&
userWallet != null &&
query == null
return if (isCreateWithCustom) {
managedCryptoCurrencyFactory.createWithCustomTokens(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
)
} else {
managedCryptoCurrencyFactory.create(
coinsResponse = updatedCoinsResponse,
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = null,
)
}
}
private suspend fun createAndSaveDefaultUserTokensResponse(userWallet: UserWallet): UserTokensResponse {
val userTokensResponse = createDefaultUserTokensResponse(userWallet)
userTokenSaver.store(userWallet.walletId, userTokensResponse, useEnricher = false)
@ -170,9 +277,59 @@ internal class DefaultManageTokensRepository(
private suspend fun fetchTestnetCurrencies(
userWallet: UserWallet,
request: Request<ManageTokensListConfig>,
params: ManageTokensListConfig.Account,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val searchText = request.params.searchText
val searchText = params.searchText
val testnetTokensConfig = testnetTokensStorage.getConfig()
val response = params.userWalletId?.let { userWalletId ->
walletAccountsFetcher.getSaved(userWalletId)
}
val accountId = params.accountId
val accountDTO = if (response != null && accountId != null) {
response.accounts.firstOrNull { it.id == accountId.value }
} else {
null
}
val tokensResponse = response?.let {
UserTokensResponse(
group = response.wallet.group.orDefault(),
sort = response.wallet.sort.orDefault(),
tokens = accountDTO?.tokens.orEmpty(),
)
}
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
testnetTokensConfig = if (!searchText.isNullOrBlank()) {
testnetTokensConfig.copy(
tokens = testnetTokensConfig.tokens.filter { token ->
token.symbol.contains(other = searchText, ignoreCase = true) ||
token.name.contains(other = searchText, ignoreCase = true)
},
)
} else {
testnetTokensConfig
},
tokensResponse = tokensResponse,
userWallet = userWallet,
accountIndex = accountDTO?.derivationIndex?.let(DerivationIndex::invoke)?.getOrNull(),
)
return BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = true,
)
}
private suspend fun fetchTestnetCurrenciesLegacy(
userWallet: UserWallet,
params: ManageTokensListConfig.Wallet,
): BatchFetchResult.Success<List<ManagedCryptoCurrency>> {
val searchText = params.searchText
val testnetTokensConfig = testnetTokensStorage.getConfig()
val items = managedCryptoCurrencyFactory.createTestnetWithCustomTokens(
@ -188,6 +345,7 @@ internal class DefaultManageTokensRepository(
},
tokensResponse = getSavedUserTokensResponseSync(userWallet.walletId),
userWallet = userWallet,
accountIndex = null,
)
return BatchFetchResult.Success(

View file

@ -1,6 +1,7 @@
package com.tangem.data.managetokens.di
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.data.common.account.WalletAccountsFetcher
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.common.currency.UserTokensSaver
import com.tangem.data.common.network.NetworkFactory
@ -11,6 +12,7 @@ import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.local.config.testnet.TestnetTokensStorage
import com.tangem.datasource.local.token.UserTokensResponseStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.managetokens.repository.CustomTokensRepository
import com.tangem.domain.managetokens.repository.ManageTokensRepository
import com.tangem.domain.walletmanager.WalletManagersFacade
@ -38,6 +40,8 @@ internal object ManageTokensDataModule {
excludedBlockchains: ExcludedBlockchains,
cardCryptoCurrencyFactory: CardCryptoCurrencyFactory,
networkFactory: NetworkFactory,
accountsFeatureToggles: AccountsFeatureToggles,
walletAccountsFetcher: WalletAccountsFetcher,
): ManageTokensRepository {
return DefaultManageTokensRepository(
tangemTechApi = tangemTechApi,
@ -50,6 +54,8 @@ internal object ManageTokensDataModule {
cardCryptoCurrencyFactory = cardCryptoCurrencyFactory,
networkFactory = networkFactory,
dispatchers = dispatchers,
accountsFeatureToggles = accountsFeatureToggles,
walletAccountsFetcher = walletAccountsFetcher,
)
}

View file

@ -13,9 +13,11 @@ import com.tangem.data.common.network.NetworkFactory
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.datasource.api.tangemTech.models.UserTokensResponse
import com.tangem.datasource.local.config.testnet.models.TestnetTokensConfig
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.card.common.extensions.canHandleToken
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency
import com.tangem.domain.managetokens.model.ManagedCryptoCurrency.SourceNetwork
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -26,15 +28,17 @@ import timber.log.Timber
internal class ManagedCryptoCurrencyFactory(
private val networkFactory: NetworkFactory,
private val excludedBlockchains: ExcludedBlockchains,
private val accountsFeatureToggles: AccountsFeatureToggles,
) {
fun create(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
return coinsResponse.coins.mapNotNull { coin ->
createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet)
createToken(coin, tokensResponse, coinsResponse.imageHost, userWallet, accountIndex)
}
}
@ -42,9 +46,10 @@ internal class ManagedCryptoCurrencyFactory(
coinsResponse: CoinsResponse,
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
val customTokens = createCustomTokens(tokensResponse, userWallet)
val tokens = create(coinsResponse, tokensResponse, userWallet)
val customTokens = createCustomTokens(tokensResponse, userWallet, accountIndex)
val tokens = create(coinsResponse, tokensResponse, userWallet, accountIndex)
return customTokens + tokens
}
@ -53,9 +58,10 @@ internal class ManagedCryptoCurrencyFactory(
testnetTokensConfig: TestnetTokensConfig,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> {
val customTokens = tokensResponse
?.let { createCustomTokens(it, userWallet) }
?.let { createCustomTokens(it, userWallet, accountIndex) }
?: emptyList()
val testnetTokens = testnetTokensConfig.tokens.map { testnetToken ->
ManagedCryptoCurrency.Token(
@ -69,9 +75,10 @@ internal class ManagedCryptoCurrencyFactory(
contractAddress = network.address,
decimals = network.decimalCount,
userWallet = userWallet,
accountIndex = accountIndex,
)
} ?: emptyList(),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet),
addedIn = findAddedInNetworks(testnetToken.id, tokensResponse, userWallet, accountIndex),
)
}
@ -81,28 +88,47 @@ internal class ManagedCryptoCurrencyFactory(
private fun createCustomTokens(
tokensResponse: UserTokensResponse,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): List<ManagedCryptoCurrency> = tokensResponse.tokens
.mapNotNull { token ->
maybeCreateCustomToken(token, userWallet)
maybeCreateCustomToken(token, userWallet, accountIndex)
}
private fun maybeCreateCustomToken(
token: UserTokensResponse.Token,
userWallet: UserWallet,
accountIndex: DerivationIndex?,
): ManagedCryptoCurrency? {
val blockchain = Blockchain.fromNetworkId(token.networkId)
?.takeUnless { it in excludedBlockchains }
?: return null
if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) {
return null
val network = if (accountsFeatureToggles.isFeatureEnabled) {
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
if (!checkIsCustomToken(token, network.derivationPath)) {
return null
}
network
} else {
if (!checkIsCustomToken(token, blockchain, userWallet.derivationStyleProvider)) {
return null
}
networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
}
val network = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = token.derivationPath,
userWallet = userWallet,
) ?: return null
val contractAddress = token.contractAddress
return if (contractAddress.isNullOrBlank()) {
@ -135,6 +161,7 @@ internal class ManagedCryptoCurrencyFactory(
tokensResponse: UserTokensResponse?,
imageHost: String?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): ManagedCryptoCurrency? {
if (coinResponse.networks.isEmpty() || !coinResponse.active) return null
@ -146,6 +173,7 @@ internal class ManagedCryptoCurrencyFactory(
contractAddress = network.contractAddress,
decimals = network.decimalCount?.toInt(),
userWallet = userWallet,
accountIndex = accountIndex,
)
}
.ifEmpty { return null }
@ -156,7 +184,7 @@ internal class ManagedCryptoCurrencyFactory(
symbol = coinResponse.symbol,
iconUrl = getIconUrl(coinResponse.id, imageHost),
availableNetworks = availableNetworks,
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet),
addedIn = findAddedInNetworks(coinResponse.id, tokensResponse, userWallet, accountIndex),
)
}
@ -166,6 +194,7 @@ internal class ManagedCryptoCurrencyFactory(
decimals: Int?,
userWallet: UserWallet?,
extraDerivationPath: String? = null,
accountIndex: DerivationIndex?,
): SourceNetwork? {
val blockchain = Blockchain.fromNetworkId(networkId)
?.takeUnless { it in excludedBlockchains }
@ -176,6 +205,7 @@ internal class ManagedCryptoCurrencyFactory(
extraDerivationPath = extraDerivationPath,
derivationStyleProvider = userWallet?.derivationStyleProvider,
canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) == true,
accountIndex = accountIndex,
) ?: return null
return when {
@ -205,6 +235,7 @@ internal class ManagedCryptoCurrencyFactory(
currencyId: String,
tokensResponse: UserTokensResponse?,
userWallet: UserWallet?,
accountIndex: DerivationIndex?,
): Set<Network> {
if (tokensResponse == null) return emptySet()
@ -219,6 +250,7 @@ internal class ManagedCryptoCurrencyFactory(
extraDerivationPath = token.derivationPath,
derivationStyleProvider = userWallet?.derivationStyleProvider,
canHandleTokens = userWallet?.canHandleToken(blockchain, excludedBlockchains) != false,
accountIndex = accountIndex,
)
} else {
null
@ -237,6 +269,10 @@ internal class ManagedCryptoCurrencyFactory(
): Boolean = token.id.isNullOrBlank() ||
checkIsCustomDerivationPath(token.derivationPath, blockchain, derivationStyleProvider)
private fun checkIsCustomToken(token: UserTokensResponse.Token, derivationPath: Network.DerivationPath): Boolean {
return token.id.isNullOrBlank() || derivationPath is Network.DerivationPath.Custom
}
private fun checkIsCustomDerivationPath(
derivationPath: String?,
blockchain: Blockchain,

View file

@ -0,0 +1,16 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository.&lt;no name provided&gt;$val last = res.tokens.size &lt; request.limit</ID>
<ID>MultilineLambdaItParameter:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository${ val error = it as QuotesFetcher.Error.ApiOperationError val errorEvent = createDetailsErrorEvent( error = error.apiError, request = MarketsDataAnalyticsEvent.Details.Error.Request.Info, tokenSymbol = tokenSymbol, ) analyticsEventHandler.send(errorEvent.toEvent()) throw error.apiError }</ID>
<ID>MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ it.copy( tokenCharts = TokenMarketChartsConverter.convert( chartsToCopy = it.tokenCharts, tokenId = it.id, interval = updateRequest.interval, value = update, ), ) }</ID>
<ID>MultilineLambdaItParameter:MarketsBatchUpdateFetcher.kt$MarketsBatchUpdateFetcher${ val exception = if (it is QuotesFetcher.Error.ApiOperationError) { onApiResponseError(it.apiError) it.apiError } else { error("Cause: $it") } throw exception }</ID>
<ID>MultilineLambdaItParameter:TokenMarketInfoConverter.kt$TokenMarketInfoConverter${ TokenMarketInfo.Link( title = it.title, id = it.id, link = it.link, ) }</ID>
<ID>NullableBooleanCheck:TokenMarketListConverter.kt$TokenMarketListConverter$token.isUnderMarketCapLimit ?: false</ID>
<ID>SuspendFunWithFlowReturnType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$suspend</ID>
<ID>UnsafeCallOnNullableType:DefaultMarketsTokenRepository.kt$DefaultMarketsTokenRepository$network.contractAddress!!</ID>
<ID>UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.Details$mapOf()</ID>
<ID>UseEmptyCounterpart:MarketsDataAnalyticsEvent.kt$MarketsDataAnalyticsEvent.List$mapOf()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -22,6 +22,7 @@ import com.tangem.datasource.local.datastore.RuntimeStateStore
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.markets.*
import com.tangem.domain.markets.repositories.MarketsTokenRepository
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.pagination.*
@ -250,6 +251,7 @@ internal class DefaultMarketsTokenRepository(
userWalletId: UserWalletId,
token: TokenMarketParams,
network: TokenMarketInfo.Network,
accountIndex: DerivationIndex?,
): CryptoCurrency? {
val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found")
val blockchain = Blockchain.fromNetworkId(network.networkId) ?: error("Unknown network [${network.networkId}]")
@ -259,12 +261,14 @@ internal class DefaultMarketsTokenRepository(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = accountIndex,
)
} else {
val currencyNetwork = networkFactory.create(
blockchain = blockchain,
extraDerivationPath = null,
userWallet = userWallet,
accountIndex = accountIndex,
) ?: return null
cryptoCurrencyFactory.createToken(

View file

@ -48,13 +48,10 @@ dependencies {
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(tangemDeps.blockchain)
testImplementation(tangemDeps.card.core)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:CommonNetworkStatusFetcher.kt$CommonNetworkStatusFetcher${ Timber.e("Failed to fetch network status for $userWalletId [${network.rawId}]: $it") networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) }</ID>
<ID>MultilineLambdaItParameter:DefaultMultiNetworkStatusFetcher.kt$DefaultMultiNetworkStatusFetcher${ networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) raise(it) }</ID>
<ID>MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return emptyList() }</ID>
<ID>MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return@withContext }</ID>
<ID>MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( id = currencyIdConverter.convertBack(value = it.key), amount = amount.value, ) }</ID>
<ID>MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val currencyId = currencyIdConverter.convert(value = it.id) val amount = NetworkStatus.Amount.Loaded(value = it.amount) currencyId to amount }</ID>
<ID>MultilineLambdaItParameter:NetworkStatusSupplierModule.kt$NetworkStatusSupplierModule.&lt;no name provided&gt;${ "single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" + it.network.derivationPath.value }</ID>
<ID>MultilineLambdaItParameter:NetworkYieldSupplyStatusConverter.kt$NetworkYieldSupplyStatusConverter${ 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 }</ID>
<ID>SuspendFunSwallowedCancellation:DefaultNetworksRepository.kt$DefaultNetworksRepository$runCatching</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,10 +1,10 @@
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 com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -1,9 +1,9 @@
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 com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest

View file

@ -3,10 +3,10 @@ package com.tangem.data.networks.converters
import com.google.common.truth.Truth
import com.tangem.datasource.local.network.entity.NetworkStatusDM
import com.tangem.domain.models.network.Network
import com.tangem.test.core.ProvideTestModels
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]
@ -18,7 +18,7 @@ class NetworkDerivationPathConverterTest {
inner class Convert {
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convert(model: ConvertModel) {
// Act
val actual = NetworkDerivationPathConverter.convert(value = model.value)
@ -57,7 +57,7 @@ class NetworkDerivationPathConverterTest {
inner class ConvertBack {
@ParameterizedTest
@MethodSource("provideTestModels")
@ProvideTestModels
fun convertBack(model: ConvertBackModel) {
// Act
val actual = NetworkDerivationPathConverter.convertBack(value = model.value)

View file

@ -2,7 +2,6 @@ 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
@ -14,6 +13,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal

View file

@ -2,7 +2,6 @@ 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.*
@ -13,6 +12,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.network.NetworkStatus.Amount
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
import java.math.BigDecimal

View file

@ -7,7 +7,6 @@ import com.tangem.blockchainsdk.models.UpdateWalletManagerResult
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.walletmanager.MockUpdateWalletManagerResultFactory
import com.tangem.common.test.utils.ProvideTestModels
import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.data.networks.store.storeStatus
@ -16,6 +15,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.domain.walletmanager.WalletManagersFacade
import com.tangem.test.core.ProvideTestModels
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest

View file

@ -3,7 +3,6 @@ package com.tangem.data.networks.multi
import arrow.core.Either
import arrow.core.left
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertEither
import com.tangem.data.common.currency.CardCryptoCurrencyFactory
import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher
import com.tangem.data.networks.store.NetworksStatusesStore
@ -11,6 +10,7 @@ import com.tangem.data.networks.store.setSourceAsCache
import com.tangem.data.networks.store.setSourceAsOnlyCache
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.test.core.assertEither
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.test.runTest

View file

@ -5,7 +5,6 @@ import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.common.network.NetworkFactory
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.store.NetworksStatusesStore
@ -14,6 +13,7 @@ import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.flow.*

View file

@ -3,10 +3,10 @@ package com.tangem.data.networks.single
import arrow.core.Either
import arrow.core.left
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.assertEither
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.single.SingleNetworkStatusFetcher
import com.tangem.test.core.assertEither
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify

View file

@ -3,12 +3,12 @@ package com.tangem.data.networks.single
import com.google.common.truth.Truth
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk

View file

@ -5,11 +5,11 @@ import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.datastore.MockStateDataStore
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.test.utils.getEmittedValues
import com.tangem.data.networks.models.SimpleNetworkStatus
import com.tangem.data.networks.toSimple
import com.tangem.datasource.local.datastore.RuntimeSharedStore
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.coroutines.test.runTest

1
data/news/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,53 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
alias(deps.plugins.kotlin.kapt)
id("configuration")
}
android {
namespace = "com.tangem.data.news"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// region Project - Core
implementation(projects.core.datasource)
api(projects.core.utils)
// endregion
// region Project - Data
implementation(projects.data.common)
// endregion
// region Project - Domain
implementation(projects.domain.news)
// endregion
// region Project - Libs
implementation(projects.libs.blockchainSdk)
// endregion
// region DI
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
// endregion
// region Other libraries
implementation(deps.androidx.datastore)
implementation(deps.moshi.kotlin)
implementation(deps.timber)
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
// endregion
}

View file

@ -0,0 +1,25 @@
package com.tangem.data.news.di
import com.tangem.data.news.repository.DefaultNewsRepository
import com.tangem.datasource.api.news.NewsApi
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal object NewsDataModule {
@Singleton
@Provides
fun provideNewsRepository(newsApi: NewsApi, dispatchers: CoroutineDispatcherProvider): NewsRepository {
return DefaultNewsRepository(
newsApi = newsApi,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.data.news.repository
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.news.NewsApi
import com.tangem.domain.news.model.NewsListBatchFlow
import com.tangem.domain.news.model.NewsListBatchingContext
import com.tangem.domain.news.model.NewsListConfig
import com.tangem.domain.news.models.ArticleCategory
import com.tangem.domain.news.models.DetailedArticle
import com.tangem.domain.news.models.ShortArticle
import com.tangem.domain.news.repository.NewsRepository
import com.tangem.pagination.BatchFetchResult
import com.tangem.pagination.BatchListSource
import com.tangem.pagination.exception.EndOfPaginationException
import com.tangem.pagination.fetcher.BatchFetcher
import com.tangem.pagination.toBatchFlow
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import javax.inject.Inject
/**
* Implementation of [NewsRepository].
[REDACTED_AUTHOR]
*/
internal class DefaultNewsRepository @Inject constructor(
private val newsApi: NewsApi,
private val dispatchers: CoroutineDispatcherProvider,
) : NewsRepository {
override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow {
return BatchListSource(
fetchDispatcher = dispatchers.io,
context = context,
generateNewKey = { keys -> keys.lastOrNull()?.inc() ?: INITIAL_BATCH_KEY },
batchFetcher = createBatchFetcher(batchSize),
).toBatchFlow()
}
override suspend fun getDetailedArticle(newsId: Int, language: String?): DetailedArticle {
val response = newsApi.getNewsDetails(newsId = newsId, language = language).getOrThrow()
return response.toDomainDetailedArticle()
}
override suspend fun getTrendingNews(limit: Int, language: String?): List<ShortArticle> {
val response = newsApi.getTrendingNews(limit = limit, language = language).getOrThrow()
return response.items.map { it.toDomainShortArticle() }
}
override suspend fun getCategories(): List<ArticleCategory> {
return newsApi.getCategories().getOrThrow().items.map { dto ->
ArticleCategory(
id = dto.id,
name = dto.name,
)
}
}
private fun createBatchFetcher(batchSize: Int): BatchFetcher<NewsListConfig, List<ShortArticle>> {
return NewsBatchFetcher(
newsApi = newsApi,
batchSize = batchSize,
)
}
private class NewsBatchFetcher(
private val newsApi: NewsApi,
private val batchSize: Int,
) : BatchFetcher<NewsListConfig, List<ShortArticle>> {
private var state: NewsPaginationState? = null
override suspend fun fetchFirst(requestParams: NewsListConfig): BatchFetchResult<List<ShortArticle>> {
return runSuspendCatching {
loadPage(
page = FIRST_PAGE,
params = requestParams,
limit = batchSize,
snapshotOverride = requestParams.snapshot,
)
}.fold(
onSuccess = { result ->
state = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
override suspend fun fetchNext(
overrideRequestParams: NewsListConfig?,
lastResult: BatchFetchResult<List<ShortArticle>>,
): BatchFetchResult<List<ShortArticle>> {
val currentState = state ?: return BatchFetchResult.Error(
IllegalStateException("fetchFirst must be called"),
)
if (lastResult is BatchFetchResult.Success && lastResult.last && overrideRequestParams == null) {
return BatchFetchResult.Error(EndOfPaginationException())
}
val params = overrideRequestParams ?: currentState.params
val shouldReset = overrideRequestParams != null && overrideRequestParams != currentState.params
val pageToLoad = if (shouldReset) FIRST_PAGE else currentState.nextPage
val snapshot = if (shouldReset) overrideRequestParams.snapshot else currentState.snapshot
return runSuspendCatching {
loadPage(
page = pageToLoad,
params = params,
limit = batchSize,
snapshotOverride = snapshot,
)
}.fold(
onSuccess = { result ->
state = result.state
result.batchResult
},
onFailure = { throwable -> BatchFetchResult.Error(throwable) },
)
}
private suspend fun loadPage(
page: Int,
params: NewsListConfig,
limit: Int,
snapshotOverride: String?,
): PageLoadResult {
val response = newsApi.getNews(
page = page,
limit = limit,
language = params.language,
snapshot = snapshotOverride,
tokenIds = params.tokenIds.takeIf { it.isNotEmpty() },
categoryIds = params.categoryIds.takeIf { it.isNotEmpty() },
).getOrThrow()
val items = response.items.map { it.toDomainShortArticle() }
val batchResult = BatchFetchResult.Success(
data = items,
empty = items.isEmpty(),
last = response.meta.hasNext.not(),
)
return PageLoadResult(
batchResult = batchResult,
state = NewsPaginationState(
nextPage = response.meta.page + 1,
snapshot = response.meta.asOf,
params = params.copy(snapshot = response.meta.asOf),
),
)
}
}
private data class PageLoadResult(
val batchResult: BatchFetchResult.Success<List<ShortArticle>>,
val state: NewsPaginationState,
)
private data class NewsPaginationState(
val nextPage: Int,
val snapshot: String?,
val params: NewsListConfig,
)
private companion object {
private const val INITIAL_BATCH_KEY = 0
private const val FIRST_PAGE = 1
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.data.news.repository
import com.tangem.datasource.api.news.models.response.NewsArticleDto
import com.tangem.datasource.api.news.models.response.NewsDetailsResponse
import com.tangem.datasource.api.news.models.response.NewsOriginalArticleDto
import com.tangem.datasource.api.news.models.response.NewsRelatedTokenDto
import com.tangem.domain.news.models.*
internal fun NewsDetailsResponse.toDomainDetailedArticle(): DetailedArticle {
return DetailedArticle(
id = id,
createdAt = createdAt,
score = score.toFloat(),
locale = language,
isTrending = isTrending,
categories = categories.map { ArticleCategory(id = it.id, name = it.name) },
relatedTokens = relatedTokens.map { it.toDomainRelatedToken() },
title = title,
newsUrl = newsUrl,
shortContent = shortContent,
content = content,
originalArticles = originalArticles.map { it.toDomainOriginalArticle() },
)
}
internal fun NewsArticleDto.toDomainShortArticle(): ShortArticle {
return ShortArticle(
id = id,
createdAt = createdAt,
score = score.toFloat(),
locale = language,
categories = categories.map { ArticleCategory(id = it.id, name = it.name) },
relatedTokens = relatedTokens.map { it.toDomainRelatedToken() },
isTrending = isTrending,
title = title,
newsUrl = newsUrl,
)
}
internal fun NewsRelatedTokenDto.toDomainRelatedToken(): RelatedToken {
return RelatedToken(
id = id,
symbol = symbol,
name = name,
)
}
internal fun NewsOriginalArticleDto.toDomainOriginalArticle(): OriginalArticle {
return OriginalArticle(
id = id,
title = title,
sourceName = sourceName,
locale = language,
publishedAt = publishedAt,
url = url,
imageUrl = imageUrl,
)
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ NFTCollections( network = network, content = NFTCollections.Content.Collections( collections = it ?.map { collection -&gt; nftSdkCollectionConverter.convert(network to collection) } ?.filter { it.id !is NFTCollection.Identifier.Unknown }, source = StatusSource.CACHE, ), ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ getNFTPersistenceStore(userWalletId, it).clear() getNFTRuntimeStore(userWalletId, it).clear() }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it !is UnsupportedOperationException) { saveFailedStateInRuntime( userWalletId = userWalletId, network = network, error = it, ) } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.id == collectionId) { it.changeAssetsStatusSource(source) } else { it } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ if (it.identifier == sdkCollectionId) { it.copy(assets = assets) } else { it } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ networkFactory.create( blockchain = it, extraDerivationPath = null, userWallet = userWallet, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ nftRuntimeStores[storeId] = it val storedCollections = getStoredCollections(userWalletId, network) val storedPrices = getStoredPrices(userWalletId, network) it.initialize( collections = storedCollections, prices = storedPrices, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ saveCollectionsInRuntime( userWalletId = userWalletId, network = network, collections = it, ) saveCollectionsInPersistence( userWalletId = userWalletId, network = network, collections = it, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ saveFailedStateInRuntime( userWalletId = userWalletId, network = network, error = it, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ storedCollections.copy( content = it, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val (assetId, _) = it assetIdConverter.convert(assetId) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val (assetId, price) = it val nftCurrency = getNFTCurrency(network) NFTSalePrice.Value( assetId = assetId, value = price.value, fiatValue = null, symbol = nftCurrency.symbol, decimals = nftCurrency.decimals, ) }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val assetId = assetIdConverter.convert(it.identifier) val price = getNFTRuntimeStore(userWalletId, network).getSalePriceSync(assetId) if (price is NFTSalePrice.Empty || price is NFTSalePrice.Error) { refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier) } }</ID>
<ID>MultilineLambdaItParameter:DefaultNFTRepository.kt$DefaultNFTRepository${ val sdkAssetId = assetIdConverter.convertBack(assetId) saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it) }</ID>
<ID>NamedArguments:DefaultNFTRepository.kt$DefaultNFTRepository$refreshSalePrice(userWalletId, network, sdkCollectionId, it.identifier)</ID>
<ID>NamedArguments:DefaultNFTRepository.kt$DefaultNFTRepository$saveSalePriceInPersistence(userWalletId, network, sdkAssetId, it)</ID>
<ID>NoNameShadowing:DefaultNFTRepository.kt$DefaultNFTRepository${ it.id !is NFTCollection.Identifier.Unknown }</ID>
<ID>SuspendFunSwallowedCancellation:DefaultNFTRepository.kt$DefaultNFTRepository$runCatching</ID>
<ID>SuspendFunWithFlowReturnType:DefaultNFTRepository.kt$DefaultNFTRepository$suspend</ID>
<ID>UnnecessaryLet:DefaultNFTRepository.kt$DefaultNFTRepository$let { prices -&gt; prices .mapKeys { val (assetId, _) = it assetIdConverter.convert(assetId) } .mapValues { val (assetId, price) = it val nftCurrency = getNFTCurrency(network) NFTSalePrice.Value( assetId = assetId, value = price.value, fiatValue = null, symbol = nftCurrency.symbol, decimals = nftCurrency.decimals, ) } }</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -29,6 +29,7 @@ dependencies {
implementation(projects.domain.walletManager)
implementation(projects.domain.appTheme.models)
implementation(projects.domain.models)
implementation(projects.domain.express.models)
// region DI

View file

@ -0,0 +1,17 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>BooleanPropertyNaming:DefaultOnrampRepository.kt$DefaultOnrampRepository$val cachedValue = onrampSepaAvailabilityStore.getSyncOrNull(key)</ID>
<ID>MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ Timber.e(it, "Unable to fetch hot crypto") analyticsEventHandler.send( event = MainScreenAnalyticsEvent.HotTokenError( errorCode = (it as? ApiResponseError.HttpException)?.code?.numericCode?.toString().orEmpty(), ), ) }</ID>
<ID>MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ if (it.id == OLD_POLYGON_NAME) { it.copy(id = NEW_POLYGON_NAME) } else { it } }</ID>
<ID>MultilineLambdaItParameter:DefaultHotCryptoRepository.kt$DefaultHotCryptoRepository${ val userWallet = userWalletsStore.getSyncOrNull(userWalletId) ?: error("UserWalletId [$userWalletId] not found") HotCryptoCurrencyConverter( userWallet = userWallet, imageHost = it.imageHost, excludedBlockchains = excludedBlockchains, ) .convertList(input = it.tokens) .filterNotNull() }</ID>
<ID>MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch express providers") throw it }</ID>
<ID>MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch onramp pairs") throw it }</ID>
<ID>MultilineLambdaItParameter:DefaultOnrampRepository.kt$DefaultOnrampRepository${ Timber.w(it, "Unable to fetch onramp payment methods") throw it }</ID>
<ID>UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(countryConverter::convert)</ID>
<ID>UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(paymentMethodsConverter::convertList)</ID>
<ID>UnnecessaryLet:DefaultOnrampRepository.kt$DefaultOnrampRepository$let(statusConverter::convert)</ID>
<ID>UseOrEmpty:DefaultOnrampTransactionRepository.kt$DefaultOnrampTransactionRepository$stored ?: emptySet()</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -12,7 +12,6 @@ import com.tangem.data.onramp.converters.error.OnrampErrorConverter
import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.express.TangemExpressApi
import com.tangem.datasource.api.express.models.TangemExpressValues
import com.tangem.datasource.api.express.models.response.ExchangeProvider
import com.tangem.datasource.api.express.models.response.ExchangeProviderType
import com.tangem.datasource.api.express.models.response.ExpressErrorResponse
@ -38,6 +37,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getObject
import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull
import com.tangem.datasource.local.preferences.utils.storeObject
import com.tangem.domain.express.models.ExpressAsset
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -560,7 +560,7 @@ internal class DefaultOnrampRepository(
}
private fun CryptoCurrency.getContractAddress(): String = when (this) {
is CryptoCurrency.Coin -> TangemExpressValues.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Coin -> ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE
is CryptoCurrency.Token -> this.contractAddress
}

View file

@ -44,6 +44,12 @@ internal class DefaultOnrampTransactionRepository(
}
}
override fun getAllTransactions(): Flow<List<OnrampTransaction>> {
return appPreferencesStore
.getObjectSet<OnrampTransactionDTO>(PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY)
.map { transactions -> transactions.map(transactionConverter::convert) }
}
override suspend fun getTransactionById(txId: String): OnrampTransaction? = withContext(dispatchers.io) {
val stored = appPreferencesStore.getObjectSetSync<OnrampTransactionDTO>(
PreferencesKeys.ONRAMP_TRANSACTIONS_STATUSES_KEY,

View file

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

View file

@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getSepaPromoBanner()?.isActive ?: false</ID>
<ID>NullableBooleanCheck:DefaultPromoRepository.kt$DefaultPromoRepository$getVisaPromoBanner()?.isActive ?: false</ID>
<ID>SuspendFunSwallowedCancellation:DefaultPromoRepository.kt$DefaultPromoRepository$runCatching</ID>
<ID>SuspendFunWithFlowReturnType:DefaultPromoRepository.kt$DefaultPromoRepository$suspend</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -48,11 +48,8 @@ dependencies {
// endregion
// region Tests
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(projects.common.test)
testImplementation(projects.test.core)
// endregion
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MultilineLambdaItParameter:DefaultMultiQuoteStatusFetcher.kt$DefaultMultiQuoteStatusFetcher${ Timber.e(it) quotesStatusesStore.setSourceAsOnlyCache(currenciesIds = params.currenciesIds) }</ID>
</CurrentIssues>
</SmellBaseline>

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