Updated on 2026-08-14

This commit is contained in:
Tangem 2025-11-05 12:22:31 +04:00
parent 17a97c66b6
commit 5a8f4299a6
8 changed files with 621 additions and 2 deletions

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

@ -132,6 +132,17 @@ internal class DefaultAccountsCRUDRepository(
}
}
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) {
val converter = convertersContainer.createCryptoPortfolioConverter(userWalletId = accountList.userWalletId)

View file

@ -62,6 +62,13 @@ interface AccountsCRUDRepository {
*/
suspend fun fetchArchivedAccounts(userWalletId: UserWalletId)
/**
* Saves a list of accounts to the repository
*
* @param accountList the list of accounts to be saved.
*/
suspend fun saveAccountsLocally(accountList: AccountList)
/**
* Saves a list of accounts to the repository
*

View file

@ -58,6 +58,18 @@ internal object AccountStatusUseCaseModule {
)
}
@Provides
@Singleton
fun provideApplyTokenListSortingUseCaseV2(
accountsCRUDRepository: AccountsCRUDRepository,
dispatchers: CoroutineDispatcherProvider,
): ApplyTokenListSortingUseCaseV2 {
return ApplyTokenListSortingUseCaseV2(
accountsCRUDRepository = accountsCRUDRepository,
dispatchers = dispatchers,
)
}
@Provides
@Singleton
fun provideGetWalletTotalBalanceUseCaseV2(

View file

@ -4,6 +4,8 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.serialization.Serializable
typealias AccountCryptoCurrencies = Map<Account, List<CryptoCurrency>>
/**
* Combines an [Account] with its corresponding [CryptoCurrency].
*

View file

@ -0,0 +1,203 @@
package com.tangem.domain.account.status.usecase
import arrow.core.*
import arrow.core.raise.*
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.account.status.model.AccountCryptoCurrencies
import com.tangem.domain.core.utils.eitherOn
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListSortingError
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import timber.log.Timber
private typealias SortingErrorByAccountId = MutableMap<AccountId, TokenListSortingError>
/**
* Use case to apply token list sorting and grouping preferences for accounts.
*
* @property accountsCRUDRepository Repository for CRUD operations on accounts.
* @property dispatchers Coroutine dispatcher provider for managing threading.
*/
class ApplyTokenListSortingUseCaseV2(
private val accountsCRUDRepository: AccountsCRUDRepository,
private val dispatchers: CoroutineDispatcherProvider,
) {
/**
* Applies the token list sorting and grouping preferences for the given accounts.
*
* @param sortedTokensIdsByAccount A map of accounts to their sorted token IDs.
* @param isGroupedByNetwork Boolean indicating if tokens should be grouped by network.
* @param isSortedByBalance Boolean indicating if tokens should be sorted by balance.
* @return Either a [TokenListSortingError] or Unit on success.
*/
suspend operator fun invoke(
sortedTokensIdsByAccount: AccountCryptoCurrencies,
isGroupedByNetwork: Boolean,
isSortedByBalance: Boolean,
): Either<TokenListSortingError, Unit> = eitherOn(dispatchers.default) {
ensure(sortedTokensIdsByAccount.isNotEmpty()) {
raise(TokenListSortingError.TokenListIsEmpty)
}
val userWalletId = sortedTokensIdsByAccount.keys.first().userWalletId
val accountList = getAccountList(userWalletId)
val maybeSortedAccountList = sortAccountList(
accountList = accountList,
sortedTokensIdsByAccount = sortedTokensIdsByAccount,
isSortedByBalance = isSortedByBalance,
isGroupedByNetwork = isGroupedByNetwork,
)
val sortedAccountList = maybeSortedAccountList.getOrNull()
val isSortingTypeChanged = accountList.isSortedByBalance() != isSortedByBalance
val isGroupingTypeChanged = accountList.isGroupedByNetwork() != isGroupedByNetwork
if (sortedAccountList != null || isSortingTypeChanged || isGroupingTypeChanged) {
applySorting(sortedAccountList ?: accountList)
}
maybeSortedAccountList.toEitherNeg()
.onLeft { errorByAccountId ->
Timber.e(
"""
Unable to sort tokens for accounts: ${
errorByAccountId.entries.joinToString { "${it.key.value}: ${it.value}" }
}
""".trimIndent(),
)
raise(TokenListSortingError.UnableToSortAccounts(errorByAccountId.keys.map { it.value }))
}
}
private suspend fun Raise<TokenListSortingError>.getAccountList(userWalletId: UserWalletId): AccountList {
return catchOrDataError {
accountsCRUDRepository.getAccountListSync(userWalletId = userWalletId).getOrElse {
throw IllegalStateException("Account list not found for wallet $userWalletId")
}
}
}
private fun AccountList.isSortedByBalance(): Boolean = sortType == TokensSortType.BALANCE
private fun AccountList.isGroupedByNetwork(): Boolean = groupType == TokensGroupType.NETWORK
private fun Raise<TokenListSortingError>.sortAccountList(
accountList: AccountList,
sortedTokensIdsByAccount: AccountCryptoCurrencies,
isSortedByBalance: Boolean,
isGroupedByNetwork: Boolean,
): Ior<SortingErrorByAccountId, AccountList> {
val errors = mutableMapOf<AccountId, TokenListSortingError>()
val updatedAccountList = AccountList(
userWalletId = accountList.userWalletId,
accounts = accountList.accounts.sortTokens(sortedTokensIdsByAccount, errors),
totalAccounts = accountList.totalAccounts,
sortType = if (isSortedByBalance) TokensSortType.BALANCE else TokensSortType.NONE,
groupType = if (isGroupedByNetwork) TokensGroupType.NETWORK else TokensGroupType.NONE,
).getOrElse {
raise(TokenListSortingError.UnableToSortTokenList)
}
return if (errors.isEmpty()) {
updatedAccountList.rightIor()
} else if (errors.size == sortedTokensIdsByAccount.keys.size) {
errors.leftIor()
} else {
(errors to updatedAccountList).bothIor()
}
}
private fun List<Account>.sortTokens(
sortedTokensIdsByAccount: AccountCryptoCurrencies,
errors: MutableMap<AccountId, TokenListSortingError>,
): List<Account> {
return map { account ->
if (account !is Account.CryptoPortfolio) return@map account
val sortedCurrencies = sortedTokensIdsByAccount[account] ?: return@map account
val accountCurrencies = account.cryptoCurrencies.toList()
.sortTokens(sortedCurrencies = sortedCurrencies)
.getOrElse {
errors[account.accountId] = it
return@map account
}
.toSet()
account.copy(cryptoCurrencies = accountCurrencies)
}
}
private fun List<CryptoCurrency>.sortTokens(
sortedCurrencies: List<CryptoCurrency>,
): Either<TokenListSortingError, List<CryptoCurrency>> = either {
val nonEmptySortedTokensIds = ensureNotNull(sortedCurrencies.toNonEmptySetOrNull()) {
TokenListSortingError.TokenListIsEmpty
}
val sortedTokens = sortedMapOf<Int, CryptoCurrency>()
distinct().forEach { currency ->
val index = nonEmptySortedTokensIds.indexOfFirst { sortedCurrency ->
sortedCurrency.id == currency.id
}
if (index >= 0) {
sortedTokens[index] = currency
} else {
raise(TokenListSortingError.UnableToSortTokenList)
}
}
ensureNotNull(sortedTokens.values.toNonEmptyListOrNull()) {
TokenListSortingError.TokenListIsEmpty
}
}
private suspend fun Raise<TokenListSortingError>.applySorting(accountList: AccountList) {
coroutineScope {
val results = awaitAll(
async {
Either.catch { accountsCRUDRepository.saveAccountsLocally(accountList) }
},
async {
Either.catch { accountsCRUDRepository.syncTokens(accountList.userWalletId) }
},
)
ensure(results.none { it.isLeft() }) {
val message = results.mapNotNull { it.leftOrNull() }.joinToString()
raise(TokenListSortingError.DataError(IllegalStateException(message)))
}
}
}
private fun Ior<SortingErrorByAccountId, AccountList>.toEitherNeg(): Either<SortingErrorByAccountId, AccountList> {
return when (this) {
is Ior.Left -> this.value.left()
is Ior.Right -> this.value.right()
is Ior.Both -> this.leftValue.left()
}
}
private suspend fun <A> Raise<TokenListSortingError>.catchOrDataError(block: suspend () -> A): A {
return catch(
block = { block() },
catch = { raise(TokenListSortingError.DataError(it)) },
)
}
}

View file

@ -0,0 +1,375 @@
package com.tangem.domain.account.status.usecase
import arrow.core.left
import arrow.core.right
import arrow.core.some
import com.google.common.truth.Truth
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsCRUDRepository
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.account.DerivationIndex
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.error.TokenListSortingError
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class ApplyTokenListSortingUseCaseTest {
private val accountsCRUDRepository = mockk<AccountsCRUDRepository>(relaxUnitFun = true)
private val useCase = ApplyTokenListSortingUseCaseV2(
accountsCRUDRepository = accountsCRUDRepository,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private val userWalletId = UserWalletId(stringValue = "011")
@AfterEach
fun tearDown() {
clearMocks(accountsCRUDRepository)
}
@Test
fun `when tokens are empty then error should be received`() = runTest {
// Act
val actual = useCase(
sortedTokensIdsByAccount = emptyMap(),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
// Assert
val expected = TokenListSortingError.TokenListIsEmpty.left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder(inverse = true) {
accountsCRUDRepository.getAccountListSync(userWalletId = any())
accountsCRUDRepository.saveAccountsLocally(accountList = any())
accountsCRUDRepository.syncTokens(userWalletId = any())
}
}
@Test
fun `when getAccountListSync throws exception then error should be received`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet())
val exception = IllegalStateException("No internet connection")
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } throws exception
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()),
isGroupedByNetwork = true,
isSortedByBalance = false,
)
// Assert
val expected = TokenListSortingError.DataError(exception).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder { accountsCRUDRepository.getAccountListSync(userWalletId) }
coVerifyOrder(inverse = true) {
accountsCRUDRepository.saveAccountsLocally(accountList = any())
accountsCRUDRepository.syncTokens(userWalletId = any())
}
}
@Test
fun `when saveAccountsLocally throws exception then error should be received`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet())
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val exception = IllegalStateException("No internet connection")
coEvery { accountsCRUDRepository.saveAccountsLocally(accountList) } throws exception
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()),
isGroupedByNetwork = true,
isSortedByBalance = false,
).leftOrNull() as TokenListSortingError.DataError
// Assert
val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString()))
Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(accountList)
accountsCRUDRepository.syncTokens(userWalletId = userWalletId)
}
}
@Test
fun `when syncTokens throws exception then error should be received`() = runTest {
// Arrange
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = emptySet())
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val exception = IllegalStateException("No internet connection")
coEvery { accountsCRUDRepository.syncTokens(userWalletId) } throws exception
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to emptyList()),
isGroupedByNetwork = true,
isSortedByBalance = false,
).leftOrNull() as TokenListSortingError.DataError
// Assert
val expected = TokenListSortingError.DataError(IllegalStateException(exception.toString()))
Truth.assertThat(actual.cause).isInstanceOf(expected.cause::class.java)
Truth.assertThat(actual.cause).hasMessageThat().isEqualTo(expected.cause.message)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(accountList)
accountsCRUDRepository.syncTokens(userWalletId = userWalletId)
}
}
@Test
fun `when apply sorting for sorted and grouped list then correct args should be used`() = runTest {
// Arrange
val token1 = mockk<CryptoCurrency>(relaxed = true)
val token2 = mockk<CryptoCurrency>(relaxed = true)
val token3 = mockk<CryptoCurrency>(relaxed = true)
val accountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = setOf(token1, token2, token3),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val sortedTokens = setOf(token2, token3, token1)
val updatedAccountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = sortedTokens,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
)
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()),
isGroupedByNetwork = true,
isSortedByBalance = true,
)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(updatedAccountList)
accountsCRUDRepository.syncTokens(userWalletId)
}
}
@Test
fun `when apply sorting for unsorted and grouped list then correct args should be used`() = runTest {
// Arrange
val token1 = mockk<CryptoCurrency>(relaxed = true)
val token2 = mockk<CryptoCurrency>(relaxed = true)
val token3 = mockk<CryptoCurrency>(relaxed = true)
val accountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = setOf(token1, token2, token3),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val sortedTokens = setOf(token2, token3, token1)
val updatedAccountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = sortedTokens,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NETWORK,
)
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()),
isGroupedByNetwork = true,
isSortedByBalance = false,
)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(updatedAccountList)
accountsCRUDRepository.syncTokens(userWalletId)
}
}
@Test
fun `when apply sorting for sorted and ungrouped list then correct args should be used`() = runTest {
// Arrange
val token1 = mockk<CryptoCurrency>(relaxed = true)
val token2 = mockk<CryptoCurrency>(relaxed = true)
val token3 = mockk<CryptoCurrency>(relaxed = true)
val accountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = setOf(token1, token2, token3),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val sortedTokens = setOf(token2, token3, token1)
val updatedAccountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = sortedTokens,
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NONE,
)
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()),
isGroupedByNetwork = false,
isSortedByBalance = true,
)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(updatedAccountList)
accountsCRUDRepository.syncTokens(userWalletId)
}
}
@Test
fun `when apply sorting for unsorted and ungrouped list then correct args should be used`() = runTest {
// Arrange
val token1 = mockk<CryptoCurrency>(relaxed = true)
val token2 = mockk<CryptoCurrency>(relaxed = true)
val token3 = mockk<CryptoCurrency>(relaxed = true)
val accountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = setOf(token1, token2, token3),
sortType = TokensSortType.BALANCE,
groupType = TokensGroupType.NETWORK,
)
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val sortedTokens = setOf(token2, token3, token1)
val updatedAccountList = AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = sortedTokens,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
// Act
val actual = useCase(
sortedTokensIdsByAccount = mapOf(accountList.mainAccount to sortedTokens.toList()),
isGroupedByNetwork = false,
isSortedByBalance = false,
)
// Assert
val expected = Unit.right()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(updatedAccountList)
accountsCRUDRepository.syncTokens(userWalletId)
}
}
@Test
fun `when sorted tokens IDs do not contain all tokens IDs then error should be received`() = runTest {
// Arrange
val token1 = mockk<CryptoCurrency>(relaxed = true)
val token2 = mockk<CryptoCurrency>(relaxed = true)
val token3 = mockk<CryptoCurrency>(relaxed = true)
val customAccount = Account.CryptoPortfolio(
accountId = AccountId.forCryptoPortfolio(userWalletId, DerivationIndex(1).getOrNull()!!),
name = "Account 1",
icon = CryptoPortfolioIcon.ofDefaultCustomAccount(),
derivationIndex = 1,
cryptoCurrencies = setOf(token1, token2, token3),
)
.getOrNull()!!
val accountList = (AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = setOf(token1, token2, token3),
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
) + customAccount).getOrNull()!!
coEvery { accountsCRUDRepository.getAccountListSync(userWalletId) } returns accountList.some()
val sortedTokensIdsByAccount: Map<Account, List<CryptoCurrency>> = mapOf(
accountList.mainAccount to listOf(token3, token2, token1),
customAccount to listOf(token2, token1), // missing token3
)
val updatedAccountList = AccountList(
userWalletId = userWalletId,
accounts = listOf(
accountList.mainAccount.copy(cryptoCurrencies = setOf(token3, token2, token1)),
customAccount, // unchanged due to error
),
totalAccounts = accountList.totalAccounts,
sortType = TokensSortType.NONE,
groupType = TokensGroupType.NONE,
)
.getOrNull()!!
// Act
val actual = useCase(
sortedTokensIdsByAccount = sortedTokensIdsByAccount,
isGroupedByNetwork = false,
isSortedByBalance = false,
)
// Assert
val expected = TokenListSortingError.UnableToSortAccounts(
accountIds = listOf(customAccount.accountId.value),
).left()
Truth.assertThat(actual).isEqualTo(expected)
coVerifyOrder {
accountsCRUDRepository.getAccountListSync(userWalletId)
accountsCRUDRepository.saveAccountsLocally(updatedAccountList)
accountsCRUDRepository.syncTokens(userWalletId)
}
}
}

View file

@ -8,5 +8,7 @@ sealed class TokenListSortingError {
object UnableToSortTokenList : TokenListSortingError()
data class UnableToSortAccounts(val accountIds: List<String>) : TokenListSortingError()
data class DataError(val cause: Throwable) : TokenListSortingError()
}